cleanlib-cli 0.1.5

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

use std::path::{Path, PathBuf};

use anyhow::Result;
use cleanlib_client::{config, transport, types};

use crate::render::terminal;

use super::scan::parse_packages_file;
use super::scan_exit_code;

pub async fn preview(
    policy_path: PathBuf,
    packages_path: PathBuf,
    ecosystem: String,
    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)?;

    // CLEANLIB-364 / CLEANLIB-367 — mixed-lockfile ecosystem filter also
    // applies to `policy preview`: send only entries matching --ecosystem so a
    // hypothetical policy is evaluated against the requested-eco subset only.
    // Silent drop would defeat the audit trail, so warn via stderr as `scan`
    // does. (Response side of policy-preview is decisions/serde-defaulted and
    // does not carry a per-decision ecosystem to filter; contract-wise the App
    // returns one decision per submitted package, so scrubbing the request is
    // sufficient here.)
    let parsed = parse_packages_file(&packages_path, &ecosystem)?;
    if !parsed.filtered_out.is_empty() {
        eprintln!(
            "warning: --ecosystem {} filtered {} packages-file entr{} of a different ecosystem",
            ecosystem,
            parsed.filtered_out.len(),
            if parsed.filtered_out.len() == 1 { "y" } else { "ies" },
        );
        for f in &parsed.filtered_out {
            eprintln!(
                "  - {}@{} (declared ecosystem: {})",
                f.name, f.version, f.declared_ecosystem,
            );
        }
    }
    let policy = parse_policy_file(&policy_path)?;
    let req = types::PolicyPreviewRequest {
        packages: parsed.packages,
        policy: Some(policy),
    };
    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 policy file — accepts JSON or TOML; TOML converts to a
/// `serde_json::Value` representation. YAML deferred (would require yaml dep).
pub fn parse_policy_file(path: &Path) -> Result<serde_json::Value> {
    let content = std::fs::read_to_string(path)
        .map_err(|e| anyhow::anyhow!("read {}: {}", path.display(), e))?;
    // Try JSON first
    if let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) {
        return Ok(v);
    }
    // Fall back to TOML
    let toml_val: toml::Value = toml::from_str(&content)
        .map_err(|e| anyhow::anyhow!("{}: not valid JSON; TOML parse: {}", path.display(), e))?;
    let json_val = serde_json::to_value(toml_val)
        .map_err(|e| anyhow::anyhow!("{}: TOML→JSON conversion: {}", path.display(), e))?;
    Ok(json_val)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_json_policy() {
        let dir = std::env::temp_dir();
        let p = dir.join("cleanlib-policy-json-test.json");
        std::fs::write(&p, r#"{"rules": [{"id": 1}]}"#).unwrap();
        let v = parse_policy_file(&p).unwrap();
        assert_eq!(v["rules"][0]["id"], 1);
        let _ = std::fs::remove_file(&p);
    }

    #[test]
    fn parse_toml_policy() {
        let dir = std::env::temp_dir();
        let p = dir.join("cleanlib-policy-toml-test.toml");
        std::fs::write(&p, "[meta]\nname = \"strict\"\n").unwrap();
        let v = parse_policy_file(&p).unwrap();
        assert_eq!(v["meta"]["name"], "strict");
        let _ = std::fs::remove_file(&p);
    }
}