cleanlib-cli 0.1.1

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. Accepts two formats — auto-detected from the
/// first non-whitespace character:
///
/// 1. **JSON array** (when the file starts with `[`) — each element is
///    `{"name": "...", "version": "..."}`. Cycle-14 DX-fix: a common
///    customer-facing shape (e.g., output of `npm ls --json | jq ...`)
///    that the text-only parser previously rejected.
///
/// 2. **Plain text** (default) — one `name@version` per line; `#`-prefix
///    lines and blank lines ignored.
///
/// Returns the parsed list or an error pointing at the first malformed
/// line (text) or a JSON parse error (array).
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() {
        // Cycle-14 DX-fix: customers commonly produce package lists via
        // tools that emit JSON (e.g., `npm ls --json | jq ...`). The
        // parser auto-detects a leading `[` and uses JSON-array parsing.
        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"}]"#); // missing version
        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);
    }
}