1use colored::Colorize;
17use comfy_table::{presets, ContentArrangement, Table};
18use kindly_guard_server::{Severity, Threat};
19use std::path::PathBuf;
20use std::time::Duration;
21
22#[derive(Debug, PartialEq, Eq)]
23pub enum OutputFormat {
24 Table,
25 Json,
26 Brief,
27}
28
29impl OutputFormat {
30 pub fn from_str(s: &str) -> anyhow::Result<Self> {
31 match s.to_lowercase().as_str() {
32 "table" => Ok(Self::Table),
33 "json" => Ok(Self::Json),
34 "brief" => Ok(Self::Brief),
35 _ => anyhow::bail!(
36 "Invalid output format: {}. Valid options: table, json, brief",
37 s
38 ),
39 }
40 }
41}
42
43pub fn print_scan_results(
44 results: &[(PathBuf, Vec<Threat>)],
45 total_files: usize,
46 total_threats: usize,
47 duration: Duration,
48 format: OutputFormat,
49) {
50 match format {
51 OutputFormat::Table => print_table_results(results, total_files, total_threats, duration),
52 OutputFormat::Json => print_json_results(results, total_files, total_threats, duration),
53 OutputFormat::Brief => print_brief_results(results, total_files, total_threats, duration),
54 }
55}
56
57fn print_table_results(
58 results: &[(PathBuf, Vec<Threat>)],
59 total_files: usize,
60 total_threats: usize,
61 duration: Duration,
62) {
63 println!("\n{}", "=== Scan Results ===".bold().cyan());
64
65 if results.is_empty() {
66 println!("\n{}", "✓ No threats detected!".green().bold());
67 } else {
68 println!(
69 "\n{} threats found in {} files",
70 total_threats.to_string().red().bold(),
71 results.len()
72 );
73
74 for (path, threats) in results {
75 println!(
76 "\n{}: {} threats",
77 path.display().to_string().yellow(),
78 threats.len().to_string().red()
79 );
80
81 let mut table = Table::new();
82 table
83 .load_preset(presets::UTF8_FULL)
84 .set_content_arrangement(ContentArrangement::Dynamic)
85 .set_header(vec!["Type", "Severity", "Location", "Description"]);
86
87 for threat in threats {
88 let severity_color = match threat.severity {
89 Severity::Critical => "red",
90 Severity::High => "yellow",
91 Severity::Medium => "blue",
92 Severity::Low => "white",
93 };
94
95 let location = match &threat.location {
96 kindly_guard_server::scanner::Location::Text { offset, length } => {
97 format!("offset: {offset}, len: {length}")
98 },
99 kindly_guard_server::scanner::Location::Json { path } => {
100 format!("JSON path: {path}")
101 },
102 kindly_guard_server::scanner::Location::Binary { offset } => {
103 format!("binary offset: {offset}")
104 },
105 };
106
107 table.add_row(vec![
108 threat.threat_type.to_string(),
109 format!("{:?}", threat.severity)
110 .color(severity_color)
111 .to_string(),
112 location,
113 threat.description.clone(),
114 ]);
115 }
116
117 println!("{table}");
118
119 let remediations: Vec<_> = threats
121 .iter()
122 .filter_map(|t| t.remediation.as_ref())
123 .collect();
124
125 if !remediations.is_empty() {
126 println!("\n{}", "Suggested Remediations:".bold());
127 for (i, remediation) in remediations.iter().enumerate() {
128 println!(" {}. {}", i + 1, remediation);
129 }
130 }
131 }
132 }
133
134 println!("\n{}", "=== Summary ===".bold().cyan());
136 println!("Files scanned: {}", total_files.to_string().bright_blue());
137 println!(
138 "Threats found: {}",
139 if total_threats > 0 {
140 total_threats.to_string().red()
141 } else {
142 total_threats.to_string().green()
143 }
144 );
145 println!("Scan duration: {:.2}s", duration.as_secs_f64());
146
147 if !results.is_empty() {
149 let mut threat_counts = std::collections::HashMap::new();
150 for (_, threats) in results {
151 for threat in threats {
152 *threat_counts.entry(threat.threat_type.clone()).or_insert(0) += 1;
153 }
154 }
155
156 println!("\n{}", "Threat Breakdown:".bold());
157 for (threat_type, count) in threat_counts {
158 println!(" {threat_type}: {count}");
159 }
160 }
161}
162
163fn print_json_results(
164 results: &[(PathBuf, Vec<Threat>)],
165 total_files: usize,
166 total_threats: usize,
167 duration: Duration,
168) {
169 let json_output = serde_json::json!({
170 "summary": {
171 "files_scanned": total_files,
172 "threats_found": total_threats,
173 "duration_ms": duration.as_millis(),
174 },
175 "results": results.iter().map(|(path, threats)| {
176 serde_json::json!({
177 "file": path.to_string_lossy(),
178 "threat_count": threats.len(),
179 "threats": threats,
180 })
181 }).collect::<Vec<_>>(),
182 });
183
184 match serde_json::to_string_pretty(&json_output) {
185 Ok(json_str) => println!("{}", json_str),
186 Err(e) => {
187 eprintln!("Error serializing output to JSON: {}", e);
188 match serde_json::to_string(&json_output) {
190 Ok(json_str) => println!("{}", json_str),
191 Err(_) => {
192 println!(r#"{{"error": "Failed to serialize scan results"}}"#);
194 },
195 }
196 },
197 }
198}
199
200fn print_brief_results(
201 results: &[(PathBuf, Vec<Threat>)],
202 total_files: usize,
203 total_threats: usize,
204 duration: Duration,
205) {
206 if results.is_empty() {
207 println!("{}", "✓ Clean".green().bold());
208 } else {
209 println!("{}", "✗ Threats detected".red().bold());
210 for (path, threats) in results {
211 println!("{}: {} threats", path.display(), threats.len());
212 }
213 }
214
215 println!(
216 "\nScanned {} files in {:.2}s | {} threats found",
217 total_files,
218 duration.as_secs_f64(),
219 total_threats
220 );
221}