cleanlib-cli 0.1.0

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! ANSI envelope renderer (cycle-7 Cli3). Migrates the cycle-4 §D.5
//! `style_decision()` palette out of `main.rs` and extends it with
//! envelope-shaped render helpers per cycle-7 entry §1.3 render table:
//!
//! | Surface | DENY | WARN | ALLOW |
//! |---|---|---|---|
//! | Bash pre-install gate | non-zero exit + remediation to stderr | yellow stderr + prompt | silent pass |
//!
//! `cleanlib-client::types::Verdict` is the canonical envelope; the v0.1.x
//! Phase-1 vocab (`ALLOWED_NO_FINDINGS` | `VECTOR_VERDICT` |
//! `DM_THRESHOLD_BLOCK` | `INSUFFICIENT_DATA`) is mapped to the universal
//! ALLOW/WARN/DENY render tier via `decision_tier()`.

use cleanlib_client::types::{PolicyDecision, Verdict};
use comfy_table::{presets, ContentArrangement, Table};
use is_terminal::IsTerminal;
use owo_colors::{OwoColorize, Style};

use super::sanitize::{mask_engine_tag, mask_fixture_label};

/// Render options threaded through every renderer entry-point. Honors the
/// NO_COLOR convention + an explicit `--no-color` CLI override.
#[derive(Debug, Clone, Copy, Default)]
#[allow(dead_code)]
pub struct RenderOpts {
    pub verbose: bool,
    pub no_color: bool,
}

#[allow(dead_code)]
impl RenderOpts {
    /// Returns true when ANSI styling should be emitted: TTY + no NO_COLOR
    /// env + no `--no-color` flag.
    pub fn use_color(&self) -> bool {
        !self.no_color
            && std::env::var("NO_COLOR").is_err()
            && std::io::stdout().is_terminal()
    }
}

/// Returns true if stdout is a TTY (color + table border rendering enabled).
/// Auto-disables aesthetic features for pipes / file redirects / CI runners.
pub fn stdout_is_tty() -> bool {
    std::io::stdout().is_terminal()
}

/// Three-tier render bucket for cross-surface consistency (cycle-7 entry §1.3).
/// Sister to `commands::scan_exit_code` — the wire enum vocab is broader (Phase
/// 1 has 5 distinct decision strings; cycle-7 envelope target is 3 tiers); the
/// CLI/Bash gate collapses to the 3-tier presentation layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum DecisionTier {
    Allow,
    Warn,
    Deny,
    Other,
}

/// Map any `decision`/`verdict` string from either the Phase-1 wire vocab
/// or the cycle-7 envelope vocab into the 3-tier render bucket.
#[allow(dead_code)]
pub fn decision_tier(decision: &str) -> DecisionTier {
    match decision {
        // Cycle-7 universal envelope
        "ALLOW" => DecisionTier::Allow,
        "WARN" => DecisionTier::Warn,
        "DENY" => DecisionTier::Deny,
        // Phase-1 verdict vocab → tier mapping
        "ALLOWED_NO_FINDINGS" => DecisionTier::Allow,
        "VECTOR_VERDICT" | "DM_THRESHOLD_BLOCK" => DecisionTier::Deny,
        "RISK_ACCEPTANCE_REQUIRED" => DecisionTier::Warn,
        _ => DecisionTier::Other,
    }
}

/// Style a decision string per cycle-4 §D.5 aesthetic-polish palette.
/// Returns the rendered string; auto-strips ANSI on non-TTY.
///
/// CLEANLIB-128 / Jira CLEANLIB-31a: the input is masked through
/// `mask_engine_tag` BEFORE rendering so internal codenames
/// (VECTOR_VERDICT / DM_THRESHOLD_BLOCK / llm_*) never reach the
/// customer-facing string. Color tier is derived from the original raw
/// label so the masked output still picks the right ANSI color
/// (Deny=red, Warn=yellow, Allow=green) per the 3-tier rule.
pub fn style_decision(decision: &str) -> String {
    let display = mask_engine_tag(decision);
    if !stdout_is_tty() {
        return display;
    }
    // Color tier derives from the raw label so masked VECTOR_VERDICT
    // ("Engine signal") still renders in DENY-red; sister of the cycle-7
    // 3-tier render bucket in `decision_tier()`.
    let style: Style = match decision_tier(decision) {
        DecisionTier::Allow => Style::new().green().bold(),
        DecisionTier::Deny => Style::new().red().bold(),
        DecisionTier::Warn => {
            // Bright-yellow specifically for RISK_ACCEPTANCE_REQUIRED to
            // distinguish it from plain WARN at the color layer.
            if decision == "RISK_ACCEPTANCE_REQUIRED" {
                Style::new().bright_yellow().bold()
            } else {
                Style::new().yellow().bold()
            }
        }
        DecisionTier::Other => {
            if decision == "INSUFFICIENT_DATA" {
                Style::new().blue().to_owned()
            } else {
                Style::new()
            }
        }
    };
    display.style(style).to_string()
}

/// Envelope-shaped verdict renderer for `cleanlib verdict <eco> <pkg> <ver>`
/// per cycle-7 entry §1.3. Emits the status header + body block.
/// stdout is for human-facing; binary stdout consumers (`fetch`) use stderr.
///
/// CLEANLIB-128 / Jira CLEANLIB-31a + CLEANLIB-21: sanitizes every
/// customer-facing field through `mask_engine_tag` (VECTOR_*/DM_*/llm_*)
/// and `mask_fixture_label` (mock/fixture/stub/test labels) before
/// emitting. Sister of the extension hover provider's
/// `maskEngineTag` wiring (extension repo `src/sanitize.ts` +
/// `src/extension.ts`).
pub fn render_verdict(verdict: &Verdict, _opts: &RenderOpts) {
    println!("verdict_id:       {}", verdict.verdict_id);
    // `verdict.verdict` carries the engine-tag label (VECTOR_VERDICT /
    // DM_THRESHOLD_BLOCK / ALLOWED_NO_FINDINGS / INSUFFICIENT_DATA);
    // style_decision now masks the engine-tag before color-styling.
    println!("verdict:          {}", style_decision(&verdict.verdict));
    // `source` may carry an engine-tag (e.g., "VECTOR_VERDICT") or a
    // fixture label (e.g., "mock_npm_fixture") for mock-cdp internal
    // test bearers — sanitize both layers; skip the line entirely if
    // mask_fixture_label hides it.
    let source_masked = mask_fixture_label(&mask_engine_tag(&verdict.source));
    if !source_masked.is_empty() {
        println!("source:           {}", source_masked);
    }
    println!("confidence:       {:.2}", verdict.confidence);
    println!("composite_score:  {}", verdict.composite_score);
    if !verdict.reasoning.is_empty() {
        println!("reasoning:        {}", verdict.reasoning);
    }
    if !verdict.suggested_actions.is_empty() {
        // Mask each suggested-action element so internal-codename
        // references inside the list stay hidden (CLEANLIB-21 hover
        // tooltip sister surface).
        let masked: Vec<String> = verdict
            .suggested_actions
            .iter()
            .map(|a| mask_engine_tag(a))
            .filter(|a| !mask_fixture_label(a).is_empty())
            .collect();
        if !masked.is_empty() {
            println!("suggested:        {}", masked.join("; "));
        }
    }
    if let Some(stale) = &verdict.stale_since_at {
        println!("stale_since_at:   {}", stale);
        if let Some(reason) = &verdict.staleness_reason {
            println!("staleness_reason: {}", reason);
        }
    }
    if let Some(at) = &verdict.computed_at {
        println!("as_of:            {}", at);
    }
}

/// Render policy decisions in either one-line form (single decision) or
/// tabular form (multiple decisions). Sister to the cycle-4 §D.5 polish
/// already in `main.rs::print_decisions_text`.
///
/// CLEANLIB-128 / Jira CLEANLIB-31a: `style_decision` masks the
/// engine-tag label before render; reason strings are passed through
/// `mask_engine_tag` so any embedded codename in the `reason` field is
/// also sanitized.
pub fn render_decisions(decisions: &[PolicyDecision]) {
    if decisions.is_empty() {
        println!("(no decisions returned)");
        return;
    }
    if decisions.len() == 1 {
        let d = &decisions[0];
        let masked_reason = mask_engine_tag(&d.reason);
        let suffix = if masked_reason.is_empty() {
            String::new()
        } else {
            format!("{}", masked_reason)
        };
        println!(
            "{}: {}@{}{}{}",
            d.ecosystem,
            d.package,
            d.version,
            style_decision(&d.decision),
            suffix
        );
        return;
    }
    let mut table = Table::new();
    let preset = if stdout_is_tty() {
        presets::UTF8_BORDERS_ONLY
    } else {
        presets::NOTHING
    };
    table
        .load_preset(preset)
        .set_content_arrangement(ContentArrangement::Dynamic)
        .set_header(vec!["ECOSYSTEM", "PACKAGE", "VERSION", "DECISION", "REASON"]);
    for d in decisions {
        table.add_row(vec![
            d.ecosystem.clone(),
            d.package.clone(),
            d.version.clone(),
            style_decision(&d.decision),
            mask_engine_tag(&d.reason),
        ]);
    }
    println!("{table}");
}

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

    #[test]
    fn decision_tier_phase1_vocab() {
        assert_eq!(decision_tier("ALLOWED_NO_FINDINGS"), DecisionTier::Allow);
        assert_eq!(decision_tier("VECTOR_VERDICT"), DecisionTier::Deny);
        assert_eq!(decision_tier("DM_THRESHOLD_BLOCK"), DecisionTier::Deny);
        assert_eq!(decision_tier("RISK_ACCEPTANCE_REQUIRED"), DecisionTier::Warn);
    }

    #[test]
    fn decision_tier_universal_envelope() {
        assert_eq!(decision_tier("ALLOW"), DecisionTier::Allow);
        assert_eq!(decision_tier("WARN"), DecisionTier::Warn);
        assert_eq!(decision_tier("DENY"), DecisionTier::Deny);
    }

    #[test]
    fn decision_tier_unknown_is_other() {
        assert_eq!(decision_tier("MAYBE"), DecisionTier::Other);
        assert_eq!(decision_tier(""), DecisionTier::Other);
    }

    #[test]
    fn render_opts_no_color_overrides_tty() {
        let opts = RenderOpts { verbose: false, no_color: true };
        assert!(!opts.use_color());
    }

    // CLEANLIB-128 / Jira CLEANLIB-31a — style_decision masks engine-tag
    // identifiers before render. NOTE: ANSI styling only fires when
    // stdout is a TTY; in test harness stdout is NOT a TTY so we get the
    // plain masked output (which is exactly what we want to assert).

    #[test]
    fn style_decision_masks_vector_verdict() {
        let out = style_decision("VECTOR_VERDICT");
        assert!(!out.contains("VECTOR_VERDICT"), "raw codename leaked: {out}");
        assert!(out.contains("Engine signal"));
    }

    #[test]
    fn style_decision_masks_dm_threshold_block() {
        let out = style_decision("DM_THRESHOLD_BLOCK");
        assert!(!out.contains("DM_THRESHOLD_BLOCK"), "raw codename leaked: {out}");
        assert!(out.contains("Policy decision"));
    }

    #[test]
    fn style_decision_passes_through_allow_deny_warn() {
        // Customer-facing universal-envelope vocab survives untouched.
        assert!(style_decision("ALLOW").contains("ALLOW"));
        assert!(style_decision("DENY").contains("DENY"));
        assert!(style_decision("WARN").contains("WARN"));
    }

    #[test]
    fn style_decision_masks_allowed_no_findings_to_plain() {
        // ALLOWED_NO_FINDINGS is NOT an engine-tag codename (it's a
        // Phase-1 vocab label and is acceptable customer-facing). It
        // must not be accidentally rewritten by the masker.
        let out = style_decision("ALLOWED_NO_FINDINGS");
        assert!(out.contains("ALLOWED_NO_FINDINGS"));
    }
}