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 config init` (cycle-7 Cli2). Migrates `cmd_config_init` and
//! `write_with_backup` out of `main.rs`.
//!
//! CLEANLIB-132 / Jira CLEANLIB-26 + CLEANLIB-30 hardening (cycle-10):
//! - Two-pass shape (validate-all-first, then emit) so a single invalid
//!   ecosystem in a comma list fails before any side effect (no partial
//!   stdout, no orphan `.npmrc.cleanlib-backup-*`).
//! - Stable-order dedup of the ecosystem list so duplicate entries
//!   (`--ecosystem=npm,npm`) process exactly once + create exactly one
//!   backup file.

use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::Result;
use cleanlib_client::{config, proxy};

/// Stable-order dedup of an ecosystem-string list. CLEANLIB-30 close.
/// Preserves first-occurrence order so `--ecosystem=npm,pypi,npm` yields
/// `[npm, pypi]` (not `[pypi, npm]`).
fn dedup_preserving_order(input: &[String]) -> Vec<String> {
    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
    let mut out: Vec<String> = Vec::with_capacity(input.len());
    for s in input {
        let trimmed = s.trim().to_string();
        if trimmed.is_empty() {
            continue;
        }
        if seen.insert(trimmed.clone()) {
            out.push(trimmed);
        }
    }
    out
}

pub fn run(
    ecosystems: Vec<String>,
    scope: Option<String>,
    write: bool,
    write_to: Option<PathBuf>,
    inline_token: bool,
    force: bool,
) -> Result<()> {
    let path = config::default_path();
    let cfg = config::load_with_env_overrides(path.as_deref())?;

    // CLEANLIB-129 / Jira CLEANLIB-28 close: refuse `--inline-token` when
    // no usable API key is configured. Pre-fix behavior was to emit
    // `_authToken=` with an empty value → broken `.npmrc` → npm install
    // would auth-fail silently. Sister of CLEANLIB-27 login fail-loud
    // shape (`[[feedback_substrate_state_fresh_read_before_banking]]`).
    if inline_token {
        let has_usable_key = cfg
            .auth
            .api_key
            .as_deref()
            .map(|k| !k.trim().is_empty())
            .unwrap_or(false);
        if !has_usable_key {
            anyhow::bail!(
                "CLIENT_INLINE_TOKEN_NO_KEY — `--inline-token` requires a non-empty API key.\n\
                 Run `cleanlib login --api-key <KEY>` first, set CLEANLIB_ENRICH_BEARER in your\n\
                 environment, or drop `--inline-token` to emit a `${{CLEANLIBRARY_API_KEY}}`\n\
                 placeholder instead (resolves at runtime from the env tier)."
            );
        }
    }

    let opts = proxy::EmitOptions {
        endpoint: cfg.endpoint.url.clone(),
        scope,
        inline_token,
        api_key: cfg.auth.api_key.clone(),
    };

    let write_mode = write || write_to.is_some();

    // CLEANLIB-30 close: stable-order dedup so duplicates in
    // `--ecosystem=npm,npm` produce exactly one render + one backup.
    let ecosystems = dedup_preserving_order(&ecosystems);

    // CLEANLIB-26 close: validate-all phase — parse every ecosystem AND
    // call `proxy::emit` for each BEFORE we touch stdout / disk. Any
    // error here propagates with zero side effects; the customer sees a
    // clean error message and no orphan output / no spurious backups.
    let mut prepared: Vec<(proxy::Ecosystem, proxy::ProxyConfig)> = Vec::with_capacity(ecosystems.len());
    for eco_str in &ecosystems {
        let eco = proxy::Ecosystem::parse(eco_str).ok_or_else(|| {
            anyhow::anyhow!(
                "unknown ecosystem: {} (locked Phase 1 Tier A vocab: npm | pypi | go per matrix §8)",
                eco_str
            )
        })?;
        let proxy_cfg = proxy::emit(eco, &opts)?;
        prepared.push((eco, proxy_cfg));
    }

    // Emit / write phase — all entries previously validated above.
    for (eco, proxy_cfg) in prepared {
        if !write_mode {
            println!(
                "# === {} ({}) ===",
                eco.as_str(),
                if proxy_cfg.canonical_location.as_os_str().is_empty() {
                    "no canonical config file; shell-snippet form".to_string()
                } else {
                    proxy_cfg.canonical_location.display().to_string()
                }
            );
            print!("{}", proxy_cfg.config_blob);
            println!();
            continue;
        }

        let target: PathBuf =
            match (&write_to, proxy_cfg.canonical_location.as_os_str().is_empty()) {
                (Some(p), _) => p.clone(),
                (None, true) => {
                    eprintln!(
                        "# {}: no canonical config file; printing shell-snippet to stdout",
                        eco.as_str()
                    );
                    print!("{}", proxy_cfg.config_blob);
                    continue;
                }
                (None, false) => proxy_cfg.canonical_location.clone(),
            };

        write_with_backup(&target, &proxy_cfg.config_blob, force)?;
        eprintln!("wrote {} ({} bytes)", target.display(), proxy_cfg.config_blob.len());
    }

    Ok(())
}

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

    // CLEANLIB-132 / Jira CLEANLIB-30 — stable-order dedup.

    #[test]
    fn dedup_strips_consecutive_duplicates() {
        let input = vec!["npm".to_string(), "npm".to_string()];
        assert_eq!(dedup_preserving_order(&input), vec!["npm".to_string()]);
    }

    #[test]
    fn dedup_strips_interleaved_duplicates() {
        let input = vec![
            "npm".to_string(),
            "pypi".to_string(),
            "npm".to_string(),
            "go".to_string(),
            "pypi".to_string(),
        ];
        assert_eq!(
            dedup_preserving_order(&input),
            vec!["npm".to_string(), "pypi".to_string(), "go".to_string()]
        );
    }

    #[test]
    fn dedup_preserves_first_occurrence_order() {
        let input = vec!["go".to_string(), "npm".to_string(), "pypi".to_string()];
        assert_eq!(
            dedup_preserving_order(&input),
            vec!["go".to_string(), "npm".to_string(), "pypi".to_string()]
        );
    }

    #[test]
    fn dedup_trims_whitespace_and_drops_empty() {
        let input = vec![
            "npm".to_string(),
            "  pypi  ".to_string(),
            "".to_string(),
            "  ".to_string(),
            "pypi".to_string(),
        ];
        assert_eq!(
            dedup_preserving_order(&input),
            vec!["npm".to_string(), "pypi".to_string()]
        );
    }

    #[test]
    fn dedup_empty_input_yields_empty() {
        let input: Vec<String> = Vec::new();
        assert!(dedup_preserving_order(&input).is_empty());
    }

    // CLEANLIB-132 / Jira CLEANLIB-26 — transactional output behavior.
    // The validate-all-first phase is encoded in `run()`'s structure;
    // testing it end-to-end here requires a HOME isolation harness which
    // is out-of-scope for this hygiene-bundle dispatch. The empirical
    // reproduce gate in §2 of the dispatch verifies the behavior at the
    // process boundary post-merge. Dedup tests above are the unit-level
    // gate for CLEANLIB-30. CLEANLIB-29 is verified via the clap-level
    // `arg_required_else_help` flag set in main.rs.
}

pub fn write_with_backup(target: &Path, blob: &str, force: bool) -> Result<()> {
    if let Some(parent) = target.parent() {
        if !parent.as_os_str().is_empty() {
            std::fs::create_dir_all(parent)?;
        }
    }

    if target.exists() && !force {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        let backup_path = target.with_extension(format!(
            "{}cleanlib-backup-{}",
            target
                .extension()
                .map(|e| format!("{}.", e.to_string_lossy()))
                .unwrap_or_default(),
            ts
        ));
        std::fs::copy(target, &backup_path)?;
        eprintln!(
            "backed up existing {}{}",
            target.display(),
            backup_path.display()
        );
    }

    std::fs::write(target, blob)?;
    Ok(())
}