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 minijinja::{Environment, context};
7use serde::Serialize;
8use serde_json::json;
9
10use crate::model::{OutputFormat, RenderOptions, ScanReport};
11use crate::scanner::totals_by_category;
12
13/// Renders a scan report without path redaction.
14///
15/// # Errors
16///
17/// Returns an error when JSON serialization fails.
18pub fn render(report: &ScanReport, format: OutputFormat) -> Result<String> {
19    render_with_options(report, format, RenderOptions::default())
20}
21
22/// Renders a scan report with presentation controls.
23///
24/// # Errors
25///
26/// Returns an error when JSON serialization fails.
27pub 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/// Formats bytes with binary units.
46#[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}