1use std::fmt::Write as _;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use anyhow::Result;
6use serde_json::json;
7
8use crate::model::{OutputFormat, RenderOptions, ScanReport};
9use crate::scanner::totals_by_category;
10
11pub fn render(report: &ScanReport, format: OutputFormat) -> Result<String> {
17 render_with_options(report, format, RenderOptions::default())
18}
19
20pub fn render_with_options(
26 report: &ScanReport,
27 format: OutputFormat,
28 options: RenderOptions,
29) -> Result<String> {
30 let display_report = if options.redact_paths {
31 redact_report(report)
32 } else {
33 report.clone()
34 };
35 match format {
36 OutputFormat::Table => Ok(render_table(&display_report)),
37 OutputFormat::Json => Ok(serde_json::to_string_pretty(&display_report)?),
38 OutputFormat::Jsonl => render_jsonl(&display_report),
39 OutputFormat::Html => Ok(render_html(&display_report)),
40 }
41}
42
43#[must_use]
45pub fn human_bytes(bytes: u64) -> String {
46 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
47 let mut unit = 0;
48 let mut divisor = 1_u64;
49 while bytes / divisor >= 1024 && unit < UNITS.len() - 1 {
50 divisor = divisor.saturating_mul(1024);
51 unit += 1;
52 }
53 if unit == 0 {
54 format!("{bytes} {}", UNITS[unit])
55 } else {
56 let whole = bytes / divisor;
57 let hundredths = bytes % divisor * 100 / divisor;
58 format!("{whole}.{hundredths:02} {}", UNITS[unit])
59 }
60}
61
62fn render_table(report: &ScanReport) -> String {
63 let mut output = String::new();
64 let _ = writeln!(
65 output,
66 "{:<23} {:>12} {:>10} PATH",
67 "CATEGORY", "SIZE", "AGE"
68 );
69 let _ = writeln!(output, "{}", "-".repeat(92));
70 for candidate in &report.candidates {
71 let _ = writeln!(
72 output,
73 "{:<23} {:>12} {:>10} {}",
74 candidate.category,
75 human_bytes(candidate.bytes),
76 human_age(candidate.modified_at_unix),
77 candidate.path.display()
78 );
79 }
80 for candidate in &report.review_candidates {
81 let _ = writeln!(
82 output,
83 "{:<23} {:>12} {:>10} {}",
84 "review-only",
85 human_bytes(candidate.bytes),
86 human_age(candidate.modified_at_unix),
87 candidate.path.display()
88 );
89 }
90 let _ = writeln!(output, "{}", "-".repeat(92));
91 let _ = writeln!(
92 output,
93 "{} candidates, {} reclaimable",
94 report.candidates.len(),
95 human_bytes(report.total_bytes)
96 );
97 if !report.review_candidates.is_empty() {
98 let _ = writeln!(
99 output,
100 "{} review-only observations, {} watched",
101 report.review_candidates.len(),
102 human_bytes(report.review_total_bytes)
103 );
104 }
105 for warning in &report.warnings {
106 let _ = writeln!(output, "warning: {warning}");
107 }
108 output
109}
110
111fn render_jsonl(report: &ScanReport) -> Result<String> {
112 let mut output = String::new();
113 for candidate in &report.candidates {
114 writeln!(
115 output,
116 "{}",
117 serde_json::to_string(&json!({"type": "candidate", "candidate": candidate}))?
118 )?;
119 }
120 for candidate in &report.review_candidates {
121 writeln!(
122 output,
123 "{}",
124 serde_json::to_string(&json!({"type": "review_candidate", "candidate": candidate}))?
125 )?;
126 }
127 writeln!(
128 output,
129 "{}",
130 serde_json::to_string(&json!({
131 "type": "summary",
132 "candidate_count": report.candidates.len(),
133 "total_bytes": report.total_bytes,
134 "review_candidate_count": report.review_candidates.len(),
135 "review_total_bytes": report.review_total_bytes,
136 "warnings": report.warnings,
137 }))?
138 )?;
139 Ok(output)
140}
141
142fn render_html(report: &ScanReport) -> String {
143 let totals = totals_by_category(report);
144 let mut cards = String::new();
145 for (category, bytes) in totals {
146 let _ = write!(
147 cards,
148 "<article><span>{}</span><strong>{}</strong></article>",
149 escape_html(&category.to_string()),
150 human_bytes(bytes)
151 );
152 }
153
154 let mut rows = String::new();
155 for candidate in &report.candidates {
156 let _ = write!(
157 rows,
158 "<tr><td>{}</td><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td></tr>",
159 escape_html(&candidate.category.to_string()),
160 escape_html(&candidate.path.to_string_lossy()),
161 human_bytes(candidate.bytes),
162 human_age(candidate.modified_at_unix),
163 escape_html(&candidate.reason)
164 );
165 }
166 for candidate in &report.review_candidates {
167 let _ = write!(
168 rows,
169 "<tr><td>review-only</td><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td></tr>",
170 escape_html(&candidate.path.to_string_lossy()),
171 human_bytes(candidate.bytes),
172 human_age(candidate.modified_at_unix),
173 escape_html(&candidate.reason)
174 );
175 }
176
177 let mut warnings = String::new();
178 for warning in &report.warnings {
179 let _ = write!(warnings, "<li>{}</li>", escape_html(warning));
180 }
181 format!(
182 r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>devclean scan report</title><style>
183 :root{{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui;background:#08111f;color:#ecf3ff}}body{{margin:0}}main{{max-width:1180px;margin:auto;padding:48px 24px}}h1{{font-size:42px;margin-bottom:8px}}.lead{{color:#aebdd2}}.summary{{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin:28px 0}}article{{background:#111d31;border:1px solid #263854;border-radius:14px;padding:16px}}article span{{display:block;color:#94a7c2;font-size:12px}}article strong{{font-size:22px}}.total{{color:#5fe0a5}}.review{{color:#ffcf72}}.table{{overflow:auto;border:1px solid #263854;border-radius:14px}}table{{width:100%;border-collapse:collapse;min-width:900px}}th,td{{padding:12px;text-align:left;border-bottom:1px solid #263854}}th{{background:#15233a;color:#cbd9ed}}td{{color:#b7c5da}}code{{color:#dbe9ff}}.warnings{{color:#ffcf72}}</style></head><body><main><h1>devclean scan</h1><p class="lead">Read-only inventory of rebuildable development artifacts.</p><p class="total"><strong>{}</strong> across {} safe candidates</p><p class="review"><strong>{}</strong> across {} review-only observations</p><section class="summary">{}</section><div class="table"><table><thead><tr><th>Category</th><th>Path</th><th>Size</th><th>Age</th><th>Evidence</th></tr></thead><tbody>{}</tbody></table></div><section class="warnings"><h2>Warnings</h2><ul>{}</ul></section></main></body></html>"#,
184 human_bytes(report.total_bytes),
185 report.candidates.len(),
186 human_bytes(report.review_total_bytes),
187 report.review_candidates.len(),
188 cards,
189 rows,
190 warnings
191 )
192}
193
194fn redact_report(report: &ScanReport) -> ScanReport {
195 let mut redacted = report.clone();
196 for candidate in &mut redacted.candidates {
197 candidate.path = redact_path(&candidate.path, &report.roots);
198 }
199 for candidate in &mut redacted.review_candidates {
200 candidate.path = redact_path(&candidate.path, &report.roots);
201 }
202 for observation in &mut redacted.learning_observations {
203 observation.path = redact_path(&observation.path, &report.roots);
204 }
205 redacted.roots = report
206 .roots
207 .iter()
208 .enumerate()
209 .map(|(index, _)| PathBuf::from(format!("<root:{}>", index + 1)))
210 .collect();
211 redacted.warnings = report
212 .warnings
213 .iter()
214 .map(|warning| redact_text(warning, &report.roots))
215 .collect();
216 redacted
217}
218
219fn redact_text(value: &str, roots: &[PathBuf]) -> String {
220 let mut redacted = value.to_owned();
221 for (index, root) in roots.iter().enumerate() {
222 let root_text = root.to_string_lossy();
223 redacted = redacted.replace(root_text.as_ref(), &format!("<root:{}>", index + 1));
224 }
225 if let Some(base) = directories::BaseDirs::new() {
226 let home = base.home_dir().to_string_lossy();
227 redacted = redacted.replace(home.as_ref(), "<home>");
228 }
229 redacted
230}
231
232fn redact_path(path: &Path, roots: &[PathBuf]) -> PathBuf {
233 for (index, root) in roots.iter().enumerate() {
234 if let Ok(relative) = path.strip_prefix(root) {
235 return PathBuf::from(format!("<root:{}>", index + 1)).join(relative);
236 }
237 }
238 if let Some(base) = directories::BaseDirs::new() {
239 if let Ok(relative) = path.strip_prefix(base.home_dir()) {
240 return PathBuf::from("<home>").join(relative);
241 }
242 }
243 PathBuf::from("<external>").join(path.file_name().unwrap_or_default())
244}
245
246fn human_age(modified_at_unix: Option<u64>) -> String {
247 let Some(modified) = modified_at_unix else {
248 return "unknown".to_owned();
249 };
250 let now = SystemTime::now()
251 .duration_since(UNIX_EPOCH)
252 .map_or(modified, |duration| duration.as_secs());
253 let seconds = now.saturating_sub(modified);
254 if seconds >= 86_400 {
255 format!("{}d", seconds / 86_400)
256 } else if seconds >= 3_600 {
257 format!("{}h", seconds / 3_600)
258 } else if seconds >= 60 {
259 format!("{}m", seconds / 60)
260 } else {
261 format!("{seconds}s")
262 }
263}
264
265fn escape_html(value: &str) -> String {
266 value
267 .replace('&', "&")
268 .replace('<', "<")
269 .replace('>', ">")
270 .replace('"', """)
271 .replace('\'', "'")
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::model::{Candidate, Category, Confidence, ScanReport};
278
279 fn report(path: &str) -> ScanReport {
280 ScanReport {
281 roots: vec![PathBuf::from("/private/project")],
282 candidates: vec![Candidate {
283 category: Category::NodeModules,
284 path: PathBuf::from(path),
285 bytes: 1024,
286 reason: "test".to_owned(),
287 modified_at_unix: None,
288 confidence: Confidence::Safe,
289 }],
290 review_candidates: Vec::new(),
291 learning_observations: Vec::new(),
292 warnings: Vec::new(),
293 total_bytes: 1024,
294 review_total_bytes: 0,
295 observed_total_bytes: 0,
296 protect_git_tracked: true,
297 }
298 }
299
300 #[test]
301 fn human_bytes_should_use_binary_units() {
302 assert_eq!(human_bytes(1024), "1.00 KiB");
303 }
304
305 #[test]
306 fn render_html_should_escape_candidate_paths() -> Result<()> {
307 let output = render(&report("/tmp/<script>"), OutputFormat::Html)?;
308
309 assert!(!output.contains("<script>"));
310 Ok(())
311 }
312
313 #[test]
314 fn render_should_redact_paths_in_json() -> Result<()> {
315 let output = render_with_options(
316 &report("/private/project/node_modules"),
317 OutputFormat::Json,
318 RenderOptions { redact_paths: true },
319 )?;
320
321 assert!(!output.contains("/private/project"));
322 Ok(())
323 }
324
325 #[test]
326 fn render_should_redact_paths_inside_warnings() -> Result<()> {
327 let mut input = report("/private/project/node_modules");
328 input
329 .warnings
330 .push("protected /private/project/node_modules".to_owned());
331
332 let output = render_with_options(
333 &input,
334 OutputFormat::Json,
335 RenderOptions { redact_paths: true },
336 )?;
337
338 assert!(!output.contains("/private/project"));
339 Ok(())
340 }
341}