Skip to main content

kindly_tools/
output.rs

1// Copyright 2025 Kindly Software Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//! Output formatting for scan results
15
16use 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            // Print remediations
120            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    // Print summary
135    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    // Threat breakdown
148    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            // Fall back to compact JSON format
189            match serde_json::to_string(&json_output) {
190                Ok(json_str) => println!("{}", json_str),
191                Err(_) => {
192                    // Last resort: print error JSON
193                    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}