cleanlib-cli 0.1.4

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 cleanlib_client::{CustomerState, Tier};
use comfy_table::{presets, ContentArrangement, Table};
use is_terminal::IsTerminal;
use owo_colors::{OwoColorize, Style};

use super::sanitize::{mask_engine_tag, mask_fixture_label};
use super::output::{decision_tier, decision_tier_str_with_severity, DecisionTier};
/// 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()
}


/// 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 --ecosystem <eco> --package <pkg> --version <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) {
    // CLEANLIB-178: lead with the customer-facing STATE derived from the wire
    // `source` (Compromised / Malicious / Ransomware-linked / Actively
    // exploited / Vulnerable / Blocked by policy / Not yet assessed / Clean).
    // This is the at-a-glance malicious-vs-vulnerable signal the cluster
    // exists to give — it makes "this is malware, remove it" read instantly
    // differently from "this has CVEs, upgrade it". Label / copy / emoji come
    // from the canonical taxonomy in `cleanlib_client::customer_state` (single
    // source of truth shared with the LSP + SDKs).
    render_customer_state(&verdict.source);

    // Cycle-14 DX-fix: the decision tier (ALLOW/WARN/DENY) is the policy gate
    // outcome — distinct from the state above (policy outranks findings). It
    // remains the exit-code driver (`verdict_exit_code`).
    let tier_str = decision_tier_str_with_severity(&verdict.verdict, verdict.severity.as_deref(), verdict.composite_score);
    println!("decision:         {}", style_decision(tier_str));
    println!("verdict_id:       {}", verdict.verdict_id);
    println!("verdict:          {}", style_decision(&verdict.verdict));
    // CLEANLIB-178: the raw `source:` line is removed. It used to print the
    // masked wire source (e.g. "Engine signal" for VECTOR_VERDICT), but the
    // post-176 wire variants (CVE_FINDING / CVE_FINDING_ON_KEV /
    // CVE_FINDING_ON_RANSOMWARE / MALICIOUS_TRIAGE) are internal codenames
    // (spec §0 rule 1) the masker doesn't cover — printing them raw would leak.
    // The customer-state header at the top is `source`'s customer-language
    // representation (label + copy), so the line is redundant as well as
    // leak-prone. Dropped on both counts.
    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();
        // Cycle-14 DX-fix: render suggested_actions as a bulleted list
        // instead of a semicolon-joined run-on string. Customer-impact
        // survey 2026-06-05 showed the joined form crowded multi-action
        // advice (the CLEANLIB-72 populator emits 3-4 strings per
        // ALLOW_BY_ABSENCE / policy-DENY response) into an unreadable wall.
        if let [single] = masked.as_slice() {
            println!("suggested:        {}", single);
        } else if !masked.is_empty() {
            println!("suggested:");
            for action in &masked {
                println!("  - {}", action);
            }
        }
    }
    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);
    }
}

/// CLEANLIB-178 — render the customer-facing state header derived from the
/// wire `source`. Colour follows the state's tier (block=red, warn=yellow,
/// clean=green) via the existing 3-tier ANSI palette; label + emoji + copy come
/// from the canonical `cleanlib_client::customer_state` taxonomy so the CLI,
/// LSP, extension, MCP, and SDKs all speak the same 8 states. The `source` is
/// the wire variant string (e.g. `CVE_FINDING_ON_RANSOMWARE`); the mapping is
/// customer-language only, so no codename can leak (the strings themselves are
/// the customer vocabulary, not engine tags — no `mask_engine_tag` needed).
fn render_customer_state(source: &str) {
    let state = CustomerState::from_wire(source);
    let header = format!("{} {}", state.emoji(), state.label());
    let header = if stdout_is_tty() {
        let style = match state.tier() {
            Tier::Block => Style::new().red().bold(),
            Tier::Warn => Style::new().yellow().bold(),
            Tier::Clean => Style::new().green().bold(),
        };
        header.style(style).to_string()
    } else {
        header
    };
    println!("{header}");
    println!("  {}", state.copy());
}

/// 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"));
    }

    // ── CLEANLIB-178 customer-state header ──────────────────────────────────
    // render_customer_state writes to stdout; we assert the taxonomy wiring
    // (label + copy resolve from the wire source) via the shared module the
    // header calls — behavioral, not a source-grep (the same function the
    // renderer uses).
    #[test]
    fn customer_state_header_resolves_from_wire_source() {
        // The renderer derives its header through this exact path.
        let s = CustomerState::from_wire("CVE_FINDING_ON_RANSOMWARE");
        assert_eq!(s.label(), "Ransomware-linked");
        assert_eq!(s.tier(), Tier::Block);
        // malicious-vs-vulnerable: a malware triage reads differently.
        assert_eq!(CustomerState::from_wire("MALICIOUS_TRIAGE").label(), "Malicious");
        assert_eq!(CustomerState::from_wire("CVE_FINDING").label(), "Vulnerable");
    }

    #[test]
    fn customer_state_header_never_leaks_wire_codename() {
        // Whatever wire source arrives, the rendered label/copy is customer
        // vocabulary — no engine tag leaks (the cluster's ROOT rule).
        for src in ["CVE_FINDING", "CVE_FINDING_ON_KEV", "MALICIOUS_TRIAGE", "DM_THRESHOLD_BLOCK"] {
            let s = CustomerState::from_wire(src);
            assert!(!s.label().contains(src));
            assert!(!s.copy().contains(src));
        }
    }

    #[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"));
    }

    // Cycle-18 CLEANLIB-371 — style_decision renders `INSUFFICIENT_DATA`
    // as the canonical customer-facing label. Before this change, the
    // text `verdict:` line leaked the raw wire codename (masker was a
    // no-op on this token). The color tier still derives from the raw
    // input so the `Other` blue-color fallback still fires — verified by
    // running the CLI text output and confirming the ANSI escape wraps
    // "Not yet assessed" (color check is covered end-to-end at the
    // integration test layer).
    #[test]
    fn style_decision_masks_insufficient_data_to_customer_label() {
        let out = style_decision("INSUFFICIENT_DATA");
        assert!(
            !out.contains("INSUFFICIENT_DATA"),
            "raw wire codename leaked in text render: {out}"
        );
        assert!(
            out.contains("Not yet assessed"),
            "canonical customer label missing: {out}"
        );
    }
}