cleanlib-cli 0.1.3

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! `cleanlib risk-accept` (cycle-7 Cli2). Migrates `cmd_risk_accept` from
//! `main.rs`.
//!
//! CLEANLIB-179..184 (v0.1.3 hygiene bundle) hardens the risk-acceptance flow:
//! reject blank required fields (179/180/181), back up an existing target
//! before overwriting (183), and report a missing target directory clearly
//! instead of a misleading "Permission denied" (184). The shell-level `!`
//! history-expansion gotcha (182) is documented in the `--justification` help
//! text in `main.rs` — nothing the CLI can do at runtime, because bash mangles
//! the argument before the process is ever exec'd.

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

use anyhow::Result;
use cleanlib_client::risk_acceptance;

/// CLEANLIB-179/180/181: a risk-acceptance record with a blank package,
/// version, or justification is schema-valid but operationally useless — it
/// can't be matched in CDP (empty package/version) or carries no security
/// rationale (empty justification). Reject it loudly instead of emitting a
/// valid-but-useless record. `trim()` also rejects whitespace-only input.
fn require_nonblank(value: &str, flag: &str, why: &str) -> Result<()> {
    if value.trim().is_empty() {
        anyhow::bail!("--{flag} must not be empty: {why}");
    }
    Ok(())
}

pub fn run(
    package: String,
    version: String,
    justification: String,
    proposed_by: Option<String>,
    write_to: Option<PathBuf>,
    force: bool,
) -> Result<()> {
    require_nonblank(
        &package,
        "package",
        "a risk-acceptance record with no package name cannot be matched against any package in CDP",
    )?;
    require_nonblank(
        &version,
        "version",
        "an empty version range cannot be matched against any package version in CDP",
    )?;
    require_nonblank(
        &justification,
        "justification",
        "the justification is the security rationale for the exception; an empty one creates a useless audit trail",
    )?;

    let rule = risk_acceptance::Rule {
        package,
        version_range: version,
        justification,
        proposed_by,
        proposed_at: chrono::Utc::now(),
    };

    let yaml = risk_acceptance::emit_yaml(&rule);

    match write_to {
        Some(path) => {
            // CLEANLIB-184: if the parent directory does not exist, say so
            // clearly. The previous code called `create_dir_all(parent)`, which
            // — for a path like /nonexistent/path/risk.yaml — fails trying to
            // mkdir under a non-writable root and surfaces the OS "Permission
            // denied (os error 13)", sending developers chasing permissions
            // when the real problem is the missing directory.
            if let Some(parent) = path.parent() {
                if !parent.as_os_str().is_empty() && !parent.exists() {
                    anyhow::bail!(
                        "directory does not exist: {} — create it first, or choose an existing directory",
                        parent.display()
                    );
                }
            }

            // CLEANLIB-183: back up an existing target (timestamped) before
            // overwriting, matching `config init`'s write_with_backup behavior,
            // unless --force is passed. Prevents silent, unrecoverable loss of a
            // prior risk-acceptance record.
            if path.exists() && !force {
                backup_existing(&path)?;
            }

            std::fs::write(&path, &yaml)?;
            eprintln!("wrote {} ({} bytes)", path.display(), yaml.len());
        }
        None => print!("{yaml}"),
    }

    Ok(())
}

/// Copy `target` to a timestamped `*.cleanlib-backup-<unix>` sibling. Mirrors
/// the backup naming in `config_init::write_with_backup` for CLEANLIB-183
/// parity.
fn backup_existing(target: &Path) -> Result<()> {
    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(())
}