Skip to main content

devclean/
render.rs

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
11/// Renders a scan report without path redaction.
12///
13/// # Errors
14///
15/// Returns an error when JSON serialization fails.
16pub fn render(report: &ScanReport, format: OutputFormat) -> Result<String> {
17    render_with_options(report, format, RenderOptions::default())
18}
19
20/// Renders a scan report with presentation controls.
21///
22/// # Errors
23///
24/// Returns an error when JSON serialization fails.
25pub 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/// Formats bytes with binary units.
44#[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        candidate.project_root = candidate
202            .project_root
203            .as_deref()
204            .map(|path| redact_path(path, &report.roots));
205    }
206    for observation in &mut redacted.learning_observations {
207        observation.path = redact_path(&observation.path, &report.roots);
208    }
209    redacted.roots = report
210        .roots
211        .iter()
212        .enumerate()
213        .map(|(index, _)| PathBuf::from(format!("<root:{}>", index + 1)))
214        .collect();
215    redacted.warnings = report
216        .warnings
217        .iter()
218        .map(|warning| redact_text(warning, &report.roots))
219        .collect();
220    redacted
221}
222
223fn redact_text(value: &str, roots: &[PathBuf]) -> String {
224    let mut redacted = value.to_owned();
225    for (index, root) in roots.iter().enumerate() {
226        let root_text = root.to_string_lossy();
227        redacted = redacted.replace(root_text.as_ref(), &format!("<root:{}>", index + 1));
228    }
229    if let Some(base) = directories::BaseDirs::new() {
230        let home = base.home_dir().to_string_lossy();
231        redacted = redacted.replace(home.as_ref(), "<home>");
232    }
233    redacted
234}
235
236fn redact_path(path: &Path, roots: &[PathBuf]) -> PathBuf {
237    for (index, root) in roots.iter().enumerate() {
238        if let Ok(relative) = path.strip_prefix(root) {
239            return PathBuf::from(format!("<root:{}>", index + 1)).join(relative);
240        }
241    }
242    if let Some(base) = directories::BaseDirs::new() {
243        if let Ok(relative) = path.strip_prefix(base.home_dir()) {
244            return PathBuf::from("<home>").join(relative);
245        }
246    }
247    PathBuf::from("<external>").join(path.file_name().unwrap_or_default())
248}
249
250fn human_age(modified_at_unix: Option<u64>) -> String {
251    let Some(modified) = modified_at_unix else {
252        return "unknown".to_owned();
253    };
254    let now = SystemTime::now()
255        .duration_since(UNIX_EPOCH)
256        .map_or(modified, |duration| duration.as_secs());
257    let seconds = now.saturating_sub(modified);
258    if seconds >= 86_400 {
259        format!("{}d", seconds / 86_400)
260    } else if seconds >= 3_600 {
261        format!("{}h", seconds / 3_600)
262    } else if seconds >= 60 {
263        format!("{}m", seconds / 60)
264    } else {
265        format!("{seconds}s")
266    }
267}
268
269fn escape_html(value: &str) -> String {
270    value
271        .replace('&', "&amp;")
272        .replace('<', "&lt;")
273        .replace('>', "&gt;")
274        .replace('"', "&quot;")
275        .replace('\'', "&#39;")
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::model::{Candidate, Category, Confidence, ReviewCandidate, ReviewRule, ScanReport};
282
283    fn report(path: &str) -> ScanReport {
284        ScanReport {
285            roots: vec![PathBuf::from("/private/project")],
286            candidates: vec![Candidate {
287                category: Category::NodeModules,
288                path: PathBuf::from(path),
289                bytes: 1024,
290                reason: "test".to_owned(),
291                modified_at_unix: None,
292                confidence: Confidence::Safe,
293                approved_rule: None,
294            }],
295            review_candidates: Vec::new(),
296            learning_observations: Vec::new(),
297            warnings: Vec::new(),
298            total_bytes: 1024,
299            review_total_bytes: 0,
300            observed_total_bytes: 0,
301            protect_git_tracked: true,
302        }
303    }
304
305    #[test]
306    fn human_bytes_should_use_binary_units() {
307        assert_eq!(human_bytes(1024), "1.00 KiB");
308    }
309
310    #[test]
311    fn render_html_should_escape_candidate_paths() -> Result<()> {
312        let output = render(&report("/tmp/<script>"), OutputFormat::Html)?;
313
314        assert!(!output.contains("<script>"));
315        Ok(())
316    }
317
318    #[test]
319    fn render_should_redact_paths_in_json() -> Result<()> {
320        let output = render_with_options(
321            &report("/private/project/node_modules"),
322            OutputFormat::Json,
323            RenderOptions { redact_paths: true },
324        )?;
325
326        assert!(!output.contains("/private/project"));
327        Ok(())
328    }
329
330    #[test]
331    fn render_should_redact_review_project_root_in_json() -> Result<()> {
332        let mut input = report("/private/project/node_modules");
333        input.review_candidates.push(ReviewCandidate {
334            path: PathBuf::from("/private/project/.build"),
335            bytes: 2048,
336            reason: "test".to_owned(),
337            modified_at_unix: None,
338            confidence: Confidence::Review,
339            suggested_rule: Some(ReviewRule::SwiftPackageBuild),
340            project_root: Some(PathBuf::from("/private/project")),
341            approved: false,
342        });
343
344        let output = render_with_options(
345            &input,
346            OutputFormat::Json,
347            RenderOptions { redact_paths: true },
348        )?;
349
350        assert!(!output.contains("/private/project"));
351        Ok(())
352    }
353
354    #[test]
355    fn render_should_redact_paths_inside_warnings() -> Result<()> {
356        let mut input = report("/private/project/node_modules");
357        input
358            .warnings
359            .push("protected /private/project/node_modules".to_owned());
360
361        let output = render_with_options(
362            &input,
363            OutputFormat::Json,
364            RenderOptions { redact_paths: true },
365        )?;
366
367        assert!(!output.contains("/private/project"));
368        Ok(())
369    }
370}