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}