1use std::path::Path;
8
9use anyhow::Result;
10
11use crate::graph::GraphStore;
12
13#[derive(Debug, Clone)]
14pub struct DepEntry {
15 pub name: String,
16 pub version: String,
17 pub ecosystem: String,
18 pub is_dev: bool,
19}
20
21#[derive(Debug, Default)]
22pub struct ManifestResult {
23 pub ecosystem: String,
24 pub manifest_file: String,
25 pub deps: Vec<DepEntry>,
26}
27
28pub fn index_manifests(root: &Path, store: &GraphStore) -> Result<Vec<ManifestResult>> {
30 let mut results = Vec::new();
31
32 let candidates = [
33 "package.json",
34 "Cargo.toml",
35 "go.mod",
36 "pom.xml",
37 "build.gradle",
38 "build.gradle.kts",
39 "requirements.txt",
40 "pyproject.toml",
41 "Gemfile",
42 "composer.json",
43 "packages.config",
44 "pubspec.yaml",
45 ];
46
47 for name in &candidates {
48 let path = root.join(name);
49 if path.exists() {
50 if let Ok(result) = parse_manifest(&path) {
51 store_manifest(store, &result)?;
52 results.push(result);
53 }
54 }
55 }
56
57 scan_csproj(root, store, &mut results)?;
59
60 Ok(results)
61}
62
63pub fn query_deps(store: &GraphStore) -> Result<Vec<DepEntry>> {
65 let conn = store.connection()?;
66 let q = "MATCH (d:Dependency) RETURN d.name, d.version, d.ecosystem, d.is_dev ORDER BY d.ecosystem, d.name";
67 let result = conn
68 .query(q)
69 .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
70
71 let mut deps = Vec::new();
72 for row in result {
73 if row.len() >= 4 {
74 deps.push(DepEntry {
75 name: row[0].to_string().trim_matches('"').to_string(),
76 version: row[1].to_string().trim_matches('"').to_string(),
77 ecosystem: row[2].to_string().trim_matches('"').to_string(),
78 is_dev: row[3].to_string() == "True" || row[3].to_string() == "true",
79 });
80 }
81 }
82 Ok(deps)
83}
84
85fn parse_manifest(path: &Path) -> Result<ManifestResult> {
86 let name = path.file_name().unwrap_or_default().to_string_lossy();
87 let content = std::fs::read_to_string(path)?;
88
89 match name.as_ref() {
90 "package.json" => parse_package_json(&content, path),
91 "Cargo.toml" => parse_cargo_toml(&content, path),
92 "go.mod" => parse_go_mod(&content, path),
93 "pom.xml" => parse_pom_xml(&content, path),
94 "build.gradle" | "build.gradle.kts" => parse_gradle(&content, path),
95 "requirements.txt" => parse_requirements_txt(&content, path),
96 "pyproject.toml" => parse_pyproject_toml(&content, path),
97 "Gemfile" => parse_gemfile(&content, path),
98 "composer.json" => parse_composer_json(&content, path),
99 "packages.config" => parse_packages_config(&content, path),
100 "pubspec.yaml" => parse_pubspec_yaml(&content, path),
101 _ => anyhow::bail!("unknown manifest: {}", name),
102 }
103}
104
105fn parse_package_json(content: &str, path: &Path) -> Result<ManifestResult> {
106 let v: serde_json::Value = serde_json::from_str(content)?;
107 let mut deps = Vec::new();
108
109 if let Some(obj) = v.get("dependencies").and_then(|d| d.as_object()) {
110 for (name, ver) in obj {
111 deps.push(DepEntry {
112 name: name.clone(),
113 version: ver.as_str().unwrap_or("*").to_string(),
114 ecosystem: "npm".to_string(),
115 is_dev: false,
116 });
117 }
118 }
119 if let Some(obj) = v.get("devDependencies").and_then(|d| d.as_object()) {
120 for (name, ver) in obj {
121 deps.push(DepEntry {
122 name: name.clone(),
123 version: ver.as_str().unwrap_or("*").to_string(),
124 ecosystem: "npm".to_string(),
125 is_dev: true,
126 });
127 }
128 }
129 if let Some(obj) = v.get("peerDependencies").and_then(|d| d.as_object()) {
130 for (name, ver) in obj {
131 deps.push(DepEntry {
132 name: name.clone(),
133 version: ver.as_str().unwrap_or("*").to_string(),
134 ecosystem: "npm".to_string(),
135 is_dev: false,
136 });
137 }
138 }
139
140 Ok(ManifestResult {
141 ecosystem: "npm".to_string(),
142 manifest_file: path.to_string_lossy().replace('\\', "/"),
143 deps,
144 })
145}
146
147fn parse_cargo_toml(content: &str, path: &Path) -> Result<ManifestResult> {
148 let v: toml::Value = content.parse()?;
149 let mut deps = Vec::new();
150
151 for (section, is_dev) in &[
152 ("dependencies", false),
153 ("dev-dependencies", true),
154 ("build-dependencies", true),
155 ] {
156 if let Some(table) = v.get(section).and_then(|d| d.as_table()) {
157 for (name, val) in table {
158 let version = match val {
159 toml::Value::String(s) => s.clone(),
160 toml::Value::Table(t) => t
161 .get("version")
162 .and_then(|v| v.as_str())
163 .unwrap_or("*")
164 .to_string(),
165 _ => "*".to_string(),
166 };
167 if val.as_table().and_then(|t| t.get("workspace")).is_some() {
169 continue;
170 }
171 deps.push(DepEntry {
172 name: name.clone(),
173 version,
174 ecosystem: "cargo".to_string(),
175 is_dev: *is_dev,
176 });
177 }
178 }
179 }
180
181 Ok(ManifestResult {
182 ecosystem: "cargo".to_string(),
183 manifest_file: path.to_string_lossy().replace('\\', "/"),
184 deps,
185 })
186}
187
188fn parse_go_mod(content: &str, path: &Path) -> Result<ManifestResult> {
189 let mut deps = Vec::new();
190 let mut in_require = false;
191
192 for line in content.lines() {
193 let line = line.trim();
194 if line.starts_with("require (") || line == "require (" {
195 in_require = true;
196 continue;
197 }
198 if in_require && line == ")" {
199 in_require = false;
200 continue;
201 }
202 let parts: Vec<&str> = if in_require {
204 line.split_whitespace().collect()
205 } else if let Some(stripped) = line.strip_prefix("require ") {
206 stripped.split_whitespace().collect()
207 } else {
208 continue;
209 };
210
211 if parts.len() >= 2 {
212 let is_indirect = parts
213 .get(2)
214 .map(|s| s.contains("indirect"))
215 .unwrap_or(false);
216 deps.push(DepEntry {
217 name: parts[0].to_string(),
218 version: parts[1].to_string(),
219 ecosystem: "go".to_string(),
220 is_dev: is_indirect,
221 });
222 }
223 }
224
225 Ok(ManifestResult {
226 ecosystem: "go".to_string(),
227 manifest_file: path.to_string_lossy().replace('\\', "/"),
228 deps,
229 })
230}
231
232fn parse_pom_xml(content: &str, path: &Path) -> Result<ManifestResult> {
233 let dep_re = regex::Regex::new(
235 r"<dependency>\s*<groupId>([^<]+)</groupId>\s*<artifactId>([^<]+)</artifactId>\s*(?:<version>([^<]+)</version>\s*)?(?:<scope>([^<]+)</scope>\s*)?"
236 ).unwrap();
237
238 let mut deps = Vec::new();
239 for cap in dep_re.captures_iter(content) {
240 let group = cap.get(1).map(|m| m.as_str()).unwrap_or("");
241 let artifact = cap.get(2).map(|m| m.as_str()).unwrap_or("");
242 let version = cap.get(3).map(|m| m.as_str()).unwrap_or("*");
243 let scope = cap.get(4).map(|m| m.as_str()).unwrap_or("compile");
244 let is_dev = matches!(scope, "test" | "provided");
245 deps.push(DepEntry {
246 name: format!("{}:{}", group.trim(), artifact.trim()),
247 version: version.trim().to_string(),
248 ecosystem: "maven".to_string(),
249 is_dev,
250 });
251 }
252
253 Ok(ManifestResult {
254 ecosystem: "maven".to_string(),
255 manifest_file: path.to_string_lossy().replace('\\', "/"),
256 deps,
257 })
258}
259
260fn parse_gradle(content: &str, path: &Path) -> Result<ManifestResult> {
261 let re = regex::Regex::new(
263 r#"(?:implementation|api|compileOnly|runtimeOnly|testImplementation|testCompileOnly|annotationProcessor)\s*[("']([^"'()]+)[)"']"#
264 ).unwrap();
265
266 let mut deps = Vec::new();
267 for cap in re.captures_iter(content) {
268 let spec = cap.get(1).map(|m| m.as_str()).unwrap_or("");
269 let is_dev = cap
270 .get(0)
271 .map(|m| m.as_str().starts_with("test"))
272 .unwrap_or(false);
273 let parts: Vec<&str> = spec.split(':').collect();
274 let name = if parts.len() >= 2 {
275 format!("{}:{}", parts[0], parts[1])
276 } else {
277 spec.to_string()
278 };
279 let version = parts.get(2).unwrap_or(&"*").to_string();
280 deps.push(DepEntry {
281 name,
282 version,
283 ecosystem: "gradle".to_string(),
284 is_dev,
285 });
286 }
287
288 Ok(ManifestResult {
289 ecosystem: "gradle".to_string(),
290 manifest_file: path.to_string_lossy().replace('\\', "/"),
291 deps,
292 })
293}
294
295fn parse_requirements_txt(content: &str, path: &Path) -> Result<ManifestResult> {
296 let mut deps = Vec::new();
297 for line in content.lines() {
298 let line = line.trim();
299 if line.is_empty() || line.starts_with('#') || line.starts_with('-') {
300 continue;
301 }
302 let (name, version) = if let Some(idx) = line.find(['=', '>', '<', '~', '!']) {
304 (
305 line[..idx].trim().to_string(),
306 line[idx..].trim().to_string(),
307 )
308 } else {
309 (line.to_string(), "*".to_string())
310 };
311 if !name.is_empty() {
312 deps.push(DepEntry {
313 name,
314 version,
315 ecosystem: "pip".to_string(),
316 is_dev: false,
317 });
318 }
319 }
320
321 Ok(ManifestResult {
322 ecosystem: "pip".to_string(),
323 manifest_file: path.to_string_lossy().replace('\\', "/"),
324 deps,
325 })
326}
327
328fn parse_pyproject_toml(content: &str, path: &Path) -> Result<ManifestResult> {
329 let v: toml::Value = content.parse()?;
330 let mut deps = Vec::new();
331
332 if let Some(arr) = v
334 .get("project")
335 .and_then(|p| p.get("dependencies"))
336 .and_then(|d| d.as_array())
337 {
338 for dep in arr {
339 if let Some(s) = dep.as_str() {
340 let (name, ver) = split_pep508(s);
341 deps.push(DepEntry {
342 name,
343 version: ver,
344 ecosystem: "pip".to_string(),
345 is_dev: false,
346 });
347 }
348 }
349 }
350 if let Some(table) = v
352 .get("tool")
353 .and_then(|t| t.get("poetry"))
354 .and_then(|p| p.get("dependencies"))
355 .and_then(|d| d.as_table())
356 {
357 for (name, val) in table {
358 if name == "python" {
359 continue;
360 }
361 let version = match val {
362 toml::Value::String(s) => s.clone(),
363 toml::Value::Table(t) => t
364 .get("version")
365 .and_then(|v| v.as_str())
366 .unwrap_or("*")
367 .to_string(),
368 _ => "*".to_string(),
369 };
370 deps.push(DepEntry {
371 name: name.clone(),
372 version,
373 ecosystem: "pip".to_string(),
374 is_dev: false,
375 });
376 }
377 }
378 if let Some(table) = v
379 .get("tool")
380 .and_then(|t| t.get("poetry"))
381 .and_then(|p| p.get("dev-dependencies"))
382 .and_then(|d| d.as_table())
383 {
384 for (name, val) in table {
385 let version = match val {
386 toml::Value::String(s) => s.clone(),
387 _ => "*".to_string(),
388 };
389 deps.push(DepEntry {
390 name: name.clone(),
391 version,
392 ecosystem: "pip".to_string(),
393 is_dev: true,
394 });
395 }
396 }
397
398 Ok(ManifestResult {
399 ecosystem: "pip".to_string(),
400 manifest_file: path.to_string_lossy().replace('\\', "/"),
401 deps,
402 })
403}
404
405fn parse_gemfile(content: &str, path: &Path) -> Result<ManifestResult> {
406 let re = regex::Regex::new(r#"gem\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?"#).unwrap();
407 let mut deps = Vec::new();
408 let mut in_test_group = false;
409
410 for line in content.lines() {
411 let trimmed = line.trim();
412 if trimmed.starts_with("group :test") || trimmed.starts_with("group :development") {
413 in_test_group = true;
414 }
415 if trimmed == "end" {
416 in_test_group = false;
417 }
418 if let Some(cap) = re.captures(trimmed) {
419 let name = cap.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
420 let version = cap.get(2).map(|m| m.as_str()).unwrap_or("*").to_string();
421 deps.push(DepEntry {
422 name,
423 version,
424 ecosystem: "gem".to_string(),
425 is_dev: in_test_group,
426 });
427 }
428 }
429
430 Ok(ManifestResult {
431 ecosystem: "gem".to_string(),
432 manifest_file: path.to_string_lossy().replace('\\', "/"),
433 deps,
434 })
435}
436
437fn parse_composer_json(content: &str, path: &Path) -> Result<ManifestResult> {
438 let v: serde_json::Value = serde_json::from_str(content)?;
439 let mut deps = Vec::new();
440
441 for (key, is_dev) in &[("require", false), ("require-dev", true)] {
442 if let Some(obj) = v.get(*key).and_then(|d| d.as_object()) {
443 for (name, ver) in obj {
444 if name == "php" {
445 continue;
446 }
447 deps.push(DepEntry {
448 name: name.clone(),
449 version: ver.as_str().unwrap_or("*").to_string(),
450 ecosystem: "composer".to_string(),
451 is_dev: *is_dev,
452 });
453 }
454 }
455 }
456
457 Ok(ManifestResult {
458 ecosystem: "composer".to_string(),
459 manifest_file: path.to_string_lossy().replace('\\', "/"),
460 deps,
461 })
462}
463
464fn parse_packages_config(content: &str, path: &Path) -> Result<ManifestResult> {
465 let re = regex::Regex::new(r#"<package\s+id="([^"]+)"\s+version="([^"]+)""#).unwrap();
466 let dev_re = regex::Regex::new(r#"developmentDependency="true""#).unwrap();
467 let mut deps = Vec::new();
468
469 for line in content.lines() {
470 if let Some(cap) = re.captures(line) {
471 let is_dev = dev_re.is_match(line);
472 deps.push(DepEntry {
473 name: cap[1].to_string(),
474 version: cap[2].to_string(),
475 ecosystem: "nuget".to_string(),
476 is_dev,
477 });
478 }
479 }
480
481 Ok(ManifestResult {
482 ecosystem: "nuget".to_string(),
483 manifest_file: path.to_string_lossy().replace('\\', "/"),
484 deps,
485 })
486}
487
488fn parse_pubspec_yaml(content: &str, path: &Path) -> Result<ManifestResult> {
489 let mut deps = Vec::new();
491 let mut in_deps = false;
492 let mut in_dev_deps = false;
493 let dep_re = regex::Regex::new(r"^\s{2}(\w[\w_-]*):\s*(.*)$").unwrap();
494
495 for line in content.lines() {
496 if line.starts_with("dependencies:") {
497 in_deps = true;
498 in_dev_deps = false;
499 continue;
500 }
501 if line.starts_with("dev_dependencies:") {
502 in_dev_deps = true;
503 in_deps = false;
504 continue;
505 }
506 if !line.starts_with(' ') && !line.is_empty() {
507 in_deps = false;
508 in_dev_deps = false;
509 }
510
511 if in_deps || in_dev_deps {
512 if let Some(cap) = dep_re.captures(line) {
513 let name = cap[1].to_string();
514 let raw_ver = cap[2].trim().to_string();
515 let version = if raw_ver.is_empty() || raw_ver == "any" {
516 "*".to_string()
517 } else {
518 raw_ver
519 };
520 if name != "flutter" && name != "sdk" {
521 deps.push(DepEntry {
522 name,
523 version,
524 ecosystem: "pub".to_string(),
525 is_dev: in_dev_deps,
526 });
527 }
528 }
529 }
530 }
531
532 Ok(ManifestResult {
533 ecosystem: "pub".to_string(),
534 manifest_file: path.to_string_lossy().replace('\\', "/"),
535 deps,
536 })
537}
538
539fn scan_csproj(root: &Path, store: &GraphStore, results: &mut Vec<ManifestResult>) -> Result<()> {
540 let re =
541 regex::Regex::new(r#"<PackageReference\s+Include="([^"]+)"\s+Version="([^"]+)""#).unwrap();
542 scan_csproj_dir(root, &re, store, results)
543}
544
545fn scan_csproj_dir(
546 dir: &Path,
547 re: ®ex::Regex,
548 store: &GraphStore,
549 results: &mut Vec<ManifestResult>,
550) -> Result<()> {
551 let ignore = [".git", "node_modules", "target", "bin", "obj"];
552 let Ok(entries) = std::fs::read_dir(dir) else {
553 return Ok(());
554 };
555 for entry in entries.flatten() {
556 let path = entry.path();
557 let name = entry.file_name();
558 let name_str = name.to_string_lossy();
559 if path.is_dir() && !ignore.contains(&name_str.as_ref()) {
560 scan_csproj_dir(&path, re, store, results)?;
561 } else if path
562 .extension()
563 .map(|e| e == "csproj" || e == "fsproj" || e == "vbproj")
564 .unwrap_or(false)
565 {
566 if let Ok(content) = std::fs::read_to_string(&path) {
567 let mut deps = Vec::new();
568 for cap in re.captures_iter(&content) {
569 deps.push(DepEntry {
570 name: cap[1].to_string(),
571 version: cap[2].to_string(),
572 ecosystem: "nuget".to_string(),
573 is_dev: false,
574 });
575 }
576 if !deps.is_empty() {
577 let result = ManifestResult {
578 ecosystem: "nuget".to_string(),
579 manifest_file: path.to_string_lossy().replace('\\', "/"),
580 deps,
581 };
582 let _ = store_manifest(store, &result);
583 results.push(result);
584 }
585 }
586 }
587 }
588 Ok(())
589}
590
591fn store_manifest(store: &GraphStore, result: &ManifestResult) -> Result<()> {
592 let _lock = store.write_lock()?;
593 let conn = store.connection()?;
594
595 for dep in &result.deps {
596 let id = format!("{}::{}", dep.ecosystem, dep.name);
597 let check = format!(
599 "MATCH (d:Dependency) WHERE d.id = '{}' RETURN d.id",
600 escape(&id)
601 );
602 let mut r = conn.query(&check).map_err(|e| anyhow::anyhow!("{e}"))?;
603 if r.next().is_none() {
604 let insert = format!(
605 "CREATE (d:Dependency {{id: '{}', name: '{}', version: '{}', ecosystem: '{}', is_dev: {}}})",
606 escape(&id), escape(&dep.name), escape(&dep.version), escape(&dep.ecosystem), dep.is_dev
607 );
608 let _ = conn.query(&insert);
609 }
610
611 let manifest_mod_id = &result.manifest_file;
613 let rel = format!(
614 "MATCH (m:Module), (d:Dependency) WHERE m.file CONTAINS '{}' AND d.id = '{}' \
615 CREATE (m)-[:DEPENDS_ON {{is_dev: {}}}]->(d)",
616 escape(result.manifest_file.rsplit('/').next().unwrap_or("")),
617 escape(&id),
618 dep.is_dev
619 );
620 let _ = conn.query(&rel);
621 let _ = manifest_mod_id;
622 }
623 Ok(())
624}
625
626fn split_pep508(s: &str) -> (String, String) {
627 if let Some(idx) = s.find(['=', '>', '<', '~', '!', '[', ';']) {
628 (s[..idx].trim().to_string(), s[idx..].trim().to_string())
629 } else {
630 (s.trim().to_string(), "*".to_string())
631 }
632}
633
634fn escape(s: &str) -> String {
635 s.replace('\\', "\\\\").replace('\'', "\\'")
636}