1use std::{
2 collections::BTreeMap,
3 fs,
4 io::Read,
5 path::{Path, PathBuf},
6 process::{Command, Stdio},
7 thread,
8 time::{Duration, Instant},
9};
10
11use cargo_metadata::DependencyKind;
12use toml::Value;
13
14#[derive(Debug, Clone)]
15pub struct RepositorySnapshot {
16 pub root: PathBuf,
17 pub files: Vec<FileSnapshot>,
18 pub manifests: Vec<CargoManifest>,
19}
20
21#[derive(Debug, Clone)]
22pub struct FileSnapshot {
23 pub path: PathBuf,
24 pub relative_path: String,
25 pub extension: Option<String>,
26 pub bytes: u64,
27 pub lines: usize,
28 pub content: Option<String>,
29}
30
31#[derive(Debug, Clone)]
32pub struct CargoManifest {
33 pub relative_path: String,
34 pub package_name: Option<String>,
35 pub workspace_members: Vec<String>,
36 pub dependencies: BTreeMap<String, String>,
37 pub dev_dependencies: BTreeMap<String, String>,
38 pub build_dependencies: BTreeMap<String, String>,
39}
40
41impl RepositorySnapshot {
42 pub fn collect(root: &Path) -> Result<Self, String> {
43 let root = root
44 .canonicalize()
45 .map_err(|err| format!("failed to resolve {}: {err}", root.display()))?;
46 let mut files = Vec::new();
47 walk(&root, &root, &mut files)?;
48
49 let parsed_manifests = files
50 .iter()
51 .filter(|file| file.relative_path.ends_with("Cargo.toml"))
52 .filter_map(|file| file.content.as_deref().map(|content| (file, content)))
53 .map(|(file, content)| CargoManifest::parse(&file.relative_path, content))
54 .collect::<Result<Vec<_>, _>>()?;
55 let manifests = collect_metadata_manifests(&root).unwrap_or(parsed_manifests);
56
57 Ok(Self {
58 root,
59 files,
60 manifests,
61 })
62 }
63
64 pub fn rust_files(&self) -> impl Iterator<Item = &FileSnapshot> {
65 self.files
66 .iter()
67 .filter(|file| file.extension.as_deref() == Some("rs"))
68 }
69}
70
71fn collect_metadata_manifests(root: &Path) -> Option<Vec<CargoManifest>> {
72 if std::env::var_os("RTA_DISABLE_CARGO_METADATA").is_some() {
73 return None;
74 }
75 let manifest_path = root.join("Cargo.toml");
76 if !manifest_path.exists() {
77 return None;
78 }
79
80 let metadata = cargo_metadata_with_timeout(root, &manifest_path)?;
81
82 let workspace_members = metadata
83 .workspace_members
84 .iter()
85 .filter_map(|member_id| {
86 metadata
87 .packages
88 .iter()
89 .find(|package| &package.id == member_id)
90 })
91 .filter_map(|package| relative_manifest_path(root, package.manifest_path.as_std_path()))
92 .map(|relative| relative.trim_end_matches("/Cargo.toml").to_string())
93 .collect::<Vec<_>>();
94 let mut manifests = Vec::new();
95
96 for package in metadata.packages {
97 let relative_path = relative_manifest_path(root, package.manifest_path.as_std_path())?;
98 let mut manifest = CargoManifest {
99 relative_path,
100 package_name: Some(package.name.to_string()),
101 workspace_members: Vec::new(),
102 dependencies: BTreeMap::new(),
103 dev_dependencies: BTreeMap::new(),
104 build_dependencies: BTreeMap::new(),
105 };
106
107 if package.manifest_path.as_std_path() == manifest_path {
108 manifest.workspace_members = workspace_members.clone();
109 }
110
111 for dependency in package.dependencies {
112 let value = dependency_value_from_metadata(&dependency);
113 match dependency.kind {
114 DependencyKind::Normal => {
115 manifest.dependencies.insert(dependency.name, value);
116 }
117 DependencyKind::Development => {
118 manifest.dev_dependencies.insert(dependency.name, value);
119 }
120 DependencyKind::Build => {
121 manifest.build_dependencies.insert(dependency.name, value);
122 }
123 _ => {}
124 }
125 }
126
127 manifests.push(manifest);
128 }
129
130 Some(manifests)
131}
132
133fn cargo_metadata_with_timeout(
134 root: &Path,
135 manifest_path: &Path,
136) -> Option<cargo_metadata::Metadata> {
137 let mut child = Command::new("cargo")
138 .arg("metadata")
139 .arg("--no-deps")
140 .arg("--format-version")
141 .arg("1")
142 .arg("--manifest-path")
143 .arg(manifest_path)
144 .current_dir(root)
145 .stdout(Stdio::piped())
146 .stderr(Stdio::null())
147 .spawn()
148 .ok()?;
149 let started = Instant::now();
150 loop {
151 match child.try_wait().ok()? {
152 Some(status) if status.success() => break,
153 Some(_) => return None,
154 None if started.elapsed() > Duration::from_secs(5) => {
155 let _ = child.kill();
156 let _ = child.wait();
157 return None;
158 }
159 None => thread::sleep(Duration::from_millis(50)),
160 }
161 }
162
163 let mut output = String::new();
164 child.stdout.take()?.read_to_string(&mut output).ok()?;
165 serde_json::from_str(&output).ok()
166}
167
168fn dependency_value_from_metadata(dependency: &cargo_metadata::Dependency) -> String {
169 if let Some(path) = &dependency.path {
170 return format!("path = {}", path);
171 }
172 if let Some(source) = &dependency.source {
173 return format!("{} ({source})", dependency.req);
174 }
175 dependency.req.to_string()
176}
177
178fn relative_manifest_path(root: &Path, manifest_path: &Path) -> Option<String> {
179 Some(
180 manifest_path
181 .strip_prefix(root)
182 .ok()?
183 .to_string_lossy()
184 .replace('\\', "/"),
185 )
186}
187
188impl CargoManifest {
189 fn parse(relative_path: &str, content: &str) -> Result<Self, String> {
190 let parsed = content
191 .parse::<Value>()
192 .map_err(|err| format!("failed to parse {relative_path}: {err}"))?;
193 let package_name = parsed
194 .get("package")
195 .and_then(|package| package.get("name"))
196 .and_then(Value::as_str)
197 .map(str::to_string);
198 let workspace_members = parsed
199 .get("workspace")
200 .and_then(|workspace| workspace.get("members"))
201 .and_then(Value::as_array)
202 .map(|members| {
203 members
204 .iter()
205 .filter_map(Value::as_str)
206 .map(str::to_string)
207 .collect()
208 })
209 .unwrap_or_default();
210 let dependencies = parse_dependency_table(&parsed, "dependencies");
211 let dev_dependencies = parse_dependency_table(&parsed, "dev-dependencies");
212 let build_dependencies = parse_dependency_table(&parsed, "build-dependencies");
213
214 Ok(Self {
215 relative_path: relative_path.to_string(),
216 package_name,
217 workspace_members,
218 dependencies,
219 dev_dependencies,
220 build_dependencies,
221 })
222 }
223}
224
225fn walk(root: &Path, current: &Path, files: &mut Vec<FileSnapshot>) -> Result<(), String> {
226 let entries = fs::read_dir(current)
227 .map_err(|err| format!("failed to read {}: {err}", current.display()))?;
228
229 for entry in entries {
230 let entry = entry.map_err(|err| format!("failed to read directory entry: {err}"))?;
231 let path = entry.path();
232 let name = entry.file_name();
233 let name = name.to_string_lossy();
234
235 if should_skip(&name) {
236 continue;
237 }
238
239 let metadata = fs::symlink_metadata(&path)
240 .map_err(|err| format!("failed to stat {}: {err}", path.display()))?;
241 if metadata.file_type().is_symlink() {
242 continue;
243 }
244 if metadata.is_dir() {
245 walk(root, &path, files)?;
246 continue;
247 }
248 if !metadata.is_file() {
249 continue;
250 }
251
252 let relative_path = path
253 .strip_prefix(root)
254 .unwrap_or(&path)
255 .to_string_lossy()
256 .replace('\\', "/");
257 let extension = path
258 .extension()
259 .and_then(|ext| ext.to_str())
260 .map(|ext| ext.to_ascii_lowercase());
261 let content = if should_read_content(extension.as_deref()) {
262 read_text_file(&path, metadata.len())
263 } else {
264 None
265 };
266 let lines = content
267 .as_deref()
268 .map(|content| content.lines().count())
269 .unwrap_or(0);
270
271 files.push(FileSnapshot {
272 path,
273 relative_path,
274 extension,
275 bytes: metadata.len(),
276 lines,
277 content,
278 });
279 }
280
281 Ok(())
282}
283
284fn should_skip(name: &str) -> bool {
285 matches!(
286 name,
287 ".git" | "target" | "node_modules" | ".idea" | ".vscode" | ".DS_Store"
288 )
289}
290
291fn read_text_file(path: &Path, bytes: u64) -> Option<String> {
292 if bytes > 1_000_000 {
293 return None;
294 }
295 fs::read_to_string(path).ok()
296}
297
298fn should_read_content(extension: Option<&str>) -> bool {
299 matches!(extension, Some("rs" | "toml"))
300}
301
302fn parse_dependency_table(manifest: &Value, section: &str) -> BTreeMap<String, String> {
303 manifest
304 .get(section)
305 .and_then(Value::as_table)
306 .map(|dependencies| {
307 dependencies
308 .iter()
309 .map(|(name, value)| (name.clone(), dependency_value(value)))
310 .collect()
311 })
312 .unwrap_or_default()
313}
314
315fn dependency_value(value: &Value) -> String {
316 value
317 .as_str()
318 .map(str::to_string)
319 .unwrap_or_else(|| value.to_string())
320}
321
322#[cfg(test)]
323mod tests {
324 use super::CargoManifest;
325
326 #[test]
327 fn parses_multiline_workspace_members() {
328 let manifest = CargoManifest::parse(
329 "Cargo.toml",
330 r#"
331[workspace]
332members = [
333 "crates/audit-core",
334 "crates/audit-cli",
335]
336"#,
337 )
338 .expect("workspace manifest should parse");
339
340 assert_eq!(
341 manifest.workspace_members,
342 ["crates/audit-core", "crates/audit-cli"]
343 );
344 }
345
346 #[test]
347 fn parses_dependency_tables_without_losing_path_or_git_signals() {
348 let manifest = CargoManifest::parse(
349 "Cargo.toml",
350 r#"
351[package]
352name = "fixture"
353
354[dependencies]
355serde = "1"
356local-helper = { path = "../local-helper" }
357remote-helper = { git = "https://example.test/repo.git", rev = "abc123" }
358
359[dev-dependencies]
360axum = { version = "0.7", features = ["json"] }
361
362[build-dependencies]
363cc = "1"
364"#,
365 )
366 .expect("dependency manifest should parse");
367
368 assert_eq!(manifest.package_name.as_deref(), Some("fixture"));
369 assert_eq!(manifest.dependencies["serde"], "1");
370 assert!(manifest.dependencies["local-helper"].contains("path"));
371 assert!(manifest.dependencies["remote-helper"].contains("git"));
372 assert!(manifest.dev_dependencies["axum"].contains("\"0.7\""));
373 assert_eq!(manifest.build_dependencies["cc"], "1");
374 }
375
376 #[test]
377 fn reports_invalid_toml() {
378 let err = CargoManifest::parse("Cargo.toml", "[dependencies")
379 .expect_err("invalid TOML should be rejected");
380
381 assert!(err.contains("failed to parse Cargo.toml"));
382 }
383}