Skip to main content

lean_ctx_patterns/
fd.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::collections::HashMap;
4
5pub fn compress(output: &str) -> Option<String> {
6    let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
7    if lines.len() < 5 {
8        return None;
9    }
10
11    let mut by_dir: HashMap<String, Vec<String>> = HashMap::new();
12    let mut total_files = 0usize;
13
14    for line in &lines {
15        let path = line.trim();
16        if should_skip(path) {
17            continue;
18        }
19
20        total_files += 1;
21
22        if let Some(slash_pos) = path.rfind('/') {
23            let dir = &path[..slash_pos];
24            let file = &path[slash_pos + 1..];
25            by_dir
26                .entry(dir.to_string())
27                .or_default()
28                .push(file.to_string());
29        } else {
30            by_dir
31                .entry(".".to_string())
32                .or_default()
33                .push(path.to_string());
34        }
35    }
36
37    if total_files == 0 {
38        return None;
39    }
40
41    let mut result = format!("{total_files}F {}D:\n", by_dir.len());
42    let mut sorted_dirs: Vec<_> = by_dir.iter().collect();
43    sorted_dirs.sort_by_key(|(dir, _)| (*dir).clone());
44
45    for (dir, files) in &sorted_dirs {
46        result.push_str(&format!("\n{dir}/"));
47        let show: Vec<_> = files.iter().take(10).collect();
48        let mut line_buf = String::new();
49        for f in &show {
50            if line_buf.len() + f.len() + 1 > 60 {
51                result.push_str(&format!("\n  {line_buf}"));
52                line_buf.clear();
53            }
54            if !line_buf.is_empty() {
55                line_buf.push(' ');
56            }
57            line_buf.push_str(f);
58        }
59        if !line_buf.is_empty() {
60            result.push_str(&format!("\n  {line_buf}"));
61        }
62        if files.len() > 10 {
63            result.push_str(&format!("\n  ... +{} more", files.len() - 10));
64        }
65    }
66
67    Some(result)
68}
69
70fn should_skip(path: &str) -> bool {
71    const SKIP: &[&str] = &[
72        "node_modules/",
73        ".git/",
74        "target/debug/",
75        "target/release/",
76        "__pycache__/",
77        ".svelte-kit/",
78        ".next/",
79        "dist/",
80        ".DS_Store",
81    ];
82    SKIP.iter().any(|d| path.contains(d))
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn groups_files_by_directory() {
91        let output = "src/main.rs\nsrc/lib.rs\nsrc/util/helpers.rs\nsrc/util/math.rs\ntests/integration.rs\n";
92        let result = compress(output).unwrap();
93        assert!(result.contains("5F"), "should count 5 files");
94        assert!(result.contains("src/"), "should group by src");
95        assert!(result.contains("tests/"), "should group by tests");
96    }
97
98    #[test]
99    fn skips_noisy_dirs() {
100        let output = "node_modules/foo/bar.js\nsrc/a.rs\nsrc/b.rs\nsrc/c.rs\nsrc/d.rs\nsrc/e.rs\n";
101        let result = compress(output).unwrap();
102        assert!(!result.contains("node_modules"), "should skip node_modules");
103    }
104
105    #[test]
106    fn too_few_lines_returns_none() {
107        assert!(compress("a.rs\nb.rs\n").is_none());
108    }
109}