1use 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 pub ecosystem: String,
15 pub name: String,
17 pub publishes: bool,
19 pub version: Option<String>,
21 pub locked: Option<String>,
23 pub manifest: String,
25}
26
27#[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 pub fn skewed(&self) -> bool {
42 matches!((&self.locked, &self.published), (Some(l), Some(p)) if l != p)
43 }
44}
45
46pub 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
214pub 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
242fn 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
261fn 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
282fn 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
333fn 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 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}