Skip to main content

sparrow/
repo_audit.rs

1use anyhow::Context;
2use serde::Serialize;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Serialize)]
6pub struct RepoAudit {
7    pub root: String,
8    pub files_total: usize,
9    pub rust_files: usize,
10    pub entrypoints: Vec<String>,
11    pub production_stubs: Vec<Finding>,
12    pub todo_comments: Vec<Finding>,
13    pub suspicious_unlinked_rust_files: Vec<String>,
14}
15
16#[derive(Debug, Clone, Serialize)]
17pub struct Finding {
18    pub file: String,
19    pub line: usize,
20    pub text: String,
21}
22
23pub fn run_repo_audit(root: &Path) -> anyhow::Result<RepoAudit> {
24    let mut files_total = 0usize;
25    let mut rust_paths = Vec::new();
26    let mut entrypoints = Vec::new();
27    let mut production_stubs = Vec::new();
28    let mut todo_comments = Vec::new();
29    let mut rust_corpus = String::new();
30
31    for entry in walkdir::WalkDir::new(root)
32        .into_iter()
33        .filter_entry(|entry| {
34            let name = entry.file_name().to_string_lossy();
35            !matches!(
36                name.as_ref(),
37                ".git"
38                    | ".claude"
39                    | ".codex"
40                    | "target"
41                    | "node_modules"
42                    | ".next"
43                    | "dist"
44                    | "build"
45            )
46        })
47    {
48        let entry = entry?;
49        if !entry.file_type().is_file() {
50            continue;
51        }
52        files_total += 1;
53        let path = entry.path();
54        let rel = rel_path(root, path);
55        if path.extension().and_then(|s| s.to_str()) != Some("rs") {
56            continue;
57        }
58
59        rust_paths.push(path.to_path_buf());
60        if rel.ends_with("src/main.rs") || rel.ends_with("src/lib.rs") {
61            entrypoints.push(rel.clone());
62        }
63
64        let text = std::fs::read_to_string(path)
65            .with_context(|| format!("failed to read Rust file {}", path.display()))?;
66        rust_corpus.push_str(&text);
67        rust_corpus.push('\n');
68        let is_test_file = rel.contains("/tests/") || rel.starts_with("tests/");
69        for (idx, line) in text.lines().enumerate() {
70            let line_no = idx + 1;
71            let code_only = strip_string_literals(line);
72            if !is_test_file
73                && (code_only.contains("todo!()") || code_only.contains("unimplemented!()"))
74            {
75                production_stubs.push(Finding {
76                    file: rel.clone(),
77                    line: line_no,
78                    text: line.trim().to_string(),
79                });
80            }
81            if line.contains("TODO") || line.contains("FIXME") {
82                todo_comments.push(Finding {
83                    file: rel.clone(),
84                    line: line_no,
85                    text: line.trim().to_string(),
86                });
87            }
88        }
89    }
90
91    let suspicious_unlinked_rust_files = rust_paths
92        .iter()
93        .filter_map(|path| {
94            let rel = rel_path(root, path);
95            let stem = path.file_stem()?.to_string_lossy();
96            if matches!(stem.as_ref(), "main" | "lib" | "mod") {
97                return None;
98            }
99            let mod_decl = format!("mod {stem}");
100            let namespaced = format!("{stem}::");
101            if rust_corpus.contains(&mod_decl) || rust_corpus.contains(&namespaced) {
102                None
103            } else {
104                Some(rel)
105            }
106        })
107        .collect();
108
109    Ok(RepoAudit {
110        root: root.display().to_string(),
111        files_total,
112        rust_files: rust_paths.len(),
113        entrypoints,
114        production_stubs,
115        todo_comments,
116        suspicious_unlinked_rust_files,
117    })
118}
119
120pub fn write_audit_markdown(root: &Path, audit: &RepoAudit) -> anyhow::Result<PathBuf> {
121    let dir = root.join("artifacts");
122    std::fs::create_dir_all(&dir)?;
123    let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
124    let path = dir.join(format!("audit-{stamp}.md"));
125    std::fs::write(&path, audit.to_markdown())?;
126    Ok(path)
127}
128
129impl RepoAudit {
130    pub fn to_markdown(&self) -> String {
131        let mut out = String::new();
132        out.push_str("# Sparrow Repo Audit\n\n");
133        out.push_str(&format!("Root: `{}`\n\n", self.root));
134        out.push_str("## Summary\n\n");
135        out.push_str(&format!("- Files scanned: {}\n", self.files_total));
136        out.push_str(&format!("- Rust files: {}\n", self.rust_files));
137        out.push_str(&format!(
138            "- Production stubs: {}\n",
139            self.production_stubs.len()
140        ));
141        out.push_str(&format!(
142            "- TODO/FIXME comments: {}\n",
143            self.todo_comments.len()
144        ));
145        out.push_str(&format!(
146            "- Suspicious unlinked Rust files: {}\n\n",
147            self.suspicious_unlinked_rust_files.len()
148        ));
149        write_list(&mut out, "Entrypoints", &self.entrypoints);
150        write_findings(&mut out, "Production Stubs", &self.production_stubs);
151        write_findings(&mut out, "TODO/FIXME", &self.todo_comments);
152        write_list(
153            &mut out,
154            "Suspicious Unlinked Rust Files",
155            &self.suspicious_unlinked_rust_files,
156        );
157        out
158    }
159}
160
161fn write_findings(out: &mut String, title: &str, findings: &[Finding]) {
162    out.push_str(&format!("## {title}\n\n"));
163    if findings.is_empty() {
164        out.push_str("None found.\n\n");
165        return;
166    }
167    for finding in findings {
168        out.push_str(&format!(
169            "- `{}`:{} — `{}`\n",
170            finding.file, finding.line, finding.text
171        ));
172    }
173    out.push('\n');
174}
175
176fn write_list(out: &mut String, title: &str, items: &[String]) {
177    out.push_str(&format!("## {title}\n\n"));
178    if items.is_empty() {
179        out.push_str("None found.\n\n");
180        return;
181    }
182    for item in items {
183        out.push_str(&format!("- `{item}`\n"));
184    }
185    out.push('\n');
186}
187
188fn rel_path(root: &Path, path: &Path) -> String {
189    path.strip_prefix(root)
190        .unwrap_or(path)
191        .to_string_lossy()
192        .replace('\\', "/")
193}
194
195fn strip_string_literals(line: &str) -> String {
196    let mut out = String::with_capacity(line.len());
197    let chars = line.chars();
198    let mut in_string = false;
199    let mut escaped = false;
200    for ch in chars {
201        if in_string {
202            if escaped {
203                escaped = false;
204            } else if ch == '\\' {
205                escaped = true;
206            } else if ch == '"' {
207                in_string = false;
208            }
209            out.push(' ');
210        } else if ch == '"' {
211            in_string = true;
212            out.push(' ');
213        } else {
214            out.push(ch);
215        }
216    }
217    out
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn repo_audit_finds_stubs_and_entrypoints() {
226        let temp = tempfile::tempdir().unwrap();
227        let src = temp.path().join("src");
228        std::fs::create_dir_all(&src).unwrap();
229        std::fs::write(
230            src.join("main.rs"),
231            "mod used;\nfn main(){ used::go(); }\n// TODO: wire cli\n",
232        )
233        .unwrap();
234        std::fs::write(src.join("used.rs"), "pub fn go() {}\n").unwrap();
235        std::fs::write(src.join("lonely.rs"), "pub fn nope(){ todo!() }\n").unwrap();
236
237        let audit = run_repo_audit(temp.path()).unwrap();
238        assert_eq!(audit.entrypoints, vec!["src/main.rs"]);
239        assert_eq!(audit.production_stubs.len(), 1);
240        assert_eq!(audit.todo_comments.len(), 1);
241        assert!(
242            audit
243                .suspicious_unlinked_rust_files
244                .contains(&"src/lonely.rs".to_string())
245        );
246        assert!(audit.to_markdown().contains("Production Stubs"));
247    }
248
249    #[test]
250    fn repo_audit_ignores_stub_words_inside_strings() {
251        let temp = tempfile::tempdir().unwrap();
252        let src = temp.path().join("src");
253        std::fs::create_dir_all(&src).unwrap();
254        std::fs::write(src.join("main.rs"), "fn main(){ println!(\"todo!()\"); }\n").unwrap();
255        let audit = run_repo_audit(temp.path()).unwrap();
256        assert!(audit.production_stubs.is_empty());
257    }
258}