Skip to main content

khive_pack_code/
manifest.rs

1//! Manifest discovery + parsing for `code.ingest` L1 (ADR-085 Amendment 2 B3, B4).
2//!
3//! Pure filesystem + parsing helpers: no storage/runtime dependency. Directory
4//! walks skip common non-source, non-manifest-bearing trees (`.git`, `target`,
5//! `node_modules`, `__pycache__`, `.venv`) to keep discovery bounded.
6
7use std::collections::BTreeSet;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use serde_json::Value as JsonValue;
12use toml::Value as TomlValue;
13
14/// One of the three languages this PR's L1/L1.5 tiers cover. Lean is deferred
15/// to the Scanner/Extractor pipeline (B2) and has no manifest tier.
16pub(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/// A governing manifest found under the ingested `path`, with its declared
32/// dependencies (B3 L1: `project depends_on project` edges).
33#[derive(Debug, Clone)]
34pub(crate) struct ManifestProject {
35    pub root: PathBuf,
36    pub name: String,
37    pub language: &'static str,
38    /// `(dependency_name, dependency_kind)`.
39    pub dependencies: Vec<(String, String)>,
40}
41
42/// Walk `path` recursively and parse every governing manifest found
43/// (B4: a workspace-only `Cargo.toml`/`pyproject.toml` with no package name
44/// is not governing and is skipped).
45pub(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
106/// Parse a `Cargo.toml`; returns `None` when it declares no `[package].name`
107/// (a virtual/workspace-only manifest, B4).
108pub(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
133/// Extract the bare package name from a PEP 508 requirement string, e.g.
134/// `"requests>=2.0; python_version >= '3.8'"` -> `"requests"`.
135fn 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
147/// Parse a `pyproject.toml`; returns `None` when it declares no
148/// `[project].name` (B4 — a Poetry-only or tool-only manifest is not
149/// governing in v1).
150pub(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
187/// Parse a `package.json`; returns `None` when it declares no `name` (B4).
188pub(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
214/// Find the nearest governing manifest at or above `file_dir`, never walking
215/// above `ingest_root` (B4: "the nearest governing manifest at or above that
216/// file"; this ingest run only has visibility into `ingest_root`'s subtree).
217/// Returns `(project_root, project_name)`.
218pub(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}