use std::path::{Path, PathBuf};
use anyhow::Result;
use cleanlib_client::{config, transport, types};
use crate::render::terminal;
use super::scan_exit_code;
pub async fn run(ecosystem: String, packages_path: PathBuf, output: String) -> Result<()> {
let path = config::default_path();
let cfg = config::load_with_env_overrides(path.as_deref())?;
let client = transport::Client::from_config(&cfg)?;
let packages = parse_packages_file(&packages_path, &ecosystem)?;
let req = types::PolicyPreviewRequest {
packages,
policy: None,
};
let resp = client.policy_preview(&req).await?;
match output.as_str() {
"json" => println!("{}", serde_json::to_string_pretty(&resp)?),
_ => terminal::render_decisions(&resp.decisions),
}
let code = scan_exit_code(&resp.decisions);
if code != 0 {
std::process::exit(code);
}
Ok(())
}
pub fn parse_packages_file(path: &Path, ecosystem: &str) -> Result<Vec<types::PackageRef>> {
let content = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("read {}: {}", path.display(), e))?;
if content.trim_start().starts_with('[') {
return parse_packages_json_array(&content, path, ecosystem);
}
let mut packages = Vec::new();
for (lineno, raw_line) in content.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (name, version) = match line.rsplit_once('@') {
Some((n, v)) if !n.is_empty() && !v.is_empty() => (n.to_string(), v.to_string()),
_ => anyhow::bail!(
"{}:{}: malformed packages-file line (expected `name@version`): {}",
path.display(),
lineno + 1,
line
),
};
packages.push(types::PackageRef {
ecosystem: ecosystem.to_string(),
name,
version,
});
}
Ok(packages)
}
#[derive(serde::Deserialize)]
struct PackagesJsonEntry {
name: String,
version: String,
}
fn parse_packages_json_array(
content: &str,
path: &Path,
ecosystem: &str,
) -> Result<Vec<types::PackageRef>> {
let entries: Vec<PackagesJsonEntry> = serde_json::from_str(content).map_err(|e| {
anyhow::anyhow!(
"{}: failed to parse JSON-array packages file (expected `[{{\"name\":\"...\",\"version\":\"...\"}},...]`): {}",
path.display(),
e
)
})?;
Ok(entries
.into_iter()
.map(|e| types::PackageRef {
ecosystem: ecosystem.to_string(),
name: e.name,
version: e.version,
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_packages_file(contents: &str) -> PathBuf {
let dir = std::env::temp_dir();
let path = dir.join(format!(
"cleanlib-scan-test-{}.txt",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, contents).unwrap();
path
}
#[test]
fn parses_simple() {
let p = tmp_packages_file("lodash@4.17.21\ncors@2.8.5\n");
let pkgs = parse_packages_file(&p, "npm").unwrap();
assert_eq!(pkgs.len(), 2);
assert_eq!(pkgs[0].name, "lodash");
assert_eq!(pkgs[0].version, "4.17.21");
let _ = std::fs::remove_file(&p);
}
#[test]
fn skips_comments_and_blank() {
let p = tmp_packages_file("# header\n\ncors@2.8.5\n# trailer\n");
let pkgs = parse_packages_file(&p, "npm").unwrap();
assert_eq!(pkgs.len(), 1);
let _ = std::fs::remove_file(&p);
}
#[test]
fn malformed_line_errors() {
let p = tmp_packages_file("invalid-no-at-sign\n");
let err = parse_packages_file(&p, "npm").unwrap_err();
assert!(err.to_string().contains("malformed"));
let _ = std::fs::remove_file(&p);
}
#[test]
fn parses_json_array_input() {
let p = tmp_packages_file(
r#"[
{"name": "lodash", "version": "4.17.21"},
{"name": "express", "version": "4.18.2"}
]"#,
);
let pkgs = parse_packages_file(&p, "npm").unwrap();
assert_eq!(pkgs.len(), 2);
assert_eq!(pkgs[0].name, "lodash");
assert_eq!(pkgs[0].version, "4.17.21");
assert_eq!(pkgs[1].name, "express");
assert_eq!(pkgs[1].ecosystem, "npm");
let _ = std::fs::remove_file(&p);
}
#[test]
fn json_array_with_invalid_shape_reports_clean_error() {
let p = tmp_packages_file(r#"[{"name": "lodash"}]"#); let err = parse_packages_file(&p, "npm").unwrap_err();
assert!(
err.to_string().contains("parse JSON-array")
|| err.to_string().contains("missing field"),
"expected JSON parse error; got: {}",
err
);
let _ = std::fs::remove_file(&p);
}
}