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 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.
//! - CLEANLIB-157: split backup from write; append when multiple ecosystems
//!   share a single `--write-to` path so every snippet survives.

use std::path::{Path, PathBuf};
// CLEANLIB-157: append-mode writes so multiple ecosystems sharing one
// `--write-to` path accumulate instead of overwriting each other.
use std::io::Write;
use std::fs::OpenOptions;
use std::fs::File;
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
}

// 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.

/// Timestamped sibling backup before the first write to `target` this run.
/// Split from the old `write_with_backup` (CLEANLIB-157) so backup and emit
/// are separate steps when several ecosystems share one `--write-to` path.
pub fn create_backup(target: &Path, 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()
        );
    }
    Ok(())
}

/// Write `blob` to `target`. When `append` is true, bytes are added at EOF;
/// otherwise the file is truncated first (canonical per-ecosystem `--write`).
/// CLEANLIB-157: `--ecosystem=npm,pypi --write-to /path/file` must land every
/// ecosystem's snippet in the same file; the old `std::fs::write` path kept
/// only the last ecosystem.
pub fn write_file(target: &Path, blob: &str, append: bool) -> Result<()> {
    if append {
        let mut file: File = OpenOptions::new()
            .append(true) // accumulate per-ecosystem blobs on a shared `--write-to`
            .create(true) // create if doesn't exist
            .open(&target)?;
        file.write_all(blob.as_bytes())?;
        file.flush()?;
    } else {
        // Truncate path: each ecosystem's canonical file (`--write` without
        // `--write-to`) must start fresh; only shared `--write-to` uses append.
        let mut file: File = OpenOptions::new()
            .truncate(true)
            .write(true)
            .create(true)
            .open(&target)?;
        file.write_all(blob.as_bytes())?;
        file.flush()?;
    }
    Ok(())
}

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!(
                "cleanlib config init does not yet support ecosystem '{}' — supported ecosystems: '{}'.\nConfigure proxy manually for now.",
                eco_str,
                proxy::Ecosystem::supported_list()
            )
        })?;
        let proxy_cfg = proxy::emit(eco, &opts)?;
        prepared.push((eco, proxy_cfg));
    }

    // Emit / write phase — all entries previously validated above.
    // CLEANLIB-157: track which path was already backed up so
    // `--ecosystem=npm,pypi --write-to /same/file` creates one backup, not two.
    let mut backup: Option<PathBuf> = None;
    // CLEANLIB-157: first blob to a shared `--write-to` truncates; later
    // ecosystems on the same path append. Unused when each ecosystem writes
    // to its own canonical location (`--write` without `--write-to`).
    let mut first_write = true;
    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(),
        };
        
        // First ecosystem touching this path backs up once; later ecosystems
        // on the same `--write-to` skip backup and append below.
        if Some(&target) != backup.as_ref() {
            create_backup(&target, force)?;
            backup = Some(target.clone());
        }
        // Three write modes (CLEANLIB-157):
        // - `--write` only → each ecosystem's canonical file is truncated.
        // - `--write-to` shared path → truncate once, then append siblings.
        // - `--write --write-to` → explicit path wins; same truncate-then-append.
        if write && write_to.is_none() {
            write_file(&target, &proxy_cfg.config_blob, false)?;
        } else if first_write {
            write_file(&target, &proxy_cfg.config_blob, false)?;
            first_write = false;
        } else {
            write_file(&target, &proxy_cfg.config_blob, true)?;
        }
        eprintln!("wrote {} ({} bytes)", target.display(), proxy_cfg.config_blob.len());
    }

    Ok(())
}


#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    // 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-157 — `write_file` truncate vs append.

    #[test]
    fn write_file_truncate_creates_fresh_content() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("proxy.conf");
        write_file(&path, "npm-snippet\n", false).unwrap();
        assert_eq!(fs::read_to_string(&path).unwrap(), "npm-snippet\n");
    }

    #[test]
    fn write_file_truncate_replaces_prior_content() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("proxy.conf");
        write_file(&path, "old\n", false).unwrap();
        write_file(&path, "new\n", false).unwrap();
        assert_eq!(fs::read_to_string(&path).unwrap(), "new\n");
    }

    #[test]
    fn write_file_append_accumulates_after_truncate() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("proxy.conf");
        write_file(&path, "npm-snippet\n", false).unwrap();
        write_file(&path, "pypi-snippet\n", true).unwrap();
        assert_eq!(
            fs::read_to_string(&path).unwrap(),
            "npm-snippet\npypi-snippet\n"
        );
    }

    #[test]
    fn write_file_append_on_nonexistent_creates_file() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("proxy.conf");
        write_file(&path, "first\n", true).unwrap();
        assert_eq!(fs::read_to_string(&path).unwrap(), "first\n");
    }

    // CLEANLIB-157 — `create_backup` hygiene.

    #[test]
    fn create_backup_preserves_existing_content() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("proxy.conf");
        fs::write(&path, "prior\n").unwrap();
        create_backup(&path, false).unwrap();
        let backups: Vec<_> = fs::read_dir(dir.path())
            .unwrap()
            .filter_map(|e| e.ok())
            .map(|e| e.path())
            .filter(|p| {
                p.file_name()
                    .unwrap()
                    .to_string_lossy()
                    .contains("cleanlib-backup")
            })
            .collect();
        assert_eq!(backups.len(), 1);
        assert_eq!(fs::read_to_string(&backups[0]).unwrap(), "prior\n");
    }

    #[test]
    fn create_backup_skipped_with_force() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("proxy.conf");
        fs::write(&path, "prior\n").unwrap();
        create_backup(&path, true).unwrap();
        let backups = fs::read_dir(dir.path())
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| {
                e.file_name()
                    .to_string_lossy()
                    .contains("cleanlib-backup")
            })
            .count();
        assert_eq!(backups, 0);
    }

    #[test]
    fn create_backup_creates_parent_directory() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("nested").join("dir").join("proxy.conf");
        create_backup(&path, false).unwrap();
        assert!(path.parent().unwrap().is_dir());
    }
}