Skip to content

Commit 3f3e78f

Browse files
committed
Implement JSON and CSV output serialization
1 parent 2dd024f commit 3f3e78f

File tree

10 files changed

+191
-37
lines changed

10 files changed

+191
-37
lines changed

Cargo.lock

+22
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ clap-verbosity-flag = "3.0.2"
3636
env_logger = "0.11.6"
3737
log = "0.4.25"
3838
tempfile = "3.16.0"
39+
csv = "1.3.1"
3940

4041
[dev-dependencies]
4142
assert_cmd = "2.0.14"

src/check.rs

+59-9
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
use crate::{fetch_license_infos, license_info::LicenseInfo, CheckOutput, CondaDenyCheckConfig};
1+
use crate::{
2+
fetch_license_infos,
3+
license_info::{LicenseInfo, LicenseState},
4+
CheckOutput, CondaDenyCheckConfig, OutputFormat,
5+
};
26
use anyhow::{Context, Result};
37
use colored::Colorize;
48
use log::debug;
9+
use serde::Serialize;
10+
use serde_json::json;
511
use std::io::Write;
612

713
fn check_license_infos(config: &CondaDenyCheckConfig) -> Result<CheckOutput> {
@@ -20,14 +26,58 @@ fn check_license_infos(config: &CondaDenyCheckConfig) -> Result<CheckOutput> {
2026
pub fn check<W: Write>(check_config: CondaDenyCheckConfig, mut out: W) -> Result<()> {
2127
let (safe_dependencies, unsafe_dependencies) = check_license_infos(&check_config)?;
2228

23-
writeln!(
24-
out,
25-
"{}",
26-
format_check_output(
27-
safe_dependencies,
28-
unsafe_dependencies.clone(),
29-
)
30-
)?;
29+
match check_config.output_format {
30+
OutputFormat::Pretty => {
31+
writeln!(
32+
out,
33+
"{}",
34+
format_check_output(safe_dependencies, unsafe_dependencies.clone(),)
35+
)?;
36+
}
37+
OutputFormat::Json => {
38+
let json_output = json!({
39+
"safe": safe_dependencies,
40+
"unsafe": unsafe_dependencies,
41+
});
42+
writeln!(out, "{}", json_output)?;
43+
}
44+
OutputFormat::Csv => {
45+
#[derive(Debug, Clone, Serialize)]
46+
struct LicenseInfoWithSafety {
47+
package_name: String,
48+
version: String,
49+
license: LicenseState,
50+
platform: Option<String>,
51+
build: String,
52+
safe: bool,
53+
}
54+
55+
let mut writer = csv::WriterBuilder::new().from_writer(vec![]);
56+
57+
for (license_info, is_safe) in unsafe_dependencies
58+
.iter()
59+
.map(|x: &LicenseInfo| (x, false))
60+
.chain(safe_dependencies.iter().map(|x: &LicenseInfo| (x, true)))
61+
{
62+
let extended_info = LicenseInfoWithSafety {
63+
package_name: license_info.package_name.clone(),
64+
version: license_info.version.clone(),
65+
license: license_info.license.clone(),
66+
platform: license_info.platform.clone(),
67+
build: license_info.build.clone(),
68+
safe: is_safe,
69+
};
70+
writer.serialize(&extended_info).with_context(|| {
71+
format!(
72+
"Failed to serialize the following LicenseInfo to CSV: {:?}",
73+
extended_info
74+
)
75+
})?;
76+
}
77+
78+
out.write_all(&writer.into_inner()?)?;
79+
}
80+
}
3181

3282
if !unsafe_dependencies.is_empty() {
3383
Err(anyhow::anyhow!("Unsafe licenses found"))

src/cli.rs

+18-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use clap_verbosity_flag::{ErrorLevel, Verbosity};
55
use clap::Parser;
66
use rattler_conda_types::Platform;
77

8+
use crate::OutputFormat;
9+
810
#[derive(Parser, Debug)]
911
#[command(name = "conda-deny", about = "Check and list licenses of pixi and conda environments", version = env!("CARGO_PKG_VERSION"))]
1012
pub struct Cli {
@@ -40,12 +42,16 @@ pub enum CondaDenyCliConfig {
4042
environment: Option<Vec<String>>,
4143

4244
/// Check against OSI licenses instead of custom license whitelists.
43-
#[arg(short, long)]
45+
#[arg(long)]
4446
osi: Option<bool>,
4547

4648
/// Ignore when encountering pypi packages instead of failing.
4749
#[arg(long)]
4850
ignore_pypi: Option<bool>,
51+
52+
/// Output format
53+
#[arg(short, long, global = true)]
54+
output: Option<OutputFormat>,
4955
},
5056
/// List all packages and their licenses in your conda or pixi environment
5157
List {
@@ -68,6 +74,10 @@ pub enum CondaDenyCliConfig {
6874
/// Ignore when encountering pypi packages instead of failing.
6975
#[arg(long)]
7076
ignore_pypi: Option<bool>,
77+
78+
/// Output format
79+
#[arg(short, long, global = true)]
80+
output: Option<OutputFormat>,
7181
},
7282
}
7383

@@ -106,6 +116,13 @@ impl CondaDenyCliConfig {
106116
CondaDenyCliConfig::List { ignore_pypi, .. } => *ignore_pypi,
107117
}
108118
}
119+
120+
pub fn output_format(&self) -> Option<OutputFormat> {
121+
match self {
122+
CondaDenyCliConfig::Check { output, .. } => *output,
123+
CondaDenyCliConfig::List { output, .. } => *output,
124+
}
125+
}
109126
}
110127

111128
#[cfg(test)]

src/conda_deny_config.rs

+8-6
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::vec;
77
use std::{fs::File, io::Read};
88

99
use crate::license_whitelist::IgnorePackage;
10+
use crate::OutputFormat;
1011

1112
#[derive(Debug, Deserialize)]
1213
pub struct CondaDenyTomlConfig {
@@ -47,12 +48,6 @@ pub enum LockfileSpec {
4748
Multiple(Vec<PathBuf>),
4849
}
4950

50-
#[derive(Debug, Deserialize)]
51-
struct PixiEnvironmentEntry {
52-
_file: String,
53-
_environments: Vec<String>,
54-
}
55-
5651
#[derive(Debug, Deserialize)]
5752
pub struct CondaDeny {
5853
#[serde(rename = "license-whitelist")]
@@ -71,6 +66,8 @@ pub struct CondaDeny {
7166
pub safe_licenses: Option<Vec<String>>,
7267
#[serde(rename = "ignore-packages")]
7368
pub ignore_packages: Option<Vec<IgnorePackage>>,
69+
#[serde(rename = "output-format")]
70+
pub output_format: Option<OutputFormat>,
7471
}
7572

7673
impl CondaDenyTomlConfig {
@@ -132,6 +129,10 @@ impl CondaDenyTomlConfig {
132129
self.tool.conda_deny.ignore_pypi
133130
}
134131

132+
pub fn get_output_format(&self) -> Option<OutputFormat> {
133+
self.tool.conda_deny.output_format
134+
}
135+
135136
pub fn empty() -> Self {
136137
CondaDenyTomlConfig {
137138
tool: Tool {
@@ -144,6 +145,7 @@ impl CondaDenyTomlConfig {
144145
ignore_pypi: None,
145146
safe_licenses: None,
146147
ignore_packages: None,
148+
output_format: None,
147149
},
148150
},
149151
}

src/lib.rs

+29-7
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use license_whitelist::{get_license_information_from_toml_config, IgnorePackage}
1919
use anyhow::{Context, Result};
2020
use log::debug;
2121
use rattler_conda_types::Platform;
22+
use serde::Deserialize;
2223
use spdx::Expression;
2324

2425
use crate::license_info::LicenseInfos;
@@ -29,19 +30,33 @@ pub enum CondaDenyConfig {
2930
List(CondaDenyListConfig),
3031
}
3132

33+
#[derive(Debug, Clone, clap::ValueEnum, Default, Deserialize, Copy)]
34+
#[serde(rename_all = "kebab-case")]
35+
pub enum OutputFormat {
36+
#[serde(rename = "pretty")]
37+
#[default]
38+
Pretty,
39+
#[serde(rename = "json")]
40+
Json,
41+
#[serde(rename = "csv")]
42+
Csv,
43+
}
44+
3245
/// Configuration for the check command
3346
#[derive(Debug)]
3447
pub struct CondaDenyCheckConfig {
3548
pub lockfile_or_prefix: LockfileOrPrefix,
3649
pub osi: bool,
3750
pub safe_licenses: Vec<Expression>,
3851
pub ignore_packages: Vec<IgnorePackage>,
52+
pub output_format: OutputFormat,
3953
}
4054

4155
/// Shared configuration between check and list commands
4256
#[derive(Debug)]
4357
pub struct CondaDenyListConfig {
4458
pub lockfile_or_prefix: LockfileOrPrefix,
59+
pub output_format: OutputFormat,
4560
}
4661

4762
#[derive(Debug, Clone)]
@@ -94,6 +109,7 @@ fn get_lockfile_or_prefix(
94109
}))
95110
}
96111
} else if !lockfile.is_empty() && !prefix.is_empty() {
112+
// TODO: Specified prefixes override lockfiles
97113
Err(anyhow::anyhow!(
98114
"Both lockfiles and conda prefixes provided. Please only provide either or."
99115
))
@@ -113,7 +129,7 @@ fn get_lockfile_or_prefix(
113129
))
114130
} else if environments.is_some() {
115131
Err(anyhow::anyhow!(
116-
"Cannot specify environments and conda prefixes at the same time"
132+
"Cannot specify pixi environments and conda prefixes at the same time"
117133
))
118134
} else if ignore_pypi.is_some() {
119135
Err(anyhow::anyhow!(
@@ -202,13 +218,17 @@ pub fn get_config_options(
202218
toml_config.get_ignore_pypi()
203219
};
204220

221+
let output_format = if cli_config.output_format().is_some() {
222+
cli_config.output_format()
223+
} else {
224+
toml_config.get_output_format()
225+
};
226+
205227
let lockfile_or_prefix =
206228
get_lockfile_or_prefix(lockfile, prefix, platforms, environments, ignore_pypi)?;
207229

208230
let config = match cli_config {
209-
CondaDenyCliConfig::Check {
210-
osi, ..
211-
} => {
231+
CondaDenyCliConfig::Check { osi, .. } => {
212232
// defaults to false
213233
let osi = if osi.is_some() {
214234
osi
@@ -234,11 +254,13 @@ pub fn get_config_options(
234254
osi,
235255
safe_licenses,
236256
ignore_packages,
257+
output_format: output_format.unwrap_or(OutputFormat::Pretty),
237258
})
238259
}
239-
CondaDenyCliConfig::List { .. } => {
240-
CondaDenyConfig::List(CondaDenyListConfig { lockfile_or_prefix })
241-
}
260+
CondaDenyCliConfig::List { .. } => CondaDenyConfig::List(CondaDenyListConfig {
261+
lockfile_or_prefix,
262+
output_format: output_format.unwrap_or(OutputFormat::Pretty),
263+
}),
242264
};
243265

244266
Ok(config)

src/license_info.rs

+15-5
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ use std::path::{Path, PathBuf};
33
use anyhow::{Context, Result};
44
use colored::Colorize;
55
use rattler_conda_types::PackageRecord;
6+
use serde::Serialize;
67
use spdx::Expression;
78

8-
9-
109
use crate::{
1110
conda_meta_entry::{CondaMetaEntries, CondaMetaEntry},
1211
expression_utils::{check_expression_safety, extract_license_texts, parse_expression},
@@ -15,7 +14,7 @@ use crate::{
1514
CheckOutput, CondaDenyCheckConfig, LockfileSpec,
1615
};
1716

18-
#[derive(Debug, Clone)]
17+
#[derive(Debug, Clone, Serialize)]
1918
pub struct LicenseInfo {
2019
pub package_name: String,
2120
pub version: String,
@@ -107,6 +106,7 @@ impl Ord for LicenseInfo {
107106
}
108107
}
109108

109+
#[derive(Debug, Clone, Serialize)]
110110
pub struct LicenseInfos {
111111
pub license_infos: Vec<LicenseInfo>,
112112
}
@@ -254,19 +254,28 @@ impl LicenseInfos {
254254
}
255255
}
256256

257-
#[derive(Debug, Clone, PartialEq)]
257+
#[derive(Debug, Clone, PartialEq, Serialize)]
258258
#[allow(clippy::large_enum_variant)]
259259
pub enum LicenseState {
260+
#[serde(serialize_with = "serialize_expression")]
260261
Valid(Expression),
261262
Invalid(String),
262263
}
263264

265+
fn serialize_expression<S>(expr: &Expression, serializer: S) -> Result<S::Ok, S::Error>
266+
where
267+
S: serde::Serializer,
268+
{
269+
serializer.serialize_str(&format!("{:?}", expr))
270+
}
271+
264272
#[cfg(test)]
265273
mod tests {
266274

267275
use super::*;
268-
use crate::{conda_meta_entry::CondaMetaEntry, LockfileOrPrefix};
276+
use crate::{conda_meta_entry::CondaMetaEntry, LockfileOrPrefix, OutputFormat};
269277
use spdx::Expression;
278+
270279
#[test]
271280
fn test_license_info_from_conda_meta_entry() {
272281
let entry = CondaMetaEntry {
@@ -330,6 +339,7 @@ mod tests {
330339
osi: false,
331340
safe_licenses,
332341
ignore_packages,
342+
output_format: OutputFormat::Pretty,
333343
};
334344

335345
let (_, unsafe_dependencies) = unsafe_license_infos.check(&config).unwrap();

0 commit comments

Comments
 (0)