cleanlib-cli 0.1.0

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! `cleanlib scan` (cycle-7 Cli2). Migrates `cmd_scan` from `main.rs`.

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(())
}

/// Parse a packages file: one `name@version` per line; `#`-prefix lines and
/// blank lines ignored. Returns the parsed list or an error pointing at
/// the first malformed line.
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))?;
    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)
}

#[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);
    }
}