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};
#[derive(Debug, Clone, Copy, Default)]
#[allow(dead_code)]
pub struct RenderOpts {
pub verbose: bool,
pub no_color: bool,
}
#[allow(dead_code)]
impl RenderOpts {
pub fn use_color(&self) -> bool {
!self.no_color
&& std::env::var("NO_COLOR").is_err()
&& std::io::stdout().is_terminal()
}
}
pub fn stdout_is_tty() -> bool {
std::io::stdout().is_terminal()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum DecisionTier {
Allow,
Warn,
Deny,
Other,
}
#[allow(dead_code)]
pub fn decision_tier(decision: &str) -> DecisionTier {
match decision {
"ALLOW" => DecisionTier::Allow,
"WARN" => DecisionTier::Warn,
"DENY" => DecisionTier::Deny,
"ALLOWED_NO_FINDINGS" => DecisionTier::Allow,
"VECTOR_VERDICT" | "DM_THRESHOLD_BLOCK" => DecisionTier::Deny,
"RISK_ACCEPTANCE_REQUIRED" => DecisionTier::Warn,
_ => DecisionTier::Other,
}
}
pub fn style_decision(decision: &str) -> String {
let display = mask_engine_tag(decision);
if !stdout_is_tty() {
return display;
}
let style: Style = match decision_tier(decision) {
DecisionTier::Allow => Style::new().green().bold(),
DecisionTier::Deny => Style::new().red().bold(),
DecisionTier::Warn => {
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()
}
pub fn render_verdict(verdict: &Verdict, _opts: &RenderOpts) {
println!("verdict_id: {}", verdict.verdict_id);
println!("verdict: {}", style_decision(&verdict.verdict));
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() {
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);
}
}
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());
}
#[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() {
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() {
let out = style_decision("ALLOWED_NO_FINDINGS");
assert!(out.contains("ALLOWED_NO_FINDINGS"));
}
}