Skip to main content

rta_core/
collector.rs

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}