1use std::collections::BTreeSet;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use serde_json::Value as JsonValue;
12use toml::Value as TomlValue;
13
14pub(crate) const LANGUAGES: &[&str] = &["rust", "python", "typescript"];
17
18const SKIP_DIRS: &[&str] = &[
19 ".git",
20 "target",
21 "node_modules",
22 "__pycache__",
23 ".venv",
24 "venv",
25 ".mypy_cache",
26 ".pytest_cache",
27 "dist",
28 "build",
29];
30
31#[derive(Debug, Clone)]
34pub(crate) struct ManifestProject {
35 pub root: PathBuf,
36 pub name: String,
37 pub language: &'static str,
38 pub dependencies: Vec<(String, String)>,
40}
41
42pub(crate) fn discover_manifests(
46 path: &Path,
47 languages: &BTreeSet<&'static str>,
48) -> std::io::Result<Vec<ManifestProject>> {
49 let mut out = Vec::new();
50 walk_dir(path, languages, &mut out)?;
51 Ok(out)
52}
53
54fn walk_dir(
55 dir: &Path,
56 languages: &BTreeSet<&'static str>,
57 out: &mut Vec<ManifestProject>,
58) -> std::io::Result<()> {
59 if languages.contains("rust") {
60 let cargo_toml = dir.join("Cargo.toml");
61 if cargo_toml.is_file() {
62 if let Ok(text) = fs::read_to_string(&cargo_toml) {
63 if let Some(project) = parse_cargo_toml(dir, &text) {
64 out.push(project);
65 }
66 }
67 }
68 }
69 if languages.contains("python") {
70 let pyproject = dir.join("pyproject.toml");
71 if pyproject.is_file() {
72 if let Ok(text) = fs::read_to_string(&pyproject) {
73 if let Some(project) = parse_pyproject_toml(dir, &text) {
74 out.push(project);
75 }
76 }
77 }
78 }
79 if languages.contains("typescript") {
80 let package_json = dir.join("package.json");
81 if package_json.is_file() {
82 if let Ok(text) = fs::read_to_string(&package_json) {
83 if let Some(project) = parse_package_json(dir, &text) {
84 out.push(project);
85 }
86 }
87 }
88 }
89
90 for entry in fs::read_dir(dir)? {
91 let entry = entry?;
92 let file_type = entry.file_type()?;
93 if !file_type.is_dir() {
94 continue;
95 }
96 let name = entry.file_name();
97 let name = name.to_string_lossy();
98 if SKIP_DIRS.contains(&name.as_ref()) || name.starts_with('.') {
99 continue;
100 }
101 walk_dir(&entry.path(), languages, out)?;
102 }
103 Ok(())
104}
105
106pub(crate) fn parse_cargo_toml(root: &Path, text: &str) -> Option<ManifestProject> {
109 let doc: TomlValue = text.parse().ok()?;
110 let name = doc.get("package")?.get("name")?.as_str()?.to_string();
111
112 let mut dependencies = Vec::new();
113 for (section, kind) in [
114 ("dependencies", "dependencies"),
115 ("dev-dependencies", "dev-dependencies"),
116 ("build-dependencies", "build-dependencies"),
117 ] {
118 if let Some(TomlValue::Table(table)) = doc.get(section) {
119 for dep_name in table.keys() {
120 dependencies.push((dep_name.clone(), kind.to_string()));
121 }
122 }
123 }
124
125 Some(ManifestProject {
126 root: root.to_path_buf(),
127 name,
128 language: "rust",
129 dependencies,
130 })
131}
132
133fn pep508_name(spec: &str) -> Option<String> {
136 let end = spec
137 .find(|c: char| c.is_whitespace() || "<>=!~;[".contains(c))
138 .unwrap_or(spec.len());
139 let name = spec[..end].trim();
140 if name.is_empty() {
141 None
142 } else {
143 Some(name.to_string())
144 }
145}
146
147pub(crate) fn parse_pyproject_toml(root: &Path, text: &str) -> Option<ManifestProject> {
151 let doc: TomlValue = text.parse().ok()?;
152 let project = doc.get("project")?;
153 let name = project.get("name")?.as_str()?.to_string();
154
155 let mut dependencies = Vec::new();
156 if let Some(TomlValue::Array(arr)) = project.get("dependencies") {
157 for item in arr {
158 if let Some(spec) = item.as_str() {
159 if let Some(dep_name) = pep508_name(spec) {
160 dependencies.push((dep_name, "dependencies".to_string()));
161 }
162 }
163 }
164 }
165 if let Some(TomlValue::Table(groups)) = project.get("optional-dependencies") {
166 for (group, arr) in groups {
167 if let TomlValue::Array(arr) = arr {
168 for item in arr {
169 if let Some(spec) = item.as_str() {
170 if let Some(dep_name) = pep508_name(spec) {
171 dependencies.push((dep_name, format!("optional-dependencies:{group}")));
172 }
173 }
174 }
175 }
176 }
177 }
178
179 Some(ManifestProject {
180 root: root.to_path_buf(),
181 name,
182 language: "python",
183 dependencies,
184 })
185}
186
187pub(crate) fn parse_package_json(root: &Path, text: &str) -> Option<ManifestProject> {
189 let doc: JsonValue = serde_json::from_str(text).ok()?;
190 let name = doc.get("name")?.as_str()?.to_string();
191
192 let mut dependencies = Vec::new();
193 for (section, kind) in [
194 ("dependencies", "dependencies"),
195 ("devDependencies", "devDependencies"),
196 ("peerDependencies", "peerDependencies"),
197 ("optionalDependencies", "optionalDependencies"),
198 ] {
199 if let Some(JsonValue::Object(obj)) = doc.get(section) {
200 for dep_name in obj.keys() {
201 dependencies.push((dep_name.clone(), kind.to_string()));
202 }
203 }
204 }
205
206 Some(ManifestProject {
207 root: root.to_path_buf(),
208 name,
209 language: "typescript",
210 dependencies,
211 })
212}
213
214pub(crate) fn find_governing_manifest(
219 file_dir: &Path,
220 ingest_root: &Path,
221 language: &str,
222) -> Option<(PathBuf, String)> {
223 let mut dir = Some(file_dir);
224 while let Some(d) = dir {
225 let found = match language {
226 "rust" => fs::read_to_string(d.join("Cargo.toml"))
227 .ok()
228 .and_then(|t| parse_cargo_toml(d, &t)),
229 "python" => fs::read_to_string(d.join("pyproject.toml"))
230 .ok()
231 .and_then(|t| parse_pyproject_toml(d, &t)),
232 "typescript" => fs::read_to_string(d.join("package.json"))
233 .ok()
234 .and_then(|t| parse_package_json(d, &t)),
235 _ => None,
236 };
237 if let Some(project) = found {
238 return Some((project.root, project.name));
239 }
240 if d == ingest_root {
241 break;
242 }
243 dir = d.parent();
244 }
245 None
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn cargo_toml_without_package_is_not_governing() {
254 let text = "[workspace]\nmembers = [\"a\", \"b\"]\n";
255 assert!(parse_cargo_toml(Path::new("/tmp"), text).is_none());
256 }
257
258 #[test]
259 fn cargo_toml_with_package_collects_dependency_kinds() {
260 let text = r#"
261[package]
262name = "foo"
263
264[dependencies]
265serde = "1.0"
266
267[dev-dependencies]
268tempfile = "3"
269"#;
270 let project = parse_cargo_toml(Path::new("/tmp"), text).expect("governing");
271 assert_eq!(project.name, "foo");
272 assert_eq!(project.language, "rust");
273 assert!(project
274 .dependencies
275 .contains(&("serde".to_string(), "dependencies".to_string())));
276 assert!(project
277 .dependencies
278 .contains(&("tempfile".to_string(), "dev-dependencies".to_string())));
279 }
280
281 #[test]
282 fn pyproject_toml_extracts_pep508_names() {
283 let text = r#"
284[project]
285name = "bar"
286dependencies = ["requests>=2.0", "click"]
287"#;
288 let project = parse_pyproject_toml(Path::new("/tmp"), text).expect("governing");
289 assert_eq!(project.name, "bar");
290 assert!(project
291 .dependencies
292 .contains(&("requests".to_string(), "dependencies".to_string())));
293 assert!(project
294 .dependencies
295 .contains(&("click".to_string(), "dependencies".to_string())));
296 }
297
298 #[test]
299 fn package_json_extracts_dependency_sections() {
300 let text = r#"{"name": "baz", "dependencies": {"left-pad": "1.0.0"}, "devDependencies": {"jest": "29.0.0"}}"#;
301 let project = parse_package_json(Path::new("/tmp"), text).expect("governing");
302 assert_eq!(project.name, "baz");
303 assert!(project
304 .dependencies
305 .contains(&("left-pad".to_string(), "dependencies".to_string())));
306 assert!(project
307 .dependencies
308 .contains(&("jest".to_string(), "devDependencies".to_string())));
309 }
310}