1use std::fmt::Write as _;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use anyhow::Result;
6use minijinja::{Environment, context};
7use serde::Serialize;
8use serde_json::json;
9
10use crate::model::{OutputFormat, RenderOptions, ScanReport};
11use crate::scanner::totals_by_category;
12
13pub fn render(report: &ScanReport, format: OutputFormat) -> Result<String> {
19 render_with_options(report, format, RenderOptions::default())
20}
21
22pub fn render_with_options(
28 report: &ScanReport,
29 format: OutputFormat,
30 options: RenderOptions,
31) -> Result<String> {
32 let display_report = if options.redact_paths {
33 redact_report(report)
34 } else {
35 report.clone()
36 };
37 match format {
38 OutputFormat::Table => Ok(render_table(&display_report)),
39 OutputFormat::Json => Ok(serde_json::to_string_pretty(&display_report)?),
40 OutputFormat::Jsonl => render_jsonl(&display_report),
41 OutputFormat::Html => render_html(&display_report),
42 }
43}
44
45#[must_use]
47pub fn human_bytes(bytes: u64) -> String {
48 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
49 let mut unit = 0;
50 let mut divisor = 1_u64;
51 while bytes / divisor >= 1024 && unit < UNITS.len() - 1 {
52 divisor = divisor.saturating_mul(1024);
53 unit += 1;
54 }
55 if unit == 0 {
56 format!("{bytes} {}", UNITS[unit])
57 } else {
58 let whole = bytes / divisor;
59 let hundredths = bytes % divisor * 100 / divisor;
60 format!("{whole}.{hundredths:02} {}", UNITS[unit])
61 }
62}
63
64fn render_table(report: &ScanReport) -> String {
65 let mut output = String::new();
66 let _ = writeln!(
67 output,
68 "{:<23} {:>12} {:>10} PATH",
69 "CATEGORY", "SIZE", "AGE"
70 );
71 let _ = writeln!(output, "{}", "-".repeat(92));
72 for candidate in &report.candidates {
73 let _ = writeln!(
74 output,
75 "{:<23} {:>12} {:>10} {}",
76 candidate.category,
77 human_bytes(candidate.bytes),
78 human_age(candidate.modified_at_unix),
79 candidate.path.display()
80 );
81 }
82 for candidate in &report.review_candidates {
83 let _ = writeln!(
84 output,
85 "{:<23} {:>12} {:>10} {}",
86 "review-only",
87 human_bytes(candidate.bytes),
88 human_age(candidate.modified_at_unix),
89 candidate.path.display()
90 );
91 }
92 let _ = writeln!(output, "{}", "-".repeat(92));
93 let _ = writeln!(
94 output,
95 "{} candidates, {} reclaimable",
96 report.candidates.len(),
97 human_bytes(report.total_bytes)
98 );
99 if !report.review_candidates.is_empty() {
100 let _ = writeln!(
101 output,
102 "{} review-only observations, {} watched",
103 report.review_candidates.len(),
104 human_bytes(report.review_total_bytes)
105 );
106 }
107 for warning in &report.warnings {
108 let _ = writeln!(output, "warning: {warning}");
109 }
110 output
111}
112
113fn render_jsonl(report: &ScanReport) -> Result<String> {
114 let mut output = String::new();
115 for candidate in &report.candidates {
116 writeln!(
117 output,
118 "{}",
119 serde_json::to_string(&json!({"type": "candidate", "candidate": candidate}))?
120 )?;
121 }
122 for candidate in &report.review_candidates {
123 writeln!(
124 output,
125 "{}",
126 serde_json::to_string(&json!({"type": "review_candidate", "candidate": candidate}))?
127 )?;
128 }
129 for observation in &report.learning_observations {
130 writeln!(
131 output,
132 "{}",
133 serde_json::to_string(
134 &json!({"type": "learning_observation", "observation": observation})
135 )?
136 )?;
137 }
138 writeln!(
139 output,
140 "{}",
141 serde_json::to_string(&json!({
142 "type": "summary",
143 "candidate_count": report.candidates.len(),
144 "total_bytes": report.total_bytes,
145 "review_candidate_count": report.review_candidates.len(),
146 "review_total_bytes": report.review_total_bytes,
147 "learning_observation_count": report.learning_observations.len(),
148 "observed_total_bytes": report.observed_total_bytes,
149 "warnings": report.warnings,
150 }))?
151 )?;
152 Ok(output)
153}
154
155#[derive(Serialize)]
156struct HtmlCard {
157 category: String,
158 bytes: String,
159}
160
161#[derive(Serialize)]
162struct HtmlRow {
163 category: String,
164 path: String,
165 size: String,
166 age: String,
167 evidence: String,
168}
169
170fn render_html(report: &ScanReport) -> Result<String> {
171 let cards = totals_by_category(report)
172 .into_iter()
173 .map(|(category, bytes)| HtmlCard {
174 category: category.to_string(),
175 bytes: human_bytes(bytes),
176 })
177 .collect::<Vec<_>>();
178 let mut rows = report
179 .candidates
180 .iter()
181 .map(|candidate| HtmlRow {
182 category: candidate.category.to_string(),
183 path: candidate.path.to_string_lossy().into_owned(),
184 size: human_bytes(candidate.bytes),
185 age: human_age(candidate.modified_at_unix),
186 evidence: candidate.reason.clone(),
187 })
188 .collect::<Vec<_>>();
189 rows.extend(report.review_candidates.iter().map(|candidate| HtmlRow {
190 category: "review-only".to_owned(),
191 path: candidate.path.to_string_lossy().into_owned(),
192 size: human_bytes(candidate.bytes),
193 age: human_age(candidate.modified_at_unix),
194 evidence: candidate.reason.clone(),
195 }));
196
197 let mut environment = Environment::new();
198 environment.add_template("report.html", include_str!("../templates/scan-report.html"))?;
199 Ok(environment.get_template("report.html")?.render(context! {
200 total_bytes => human_bytes(report.total_bytes),
201 candidate_count => report.candidates.len(),
202 review_total_bytes => human_bytes(report.review_total_bytes),
203 review_candidate_count => report.review_candidates.len(),
204 cards,
205 rows,
206 warnings => &report.warnings,
207 })?)
208}
209
210fn redact_report(report: &ScanReport) -> ScanReport {
211 let mut redacted = report.clone();
212 for candidate in &mut redacted.candidates {
213 candidate.path = redact_path(&candidate.path, &report.roots);
214 }
215 for candidate in &mut redacted.review_candidates {
216 candidate.path = redact_path(&candidate.path, &report.roots);
217 candidate.project_root = candidate
218 .project_root
219 .as_deref()
220 .map(|path| redact_path(path, &report.roots));
221 }
222 for observation in &mut redacted.learning_observations {
223 observation.path = redact_path(&observation.path, &report.roots);
224 }
225 redacted.roots = report
226 .roots
227 .iter()
228 .enumerate()
229 .map(|(index, _)| PathBuf::from(format!("<root:{}>", index + 1)))
230 .collect();
231 redacted.warnings = report
232 .warnings
233 .iter()
234 .map(|warning| redact_text(warning, &report.roots))
235 .collect();
236 redacted
237}
238
239fn redact_text(value: &str, roots: &[PathBuf]) -> String {
240 let mut redacted = value.to_owned();
241 for (index, root) in roots.iter().enumerate() {
242 let root_text = root.to_string_lossy();
243 redacted = redacted.replace(root_text.as_ref(), &format!("<root:{}>", index + 1));
244 }
245 if let Some(base) = directories::BaseDirs::new() {
246 let home = base.home_dir().to_string_lossy();
247 redacted = redacted.replace(home.as_ref(), "<home>");
248 }
249 redacted
250}
251
252fn redact_path(path: &Path, roots: &[PathBuf]) -> PathBuf {
253 for (index, root) in roots.iter().enumerate() {
254 if let Ok(relative) = path.strip_prefix(root) {
255 return PathBuf::from(format!("<root:{}>", index + 1)).join(relative);
256 }
257 }
258 if let Some(base) = directories::BaseDirs::new() {
259 if let Ok(relative) = path.strip_prefix(base.home_dir()) {
260 return PathBuf::from("<home>").join(relative);
261 }
262 }
263 PathBuf::from("<external>").join(path.file_name().unwrap_or_default())
264}
265
266fn human_age(modified_at_unix: Option<u64>) -> String {
267 let Some(modified) = modified_at_unix else {
268 return "unknown".to_owned();
269 };
270 let now = SystemTime::now()
271 .duration_since(UNIX_EPOCH)
272 .map_or(modified, |duration| duration.as_secs());
273 let seconds = now.saturating_sub(modified);
274 if seconds >= 86_400 {
275 format!("{}d", seconds / 86_400)
276 } else if seconds >= 3_600 {
277 format!("{}h", seconds / 3_600)
278 } else if seconds >= 60 {
279 format!("{}m", seconds / 60)
280 } else {
281 format!("{seconds}s")
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::model::{Candidate, Category, Confidence, ReviewCandidate, ReviewRule, ScanReport};
289
290 fn report(path: &str) -> ScanReport {
291 ScanReport {
292 roots: vec![PathBuf::from("/private/project")],
293 candidates: vec![Candidate {
294 category: Category::NodeModules,
295 path: PathBuf::from(path),
296 bytes: 1024,
297 reason: "test".to_owned(),
298 modified_at_unix: None,
299 confidence: Confidence::Safe,
300 approved_rule: None,
301 custom_rule: None,
302 }],
303 review_candidates: Vec::new(),
304 learning_observations: Vec::new(),
305 warnings: Vec::new(),
306 total_bytes: 1024,
307 review_total_bytes: 0,
308 observed_total_bytes: 0,
309 protect_git_tracked: true,
310 }
311 }
312
313 #[test]
314 fn human_bytes_should_use_binary_units() {
315 assert_eq!(human_bytes(1024), "1.00 KiB");
316 }
317
318 #[test]
319 fn category_display_should_respect_column_width() {
320 assert_eq!(format!("{:<23}", Category::NodeModules).len(), 23);
321 }
322
323 #[test]
324 fn render_html_should_escape_candidate_paths() -> Result<()> {
325 let output = render(&report("/tmp/<script>"), OutputFormat::Html)?;
326
327 assert!(!output.contains("<script>"));
328 Ok(())
329 }
330
331 #[test]
332 fn render_should_redact_paths_in_json() -> Result<()> {
333 let output = render_with_options(
334 &report("/private/project/node_modules"),
335 OutputFormat::Json,
336 RenderOptions { redact_paths: true },
337 )?;
338
339 assert!(!output.contains("/private/project"));
340 Ok(())
341 }
342
343 #[test]
344 fn render_should_redact_review_project_root_in_json() -> Result<()> {
345 let mut input = report("/private/project/node_modules");
346 input.review_candidates.push(ReviewCandidate {
347 path: PathBuf::from("/private/project/.build"),
348 bytes: 2048,
349 reason: "test".to_owned(),
350 modified_at_unix: None,
351 confidence: Confidence::Review,
352 suggested_rule: Some(ReviewRule::SwiftPackageBuild),
353 project_root: Some(PathBuf::from("/private/project")),
354 approved: false,
355 });
356
357 let output = render_with_options(
358 &input,
359 OutputFormat::Json,
360 RenderOptions { redact_paths: true },
361 )?;
362
363 assert!(!output.contains("/private/project"));
364 Ok(())
365 }
366
367 #[test]
368 fn render_jsonl_should_emit_learning_observations() -> Result<()> {
369 use crate::model::LearningObservation;
370
371 let mut input = report("/private/project/node_modules");
372 input.learning_observations.push(LearningObservation {
373 path: PathBuf::from("/private/project/target"),
374 category: Some(Category::RustTarget),
375 bytes: 4096,
376 reason: "test".to_owned(),
377 modified_at_unix: None,
378 confidence: Confidence::Safe,
379 });
380 input.observed_total_bytes = 4096;
381
382 let output = render(&input, OutputFormat::Jsonl)?;
383
384 assert!(output.contains(r#""type":"learning_observation""#));
385 assert!(output.contains(r#""learning_observation_count":1"#));
386 Ok(())
387 }
388
389 #[test]
390 fn render_should_redact_paths_inside_warnings() -> Result<()> {
391 let mut input = report("/private/project/node_modules");
392 input
393 .warnings
394 .push("protected /private/project/node_modules".to_owned());
395
396 let output = render_with_options(
397 &input,
398 OutputFormat::Json,
399 RenderOptions { redact_paths: true },
400 )?;
401
402 assert!(!output.contains("/private/project"));
403 Ok(())
404 }
405}