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