use owo_colors::{OwoColorize, Style};
use crate::finding::{Finding, Severity};
use crate::report::{CategoryReport, Grade, Report};
use super::OutputConfig;
pub fn print(report: &Report, cfg: &OutputConfig) -> anyhow::Result<()> {
let use_color = cfg.color && std::io::IsTerminal::is_terminal(&std::io::stdout());
if !cfg.quiet {
print_banner(report, use_color);
}
if report.findings.is_empty() {
print_clean(use_color);
return Ok(());
}
print_findings(report, cfg, use_color);
print_summary(report, use_color);
if !cfg.quiet {
print_hints(cfg);
}
Ok(())
}
fn print_banner(report: &Report, color: bool) {
let framework_label = if report.scanned_paths.iter().any(|p| p.contains(".claude")) {
"Claude Code"
} else {
"Agentic Framework"
};
let inner = format!(
" openclaw-scan v{} • {} Security ",
report.version, framework_label
);
let width = inner.len();
let top = format!("╔{}╗", "═".repeat(width));
let mid = format!("║{}║", inner);
let bot = format!("╚{}╝", "═".repeat(width));
if color {
println!("{}", top.bold());
println!("{}", mid.bold());
println!("{}", bot.bold());
} else {
println!("{top}");
println!("{mid}");
println!("{bot}");
}
println!();
for path in &report.scanned_paths {
let msg = format!("Scanning {} [{}]", path, report.scanned_at);
if color {
println!("{}", msg.dimmed());
} else {
println!("{msg}");
}
}
println!();
}
fn print_clean(color: bool) {
let msg = "✓ No security findings detected. Score: 100/100 (A)";
if color {
println!("{}", msg.green().bold());
} else {
println!("{msg}");
}
println!();
}
fn print_findings(report: &Report, cfg: &OutputConfig, color: bool) {
let header = "FINDINGS";
let separator = "─".repeat(54);
if color {
println!("{} {}", header.bold(), separator.dimmed());
} else {
println!("{header} {separator}");
}
for f in &report.findings {
print_finding_row(f, cfg.verbose, color);
}
println!();
}
fn print_finding_row(f: &Finding, verbose: bool, color: bool) {
let bullet = "●";
let sev_style = severity_style(f.severity, color);
let cat_label = format!("[{}]", f.category);
let location = match f.line {
Some(ln) => format!("{}:{}", f.path.display(), ln),
None => f.path.display().to_string(),
};
let location_short = truncate(&location, 40);
if color {
println!(
"{} {} {} {} {}",
bullet.style(sev_style),
f.severity.label().trim().style(sev_style),
cat_label.cyan(),
f.title,
location_short.dimmed()
);
} else {
println!(
"{bullet} {} {cat_label} {} {location_short}",
f.severity.label().trim(),
f.title
);
}
if verbose {
if let Some(ref ev) = f.evidence {
let evidence_line = format!(" Evidence: {ev}");
if color {
println!("{}", evidence_line.dimmed());
} else {
println!("{evidence_line}");
}
}
let desc_line = format!(" Description: {}", f.description);
if color {
println!("{}", desc_line.dimmed());
} else {
println!("{desc_line}");
}
let rem_line = format!(" Remediation: {}", f.remediation);
if color {
println!("{}", rem_line.yellow());
} else {
println!("{rem_line}");
}
println!();
}
}
fn print_summary(report: &Report, color: bool) {
let header = "SUMMARY";
let separator = "─".repeat(54);
if color {
println!("{} {}", header.bold(), separator.dimmed());
} else {
println!("{header} {separator}");
}
println!();
let grade_style = grade_style(report.overall_grade, color);
let score_line = format!(
" Score {} / 100 Grade: {}",
report.overall_score, report.overall_grade
);
if color {
println!("{}", score_line.bold().style(grade_style));
} else {
println!("{score_line}");
}
println!();
for cat_report in &report.categories {
print_category_row(cat_report, color);
}
println!();
let totals = format_totals(report);
if color {
println!(" {}", totals.bold());
} else {
println!(" {totals}");
}
println!();
}
fn print_category_row(cat: &CategoryReport, color: bool) {
let bar = score_bar(cat.score);
let counts = format_category_counts(cat);
let name = format!("{:<13}", cat.category.label().trim());
let score_str = format!("{:>3}", cat.score);
if color {
let bar_style = score_bar_style(cat.score);
println!(
" {} {} {} {}",
name.bold(),
bar.style(bar_style),
score_str,
counts.dimmed()
);
} else {
println!(" {name} {bar} {score_str} {counts}");
}
}
fn score_bar(score: u32) -> String {
let filled = (score / 10) as usize;
let empty = 10usize.saturating_sub(filled);
format!("{}{}", "█".repeat(filled), "░".repeat(empty))
}
fn format_counts(counts: &[(usize, &str)], sep: &str, none: &str) -> String {
let parts: Vec<String> = counts
.iter()
.filter(|&&(n, _)| n > 0)
.map(|&(n, label)| format!("{} {}", n, label))
.collect();
if parts.is_empty() {
none.to_string()
} else {
parts.join(sep)
}
}
fn format_category_counts(cat: &CategoryReport) -> String {
format_counts(
&[
(cat.critical_count, "critical"),
(cat.high_count, "high"),
(cat.medium_count, "medium"),
(cat.low_count, "low"),
(cat.info_count, "info"),
],
" ",
"—",
)
}
fn format_totals(report: &Report) -> String {
let total = report.total_critical
+ report.total_high
+ report.total_medium
+ report.total_low
+ report.total_info;
if total == 0 {
return "0 findings".to_string();
}
let detail = format_counts(
&[
(report.total_critical, "critical"),
(report.total_high, "high"),
(report.total_medium, "medium"),
(report.total_low, "low"),
(report.total_info, "info"),
],
" · ",
"",
);
format!("{} findings ({})", total, detail)
}
fn print_hints(cfg: &OutputConfig) {
if !cfg.verbose {
println!("Run `ocls -v` for remediation steps.");
}
if !cfg.json {
println!("Run `ocls --json` for machine-readable output.");
}
println!();
}
fn severity_style(sev: Severity, color: bool) -> Style {
if !color {
return Style::new();
}
match sev {
Severity::Critical => Style::new().red().bold(),
Severity::High => Style::new().yellow().bold(),
Severity::Medium => Style::new().yellow(),
Severity::Low => Style::new().blue(),
Severity::Info => Style::new().dimmed(),
}
}
fn grade_style(grade: Grade, color: bool) -> Style {
if !color {
return Style::new();
}
match grade {
Grade::A => Style::new().green().bold(),
Grade::B => Style::new().green(),
Grade::C => Style::new().yellow(),
Grade::D => Style::new().yellow().bold(),
Grade::F => Style::new().red().bold(),
}
}
fn score_bar_style(score: u32) -> Style {
match score {
75..=100 => Style::new().green(),
40..=74 => Style::new().yellow(),
_ => Style::new().red(),
}
}
fn truncate(s: &str, max: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max {
s.to_string()
} else {
let tail: String = chars[chars.len().saturating_sub(max.saturating_sub(1))..]
.iter()
.collect();
format!("…{}", tail)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::finding::{Category, Finding, Severity};
use crate::report::Report;
fn make_report_with(findings: Vec<Finding>) -> Report {
Report::build(findings, vec!["~/.openclaw".to_string()], "0.1.0")
}
#[test]
fn score_bar_full() {
assert_eq!(score_bar(100), "██████████");
}
#[test]
fn score_bar_empty() {
assert_eq!(score_bar(0), "░░░░░░░░░░");
}
#[test]
fn score_bar_half() {
assert_eq!(score_bar(50), "█████░░░░░");
}
#[test]
fn score_bar_67() {
assert_eq!(score_bar(67), "██████░░░░");
}
#[test]
fn truncate_short_string_unchanged() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn truncate_long_string_gets_ellipsis() {
let long = "a".repeat(50);
let result = truncate(&long, 20);
assert!(result.starts_with('…'));
assert!(result.chars().count() <= 20);
}
#[test]
fn format_totals_empty_report() {
let report = make_report_with(vec![]);
assert_eq!(format_totals(&report), "0 findings");
}
#[test]
fn format_totals_mixed() {
let findings = vec![
Finding::new(
Severity::Critical,
Category::SecretDetection,
"T",
"D",
"/f",
"R",
),
Finding::new(
Severity::High,
Category::ConfigSecurity,
"T",
"D",
"/f",
"R",
),
Finding::new(Severity::Info, Category::DataExposure, "T", "D", "/f", "R"),
];
let report = make_report_with(findings);
let totals = format_totals(&report);
assert!(totals.contains("3 findings"));
assert!(totals.contains("1 critical"));
assert!(totals.contains("1 high"));
assert!(totals.contains("1 info"));
}
#[test]
fn format_category_counts_all_zero_shows_dash() {
use crate::report::CategoryReport;
let cat = CategoryReport::build(Category::HookSecurity, vec![]);
assert_eq!(format_category_counts(&cat), "—");
}
#[test]
fn print_does_not_panic_on_empty_report() {
let report = make_report_with(vec![]);
let cfg = OutputConfig {
json: false,
quiet: true,
verbose: false,
color: false,
};
print(&report, &cfg).expect("print failed");
}
#[test]
fn print_does_not_panic_on_full_report() {
let findings = vec![
Finding::new(
Severity::Critical,
Category::SecretDetection,
"API key found",
"A secret was detected in history",
"/home/user/.openclaw/history.jsonl",
"Rotate the key immediately.",
)
.with_line(42)
.with_evidence("sk-ant****"),
Finding::new(
Severity::High,
Category::ConfigSecurity,
"Wildcard bash rule",
"Allow rule is too broad",
"/home/user/.openclaw/settings.json",
"Restrict the allow list.",
),
];
let report = make_report_with(findings);
let cfg = OutputConfig {
json: false,
quiet: false,
verbose: true,
color: false,
};
print(&report, &cfg).expect("print failed");
}
}