Skip to main content

comindly_core/
manifests.rs

1//! Packages and versions from manifests and lockfiles: what each repo publishes and depends on,
2//! so internal dependencies map to the repo that publishes them (and version skew shows).
3
4use std::collections::HashMap;
5use std::path::Path;
6
7use ignore::WalkBuilder;
8
9pub use comindly_api::manifests::{InternalDep, PackageRow};
10
11/// Read every manifest in a repo (respecting `.gitignore`, skipping vendored trees).
12pub fn scan(root: &Path, repo: &str) -> Vec<PackageRow> {
13    let mut rows = Vec::new();
14    let files = WalkBuilder::new(root)
15        .hidden(false)
16        .max_depth(Some(5))
17        .filter_entry(|e| {
18            !matches!(
19                e.file_name().to_str(),
20                Some("node_modules" | ".venv" | "venv" | "target" | ".git" | ".terraform")
21            )
22        })
23        .build()
24        .filter_map(|e| e.ok())
25        .filter(|e| e.file_type().is_some_and(|t| t.is_file()));
26    for e in files {
27        let name = e.file_name().to_string_lossy().to_string();
28        let Ok(rel) = e.path().strip_prefix(root) else {
29            continue;
30        };
31        let rel = rel.to_string_lossy().replace('\\', "/");
32        let dir = e.path().parent().unwrap_or(root);
33        let read = || std::fs::read_to_string(e.path()).ok();
34        let row =
35            |ecosystem: &str, name: &str, publishes: bool, version: Option<String>| PackageRow {
36                repo: repo.to_string(),
37                ecosystem: ecosystem.to_string(),
38                name: normalize(ecosystem, name),
39                publishes,
40                version,
41                locked: None,
42                manifest: rel.clone(),
43            };
44        match name.as_str() {
45            "pyproject.toml" => {
46                let Some(v) = read().and_then(|t| t.parse::<toml::Table>().ok()) else {
47                    continue;
48                };
49                let locks = python_locks(dir);
50                let project = v.get("project");
51                let poetry = v.get("tool").and_then(|t| t.get("poetry"));
52                if let Some(n) = project
53                    .or(poetry)
54                    .and_then(|p| p.get("name"))
55                    .and_then(|n| n.as_str())
56                {
57                    let ver = project
58                        .or(poetry)
59                        .and_then(|p| p.get("version"))
60                        .and_then(|v| v.as_str())
61                        .map(str::to_string);
62                    rows.push(row("pypi", n, true, ver));
63                }
64                let deps = project
65                    .and_then(|p| p.get("dependencies"))
66                    .and_then(|d| d.as_array())
67                    .into_iter()
68                    .flatten()
69                    .filter_map(|d| d.as_str())
70                    .filter_map(pep508);
71                let poetry_deps = poetry
72                    .and_then(|p| p.get("dependencies"))
73                    .and_then(|d| d.as_table())
74                    .into_iter()
75                    .flatten()
76                    .filter(|(k, _)| k.as_str() != "python")
77                    .map(|(k, v)| {
78                        let spec = v
79                            .as_str()
80                            .map(str::to_string)
81                            .or_else(|| v.get("version")?.as_str().map(str::to_string));
82                        (k.clone(), spec)
83                    });
84                for (n, spec) in deps.chain(poetry_deps) {
85                    let mut r = row("pypi", &n, false, spec);
86                    r.locked = locks.get(&r.name).cloned();
87                    rows.push(r);
88                }
89            }
90            _ if name.starts_with("requirements") && name.ends_with(".txt") => {
91                for line in read().unwrap_or_default().lines() {
92                    let line = line.split('#').next().unwrap_or_default().trim();
93                    if line.is_empty() || line.starts_with('-') {
94                        continue;
95                    }
96                    if let Some((n, spec)) = pep508(line) {
97                        let mut r = row("pypi", &n, false, spec.clone());
98                        r.locked = spec.and_then(|s| s.strip_prefix("==").map(str::to_string));
99                        rows.push(r);
100                    }
101                }
102            }
103            "package.json" => {
104                let Some(v) =
105                    read().and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
106                else {
107                    continue;
108                };
109                let locks = npm_locks(dir);
110                if let Some(n) = v.get("name").and_then(|n| n.as_str()) {
111                    let ver = v
112                        .get("version")
113                        .and_then(|x| x.as_str())
114                        .map(str::to_string);
115                    rows.push(row("npm", n, true, ver));
116                }
117                for key in ["dependencies", "peerDependencies", "devDependencies"] {
118                    for (n, spec) in v.get(key).and_then(|d| d.as_object()).into_iter().flatten() {
119                        let mut r = row("npm", n, false, spec.as_str().map(str::to_string));
120                        r.locked = locks.get(&r.name).cloned();
121                        rows.push(r);
122                    }
123                }
124            }
125            "go.mod" => {
126                let text = read().unwrap_or_default();
127                let mut in_require = false;
128                for line in text.lines() {
129                    let l = line.split("//").next().unwrap_or_default().trim();
130                    if let Some(m) = l.strip_prefix("module ") {
131                        rows.push(row("go", m.trim(), true, None));
132                    } else if l.starts_with("require (") {
133                        in_require = true;
134                    } else if in_require && l == ")" {
135                        in_require = false;
136                    } else if in_require || l.starts_with("require ") {
137                        let l = l.trim_start_matches("require ").trim();
138                        let mut parts = l.split_whitespace();
139                        if let (Some(n), Some(ver)) = (parts.next(), parts.next()) {
140                            let mut r = row("go", n, false, Some(ver.to_string()));
141                            r.locked = Some(ver.to_string());
142                            rows.push(r);
143                        }
144                    }
145                }
146            }
147            "Cargo.toml" => {
148                let Some(v) = read().and_then(|t| t.parse::<toml::Table>().ok()) else {
149                    continue;
150                };
151                let locks = cargo_locks(dir);
152                if let Some(p) = v.get("package") {
153                    if let Some(n) = p.get("name").and_then(|n| n.as_str()) {
154                        let ver = p
155                            .get("version")
156                            .and_then(|x| x.as_str())
157                            .map(str::to_string);
158                        rows.push(row("cargo", n, true, ver));
159                    }
160                }
161                for key in ["dependencies", "dev-dependencies", "build-dependencies"] {
162                    for (n, spec) in v.get(key).and_then(|d| d.as_table()).into_iter().flatten() {
163                        let s = spec
164                            .as_str()
165                            .map(str::to_string)
166                            .or_else(|| spec.get("version")?.as_str().map(str::to_string));
167                        let mut r = row("cargo", n, false, s);
168                        r.locked = locks.get(&r.name).cloned();
169                        rows.push(r);
170                    }
171                }
172            }
173            _ => {}
174        }
175    }
176    rows
177}
178
179/// Internal dependencies: dependency rows whose package another repo in the corpus publishes.
180pub fn internal(rows: &[PackageRow]) -> Vec<InternalDep> {
181    let published: HashMap<(&str, &str), &PackageRow> = rows
182        .iter()
183        .filter(|r| r.publishes)
184        .map(|r| ((r.ecosystem.as_str(), r.name.as_str()), r))
185        .collect();
186    let mut out: Vec<InternalDep> = rows
187        .iter()
188        .filter(|r| !r.publishes)
189        .filter_map(|r| {
190            let p = published.get(&(r.ecosystem.as_str(), r.name.as_str()))?;
191            (p.repo != r.repo).then(|| InternalDep {
192                ecosystem: r.ecosystem.clone(),
193                name: r.name.clone(),
194                publisher: p.repo.clone(),
195                published: p.version.clone(),
196                consumer: r.repo.clone(),
197                requirement: r.version.clone(),
198                locked: r.locked.clone(),
199            })
200        })
201        .collect();
202    out.sort_by(|a, b| (&a.name, &a.consumer).cmp(&(&b.name, &b.consumer)));
203    out.dedup_by(|a, b| a.name == b.name && a.consumer == b.consumer);
204    out
205}
206
207/// PEP 503 for PyPI (`Foo_Bar` → `foo-bar`); other ecosystems are case-sensitive as written.
208fn normalize(ecosystem: &str, name: &str) -> String {
209    if ecosystem == "pypi" {
210        let lower = name.to_ascii_lowercase();
211        let mut out = String::with_capacity(lower.len());
212        for c in lower.chars() {
213            let c = if matches!(c, '_' | '.') { '-' } else { c };
214            if !(c == '-' && out.ends_with('-')) {
215                out.push(c);
216            }
217        }
218        out
219    } else if ecosystem == "cargo" {
220        name.replace('_', "-")
221    } else {
222        name.to_string()
223    }
224}
225
226/// `acme[pgsql] >= 0.11.33 ; python_version > "3"` → (`acme`, `>= 0.11.33`).
227fn pep508(spec: &str) -> Option<(String, Option<String>)> {
228    let spec = spec.split(';').next()?.trim();
229    let end = spec
230        .find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
231        .unwrap_or(spec.len());
232    let name = &spec[..end];
233    if name.is_empty() {
234        return None;
235    }
236    let rest = spec[end..].trim();
237    let rest = match rest.strip_prefix('[') {
238        Some(r) => r.split_once(']').map_or("", |(_, t)| t).trim(),
239        None => rest,
240    };
241    Some((
242        name.to_string(),
243        (!rest.is_empty()).then(|| rest.replace(' ', "")),
244    ))
245}
246
247/// Locked versions from `uv.lock` / `poetry.lock` next to a pyproject.
248fn python_locks(dir: &Path) -> HashMap<String, String> {
249    let mut out = HashMap::new();
250    for f in ["uv.lock", "poetry.lock"] {
251        let Some(v) = std::fs::read_to_string(dir.join(f))
252            .ok()
253            .and_then(|t| t.parse::<toml::Table>().ok())
254        else {
255            continue;
256        };
257        for p in v
258            .get("package")
259            .and_then(|p| p.as_array())
260            .into_iter()
261            .flatten()
262        {
263            if let (Some(n), Some(ver)) = (
264                p.get("name").and_then(|n| n.as_str()),
265                p.get("version").and_then(|n| n.as_str()),
266            ) {
267                out.insert(normalize("pypi", n), ver.to_string());
268            }
269        }
270    }
271    out
272}
273
274fn cargo_locks(dir: &Path) -> HashMap<String, String> {
275    let mut out = HashMap::new();
276    let Some(v) = std::fs::read_to_string(dir.join("Cargo.lock"))
277        .ok()
278        .and_then(|t| t.parse::<toml::Table>().ok())
279    else {
280        return out;
281    };
282    for p in v
283        .get("package")
284        .and_then(|p| p.as_array())
285        .into_iter()
286        .flatten()
287    {
288        if let (Some(n), Some(ver)) = (
289            p.get("name").and_then(|n| n.as_str()),
290            p.get("version").and_then(|n| n.as_str()),
291        ) {
292            out.insert(normalize("cargo", n), ver.to_string());
293        }
294    }
295    out
296}
297
298/// Locked versions from `package-lock.json` (v2/v3) or a pnpm lockfile's package keys.
299fn npm_locks(dir: &Path) -> HashMap<String, String> {
300    let mut out = HashMap::new();
301    if let Some(v) = std::fs::read_to_string(dir.join("package-lock.json"))
302        .ok()
303        .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
304    {
305        for (k, p) in v
306            .get("packages")
307            .and_then(|p| p.as_object())
308            .into_iter()
309            .flatten()
310        {
311            if let (Some(n), Some(ver)) = (
312                k.rsplit("node_modules/").next().filter(|n| !n.is_empty()),
313                p.get("version").and_then(|x| x.as_str()),
314            ) {
315                out.entry(n.to_string()).or_insert_with(|| ver.to_string());
316            }
317        }
318    }
319    if let Ok(text) = std::fs::read_to_string(dir.join("pnpm-lock.yaml")) {
320        let mut in_packages = false;
321        for line in text.lines() {
322            if !line.starts_with(' ') {
323                in_packages = line.starts_with("packages:");
324                continue;
325            }
326            if !in_packages || line.starts_with("    ") {
327                continue;
328            }
329            // `  '@scope/name@1.2.3':` or `  name@1.2.3(peer@x):`
330            let key = line.trim().trim_end_matches(':').trim_matches('\'');
331            let key = key.trim_start_matches('/');
332            if let Some(at) = key[1..].find('@').map(|i| i + 1) {
333                let ver = key[at + 1..].split('(').next().unwrap_or_default();
334                out.entry(key[..at].to_string())
335                    .or_insert_with(|| ver.to_string());
336            }
337        }
338    }
339    out
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn pep508_specs() {
348        assert_eq!(
349            pep508("acme[fastapi,llm, pgsql] >= 0.12.5"),
350            Some(("acme".into(), Some(">=0.12.5".into())))
351        );
352        assert_eq!(pep508("httpx"), Some(("httpx".into(), None)));
353        assert_eq!(
354            pep508("acme-common==1.4.0 ; python_version >= '3.11'"),
355            Some(("acme-common".into(), Some("==1.4.0".into())))
356        );
357        assert_eq!(normalize("pypi", "Acme_Common"), "acme-common");
358    }
359
360    #[test]
361    fn fixture_internal_dependencies() {
362        let mut rows = Vec::new();
363        for repo in [
364            "pkg-common",
365            "service-a",
366            "ui-kit",
367            "web-app",
368            "rs-lib",
369            "rs-app",
370        ] {
371            rows.extend(scan(
372                &Path::new(concat!(
373                    env!("CARGO_MANIFEST_DIR"),
374                    "/../comindly-engine/tests/fixtures/org"
375                ))
376                .join(repo),
377                repo,
378            ));
379        }
380        let deps = internal(&rows);
381        let pairs: Vec<(&str, &str, &str)> = deps
382            .iter()
383            .map(|d| (d.name.as_str(), d.publisher.as_str(), d.consumer.as_str()))
384            .collect();
385        assert!(
386            pairs.contains(&("acme-common", "pkg-common", "service-a")),
387            "{pairs:?}"
388        );
389        assert!(
390            pairs.contains(&("@acme/ui-kit", "ui-kit", "web-app")),
391            "{pairs:?}"
392        );
393        assert!(
394            pairs.contains(&("acme-core", "rs-lib", "rs-app")),
395            "{pairs:?}"
396        );
397        let common = deps.iter().find(|d| d.name == "acme-common").unwrap();
398        assert_eq!(common.published.as_deref(), Some("1.4.0"));
399        assert_eq!(common.requirement.as_deref(), Some("==1.4.0"));
400    }
401}