1use std::collections::HashMap;
5use std::path::Path;
6
7use ignore::WalkBuilder;
8
9pub use comindly_api::manifests::{InternalDep, PackageRow};
10
11pub 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
179pub 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
207fn 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
226fn 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
247fn 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
298fn 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 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}