Skip to main content

infigraph_core/manifest/
mod.rs

1/// Manifest parser: reads package manifests and lockfiles, extracts dependencies,
2/// stores them as Dependency nodes with DEPENDS_ON edges in the graph.
3///
4/// Supported: package.json, Cargo.toml, go.mod, pom.xml, build.gradle,
5///            requirements.txt, pyproject.toml, Gemfile, composer.json,
6///            packages.config, *.csproj, pubspec.yaml
7use 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
28/// Scan a project root for manifests, parse them, store deps in graph.
29pub 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    // Also scan for *.csproj files (can be nested)
58    scan_csproj(root, store, &mut results)?;
59
60    Ok(results)
61}
62
63/// Query dependencies stored in graph for a project.
64pub 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                // Skip workspace = true entries (no version)
168                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        // Single-line: require module v1.2.3
203        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    // Simple regex-based extraction (no full XML parse needed)
234    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    // Match: implementation 'group:artifact:version' or testImplementation("...")
262    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        // Handle: name==1.0, name>=1.0, name~=1.0, name
303        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    // PEP 621: [project] dependencies
333    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    // Poetry: [tool.poetry.dependencies]
351    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    // Simple line-based parse for pubspec.yaml dependencies sections
490    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: &regex::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        // Upsert Dependency node
598        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        // Wire DEPENDS_ON from the manifest's Module (or first Module in project)
612        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}