Skip to main content

codemem_engine/index/
manifest.rs

1//! Manifest file parsing for cross-repo/cross-package dependency detection.
2//!
3//! Parses `Cargo.toml` and `package.json` files to extract workspace definitions,
4//! package names, and dependency relationships. This enables Codemem to understand
5//! monorepo structure and cross-package relationships.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10
11/// A parsed dependency from a manifest file.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Dependency {
14    /// Package name.
15    pub name: String,
16    /// Version string or spec.
17    pub version: String,
18    /// Whether this is a dev/test dependency.
19    pub dev: bool,
20    /// Path to the manifest file this was found in.
21    pub manifest_path: String,
22}
23
24/// A parsed workspace/monorepo definition.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Workspace {
27    /// Root path of the workspace.
28    pub root: String,
29    /// Member package names/paths.
30    pub members: Vec<String>,
31    /// Manifest type (e.g., "cargo", "npm").
32    pub kind: String,
33}
34
35/// Result of parsing manifests in a directory tree.
36#[derive(Debug, Clone)]
37pub struct ManifestResult {
38    /// Detected workspaces.
39    pub workspaces: Vec<Workspace>,
40    /// All parsed dependencies.
41    pub dependencies: Vec<Dependency>,
42    /// Map: package_name -> manifest_path.
43    pub packages: HashMap<String, String>,
44}
45
46impl ManifestResult {
47    /// Create an empty ManifestResult.
48    pub fn new() -> Self {
49        Self {
50            workspaces: Vec::new(),
51            dependencies: Vec::new(),
52            packages: HashMap::new(),
53        }
54    }
55
56    /// Merge another ManifestResult into this one.
57    pub fn merge(&mut self, other: ManifestResult) {
58        self.workspaces.extend(other.workspaces);
59        self.dependencies.extend(other.dependencies);
60        self.packages.extend(other.packages);
61    }
62}
63
64impl Default for ManifestResult {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70// ── Cargo.toml Parsing ───────────────────────────────────────────────────
71
72/// Parse a Cargo.toml file for workspace members, package name, and dependencies.
73pub fn parse_cargo_toml(path: &Path) -> Option<ManifestResult> {
74    let content = std::fs::read_to_string(path).ok()?;
75    let manifest_path = path.to_string_lossy().to_string();
76
77    let toml_value: toml::Value = toml::from_str(&content).ok()?;
78    let table = toml_value.as_table()?;
79
80    let mut result = ManifestResult::new();
81
82    // Extract package name
83    if let Some(package) = table.get("package").and_then(|v| v.as_table()) {
84        if let Some(name) = package.get("name").and_then(|v| v.as_str()) {
85            result
86                .packages
87                .insert(name.to_string(), manifest_path.clone());
88        }
89    }
90
91    // Extract workspace members
92    if let Some(workspace) = table.get("workspace").and_then(|v| v.as_table()) {
93        if let Some(members) = workspace.get("members").and_then(|v| v.as_array()) {
94            let member_strings: Vec<String> = members
95                .iter()
96                .filter_map(|m| m.as_str().map(|s| s.to_string()))
97                .collect();
98
99            if !member_strings.is_empty() {
100                let root = path
101                    .parent()
102                    .map(|p| p.to_string_lossy().to_string())
103                    .unwrap_or_default();
104
105                result.workspaces.push(Workspace {
106                    root,
107                    members: member_strings,
108                    kind: "cargo".to_string(),
109                });
110            }
111        }
112    }
113
114    // Extract [dependencies]
115    if let Some(deps) = table.get("dependencies").and_then(|v| v.as_table()) {
116        for (name, value) in deps {
117            let version = extract_cargo_dep_version(value);
118            result.dependencies.push(Dependency {
119                name: name.clone(),
120                version,
121                dev: false,
122                manifest_path: manifest_path.clone(),
123            });
124        }
125    }
126
127    // Extract [dev-dependencies]
128    if let Some(deps) = table.get("dev-dependencies").and_then(|v| v.as_table()) {
129        for (name, value) in deps {
130            let version = extract_cargo_dep_version(value);
131            result.dependencies.push(Dependency {
132                name: name.clone(),
133                version,
134                dev: true,
135                manifest_path: manifest_path.clone(),
136            });
137        }
138    }
139
140    // Extract [build-dependencies]
141    if let Some(deps) = table.get("build-dependencies").and_then(|v| v.as_table()) {
142        for (name, value) in deps {
143            let version = extract_cargo_dep_version(value);
144            result.dependencies.push(Dependency {
145                name: name.clone(),
146                version,
147                dev: false,
148                manifest_path: manifest_path.clone(),
149            });
150        }
151    }
152
153    Some(result)
154}
155
156/// Extract version string from a Cargo dependency value.
157/// Handles both `"1.0"` (string) and `{ version = "1.0", ... }` (table) forms.
158fn extract_cargo_dep_version(value: &toml::Value) -> String {
159    match value {
160        toml::Value::String(s) => s.clone(),
161        toml::Value::Table(t) => t
162            .get("version")
163            .and_then(|v| v.as_str())
164            .unwrap_or("")
165            .to_string(),
166        _ => String::new(),
167    }
168}
169
170// ── package.json Parsing ─────────────────────────────────────────────────
171
172/// Parse a package.json file for workspaces and dependencies.
173pub fn parse_package_json(path: &Path) -> Option<ManifestResult> {
174    let content = std::fs::read_to_string(path).ok()?;
175    let manifest_path = path.to_string_lossy().to_string();
176
177    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
178    let obj = json.as_object()?;
179
180    let mut result = ManifestResult::new();
181
182    // Extract package name
183    if let Some(name) = obj.get("name").and_then(|v| v.as_str()) {
184        result
185            .packages
186            .insert(name.to_string(), manifest_path.clone());
187    }
188
189    // Extract workspaces
190    if let Some(workspaces) = obj.get("workspaces") {
191        let member_strings = match workspaces {
192            serde_json::Value::Array(arr) => arr
193                .iter()
194                .filter_map(|v| v.as_str().map(|s| s.to_string()))
195                .collect::<Vec<_>>(),
196            serde_json::Value::Object(obj) => {
197                // npm workspaces can be { "packages": ["pkg/*"] }
198                obj.get("packages")
199                    .and_then(|v| v.as_array())
200                    .map(|arr| {
201                        arr.iter()
202                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
203                            .collect()
204                    })
205                    .unwrap_or_default()
206            }
207            _ => Vec::new(),
208        };
209
210        if !member_strings.is_empty() {
211            let root = path
212                .parent()
213                .map(|p| p.to_string_lossy().to_string())
214                .unwrap_or_default();
215
216            result.workspaces.push(Workspace {
217                root,
218                members: member_strings,
219                kind: "npm".to_string(),
220            });
221        }
222    }
223
224    // Extract dependencies
225    if let Some(deps) = obj.get("dependencies").and_then(|v| v.as_object()) {
226        for (name, value) in deps {
227            let version = value.as_str().unwrap_or("").to_string();
228            result.dependencies.push(Dependency {
229                name: name.clone(),
230                version,
231                dev: false,
232                manifest_path: manifest_path.clone(),
233            });
234        }
235    }
236
237    // Extract devDependencies
238    if let Some(deps) = obj.get("devDependencies").and_then(|v| v.as_object()) {
239        for (name, value) in deps {
240            let version = value.as_str().unwrap_or("").to_string();
241            result.dependencies.push(Dependency {
242                name: name.clone(),
243                version,
244                dev: true,
245                manifest_path: manifest_path.clone(),
246            });
247        }
248    }
249
250    // Extract peerDependencies
251    if let Some(deps) = obj.get("peerDependencies").and_then(|v| v.as_object()) {
252        for (name, value) in deps {
253            let version = value.as_str().unwrap_or("").to_string();
254            result.dependencies.push(Dependency {
255                name: name.clone(),
256                version,
257                dev: false,
258                manifest_path: manifest_path.clone(),
259            });
260        }
261    }
262
263    Some(result)
264}
265
266// ── go.mod Parsing ────────────────────────────────────────────────────────
267
268/// Parse a go.mod file for module name, Go version, and dependencies.
269pub fn parse_go_mod(path: &Path) -> Option<ManifestResult> {
270    let content = std::fs::read_to_string(path).ok()?;
271    let manifest_path = path.to_string_lossy().to_string();
272
273    let mut result = ManifestResult::new();
274
275    // Extract module name: `module github.com/user/repo`
276    for line in content.lines() {
277        let trimmed = line.trim();
278        if let Some(module) = trimmed.strip_prefix("module ") {
279            let module = module.trim();
280            result
281                .packages
282                .insert(module.to_string(), manifest_path.clone());
283            break;
284        }
285    }
286
287    // Parse require blocks (both single-line and block form)
288    let mut in_require_block = false;
289    for line in content.lines() {
290        let trimmed = line.trim();
291
292        if trimmed == "require (" {
293            in_require_block = true;
294            continue;
295        }
296        if in_require_block && trimmed == ")" {
297            in_require_block = false;
298            continue;
299        }
300
301        // Single-line require: `require github.com/pkg/errors v0.9.1`
302        if let Some(rest) = trimmed.strip_prefix("require ") {
303            if !rest.starts_with('(') {
304                if let Some(dep) = parse_go_require_line(rest, &manifest_path) {
305                    result.dependencies.push(dep);
306                }
307            }
308            continue;
309        }
310
311        // Inside require block
312        if in_require_block {
313            // Skip comments and empty lines
314            if trimmed.is_empty() || trimmed.starts_with("//") {
315                continue;
316            }
317            if let Some(dep) = parse_go_require_line(trimmed, &manifest_path) {
318                result.dependencies.push(dep);
319            }
320        }
321    }
322
323    Some(result)
324}
325
326/// Parse a single Go require line like `github.com/pkg/errors v0.9.1`
327fn parse_go_require_line(line: &str, manifest_path: &str) -> Option<Dependency> {
328    // Remove inline comments
329    let line = line.split("//").next()?.trim();
330    let mut parts = line.split_whitespace();
331    let name = parts.next()?;
332    let version = parts.next().unwrap_or("");
333    // Skip indirect dependencies marker, but still capture them
334    let is_indirect = line.contains("// indirect");
335    Some(Dependency {
336        name: name.to_string(),
337        version: version.to_string(),
338        dev: is_indirect,
339        manifest_path: manifest_path.to_string(),
340    })
341}
342
343// ── pyproject.toml Parsing ────────────────────────────────────────────────
344
345/// Parse a pyproject.toml file for project name, version, and dependencies.
346pub fn parse_pyproject_toml(path: &Path) -> Option<ManifestResult> {
347    let content = std::fs::read_to_string(path).ok()?;
348    let manifest_path = path.to_string_lossy().to_string();
349
350    let toml_value: toml::Value = toml::from_str(&content).ok()?;
351    let table = toml_value.as_table()?;
352
353    let mut result = ManifestResult::new();
354
355    // PEP 621 [project] table
356    if let Some(project) = table.get("project").and_then(|v| v.as_table()) {
357        if let Some(name) = project.get("name").and_then(|v| v.as_str()) {
358            result
359                .packages
360                .insert(name.to_string(), manifest_path.clone());
361        }
362
363        // [project.dependencies]
364        if let Some(deps) = project.get("dependencies").and_then(|v| v.as_array()) {
365            for dep in deps {
366                if let Some(spec) = dep.as_str() {
367                    if let Some(d) = parse_python_dep_spec(spec, false, &manifest_path) {
368                        result.dependencies.push(d);
369                    }
370                }
371            }
372        }
373
374        // [project.optional-dependencies] — treat as dev
375        if let Some(opt_deps) = project
376            .get("optional-dependencies")
377            .and_then(|v| v.as_table())
378        {
379            for (_group, deps) in opt_deps {
380                if let Some(arr) = deps.as_array() {
381                    for dep in arr {
382                        if let Some(spec) = dep.as_str() {
383                            if let Some(d) = parse_python_dep_spec(spec, true, &manifest_path) {
384                                result.dependencies.push(d);
385                            }
386                        }
387                    }
388                }
389            }
390        }
391    }
392
393    // Poetry: [tool.poetry]
394    if let Some(tool) = table.get("tool").and_then(|v| v.as_table()) {
395        if let Some(poetry) = tool.get("poetry").and_then(|v| v.as_table()) {
396            if let Some(name) = poetry.get("name").and_then(|v| v.as_str()) {
397                result
398                    .packages
399                    .insert(name.to_string(), manifest_path.clone());
400            }
401
402            // [tool.poetry.dependencies]
403            if let Some(deps) = poetry.get("dependencies").and_then(|v| v.as_table()) {
404                for (name, value) in deps {
405                    // Skip python itself
406                    if name == "python" {
407                        continue;
408                    }
409                    let version = extract_poetry_version(value);
410                    result.dependencies.push(Dependency {
411                        name: name.clone(),
412                        version,
413                        dev: false,
414                        manifest_path: manifest_path.clone(),
415                    });
416                }
417            }
418
419            // [tool.poetry.dev-dependencies]
420            if let Some(deps) = poetry.get("dev-dependencies").and_then(|v| v.as_table()) {
421                for (name, value) in deps {
422                    let version = extract_poetry_version(value);
423                    result.dependencies.push(Dependency {
424                        name: name.clone(),
425                        version,
426                        dev: true,
427                        manifest_path: manifest_path.clone(),
428                    });
429                }
430            }
431        }
432    }
433
434    Some(result)
435}
436
437/// Parse a PEP 508 dependency specifier like `requests>=2.28.0` into name + version.
438fn parse_python_dep_spec(spec: &str, dev: bool, manifest_path: &str) -> Option<Dependency> {
439    let spec = spec.trim();
440    if spec.is_empty() {
441        return None;
442    }
443
444    // Split on version operators: >=, <=, ==, !=, ~=, >, <
445    // Also handle extras like `package[extra]>=1.0`
446    let name_end = spec
447        .find(['>', '<', '=', '!', '~', ';', '['])
448        .unwrap_or(spec.len());
449    let name = spec[..name_end].trim();
450
451    // Extract version part (everything after the operator)
452    let version_part = &spec[name_end..];
453    // Strip extras like [extra] before version
454    let version_part = if version_part.starts_with('[') {
455        version_part
456            .find(']')
457            .map(|i| &version_part[i + 1..])
458            .unwrap_or(version_part)
459    } else {
460        version_part
461    };
462    // Strip environment markers (after `;`)
463    let version_part = version_part.split(';').next().unwrap_or("").trim();
464
465    Some(Dependency {
466        name: name.to_string(),
467        version: version_part.to_string(),
468        dev,
469        manifest_path: manifest_path.to_string(),
470    })
471}
472
473/// Extract version from a Poetry dependency value.
474fn extract_poetry_version(value: &toml::Value) -> String {
475    match value {
476        toml::Value::String(s) => s.clone(),
477        toml::Value::Table(t) => t
478            .get("version")
479            .and_then(|v| v.as_str())
480            .unwrap_or("")
481            .to_string(),
482        _ => String::new(),
483    }
484}
485
486// ── pom.xml Parsing ───────────────────────────────────────────────────────
487
488/// Parse a pom.xml file for groupId, artifactId, version, and dependencies.
489/// Uses basic regex extraction — no full XML parser needed.
490pub fn parse_pom_xml(path: &Path) -> Option<ManifestResult> {
491    let content = std::fs::read_to_string(path).ok()?;
492    let manifest_path = path.to_string_lossy().to_string();
493
494    let mut result = ManifestResult::new();
495
496    // Extract top-level artifactId (not inside <dependency>)
497    // Find the first <artifactId> that isn't inside a <dependencies> block
498    if let Some(artifact_id) = extract_xml_tag_before_deps(&content, "artifactId") {
499        let group_id = extract_xml_tag_before_deps(&content, "groupId").unwrap_or_default();
500        let name = if group_id.is_empty() {
501            artifact_id.clone()
502        } else {
503            format!("{group_id}:{artifact_id}")
504        };
505        result.packages.insert(name, manifest_path.clone());
506    }
507
508    // Extract dependencies from <dependencies> block
509    let re_dep = regex::Regex::new(r"(?s)<dependency>(.*?)</dependency>").ok()?;
510    for cap in re_dep.captures_iter(&content) {
511        let dep_block = &cap[1];
512        let group = extract_xml_tag(dep_block, "groupId").unwrap_or_default();
513        let artifact = match extract_xml_tag(dep_block, "artifactId") {
514            Some(a) => a,
515            None => continue,
516        };
517        let version = extract_xml_tag(dep_block, "version").unwrap_or_default();
518        let scope = extract_xml_tag(dep_block, "scope").unwrap_or_default();
519
520        let name = if group.is_empty() {
521            artifact
522        } else {
523            format!("{group}:{artifact}")
524        };
525
526        result.dependencies.push(Dependency {
527            name,
528            version,
529            dev: scope == "test",
530            manifest_path: manifest_path.clone(),
531        });
532    }
533
534    Some(result)
535}
536
537/// Extract text content of an XML tag using basic regex.
538fn extract_xml_tag(content: &str, tag: &str) -> Option<String> {
539    let pattern = format!(r"<{tag}>\s*(.*?)\s*</{tag}>");
540    let re = regex::Regex::new(&pattern).ok()?;
541    re.captures(content).map(|c| c[1].to_string())
542}
543
544/// Extract the first occurrence of an XML tag that appears before any `<dependencies>` block.
545fn extract_xml_tag_before_deps(content: &str, tag: &str) -> Option<String> {
546    let deps_pos = content.find("<dependencies>");
547    let search_area = match deps_pos {
548        Some(pos) => &content[..pos],
549        None => content,
550    };
551    extract_xml_tag(search_area, tag)
552}
553
554// ── .csproj Parsing ───────────────────────────────────────────────────────
555
556/// Parse a .csproj file for PackageReference items.
557pub fn parse_csproj(path: &Path) -> Option<ManifestResult> {
558    let content = std::fs::read_to_string(path).ok()?;
559    let manifest_path = path.to_string_lossy().to_string();
560
561    let mut result = ManifestResult::new();
562
563    // Extract project name from filename
564    let name = path
565        .file_stem()
566        .and_then(|n| n.to_str())
567        .unwrap_or("unknown");
568    result
569        .packages
570        .insert(name.to_string(), manifest_path.clone());
571
572    // Match: <PackageReference Include="Name" Version="1.0" />
573    // or:    <PackageReference Include="Name" Version="1.0"></PackageReference>
574    let re =
575        regex::Regex::new(r#"<PackageReference\s+Include="([^"]+)"\s+Version="([^"]*)"[^/]*/>"#)
576            .ok()?;
577    for cap in re.captures_iter(&content) {
578        result.dependencies.push(Dependency {
579            name: cap[1].to_string(),
580            version: cap[2].to_string(),
581            dev: false,
582            manifest_path: manifest_path.clone(),
583        });
584    }
585
586    // Also match the two-line form: Include then Version on separate attributes
587    let re2 =
588        regex::Regex::new(r#"<PackageReference\s+Include="([^"]+)"\s*Version="([^"]*)"[^>]*>"#)
589            .ok()?;
590    // Only add if not already captured (avoid duplicates by checking names)
591    let existing_names: std::collections::HashSet<String> =
592        result.dependencies.iter().map(|d| d.name.clone()).collect();
593    for cap in re2.captures_iter(&content) {
594        let name = cap[1].to_string();
595        if !existing_names.contains(&name) {
596            result.dependencies.push(Dependency {
597                name,
598                version: cap[2].to_string(),
599                dev: false,
600                manifest_path: manifest_path.clone(),
601            });
602        }
603    }
604
605    Some(result)
606}
607
608// ── Gemfile Parsing ───────────────────────────────────────────────────────
609
610/// Parse a Gemfile for gem dependencies.
611pub fn parse_gemfile(path: &Path) -> Option<ManifestResult> {
612    let content = std::fs::read_to_string(path).ok()?;
613    let manifest_path = path.to_string_lossy().to_string();
614
615    let mut result = ManifestResult::new();
616
617    // Match: gem 'name' or gem "name" with optional version
618    let re = regex::Regex::new(r#"gem\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]*)['"]\s*)?"#).ok()?;
619
620    let mut in_dev_group = false;
621    for line in content.lines() {
622        let trimmed = line.trim();
623
624        // Track group :development / :test blocks
625        if trimmed.starts_with("group")
626            && (trimmed.contains(":development") || trimmed.contains(":test"))
627        {
628            in_dev_group = true;
629            continue;
630        }
631        if in_dev_group && trimmed == "end" {
632            in_dev_group = false;
633            continue;
634        }
635
636        if let Some(cap) = re.captures(trimmed) {
637            let name = cap[1].to_string();
638            let version = cap
639                .get(2)
640                .map(|m| m.as_str().to_string())
641                .unwrap_or_default();
642            result.dependencies.push(Dependency {
643                name,
644                version,
645                dev: in_dev_group,
646                manifest_path: manifest_path.clone(),
647            });
648        }
649    }
650
651    Some(result)
652}
653
654// ── composer.json Parsing ─────────────────────────────────────────────────
655
656/// Parse a composer.json file for PHP dependencies.
657pub fn parse_composer_json(path: &Path) -> Option<ManifestResult> {
658    let content = std::fs::read_to_string(path).ok()?;
659    let manifest_path = path.to_string_lossy().to_string();
660
661    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
662    let obj = json.as_object()?;
663
664    let mut result = ManifestResult::new();
665
666    // Extract package name
667    if let Some(name) = obj.get("name").and_then(|v| v.as_str()) {
668        result
669            .packages
670            .insert(name.to_string(), manifest_path.clone());
671    }
672
673    // Extract require
674    if let Some(deps) = obj.get("require").and_then(|v| v.as_object()) {
675        for (name, value) in deps {
676            // Skip php version constraint
677            if name == "php" {
678                continue;
679            }
680            let version = value.as_str().unwrap_or("").to_string();
681            result.dependencies.push(Dependency {
682                name: name.clone(),
683                version,
684                dev: false,
685                manifest_path: manifest_path.clone(),
686            });
687        }
688    }
689
690    // Extract require-dev
691    if let Some(deps) = obj.get("require-dev").and_then(|v| v.as_object()) {
692        for (name, value) in deps {
693            let version = value.as_str().unwrap_or("").to_string();
694            result.dependencies.push(Dependency {
695                name: name.clone(),
696                version,
697                dev: true,
698                manifest_path: manifest_path.clone(),
699            });
700        }
701    }
702
703    Some(result)
704}
705
706// ── Directory Scanning ───────────────────────────────────────────────────
707
708/// Scan a directory for all manifest files and parse them.
709///
710/// Walks the directory tree looking for manifest files (Cargo.toml, package.json,
711/// go.mod, pyproject.toml, pom.xml, .csproj, Gemfile, composer.json),
712/// respecting `.gitignore` rules (via the `ignore` crate) to match indexer behavior.
713pub fn scan_manifests(root: &Path) -> ManifestResult {
714    let mut result = ManifestResult::new();
715
716    let walker = ignore::WalkBuilder::new(root)
717        .hidden(true) // skip hidden files/dirs
718        .git_ignore(true) // respect .gitignore
719        .git_global(true) // respect global gitignore
720        .git_exclude(true) // respect .git/info/exclude
721        .build();
722
723    for entry in walker {
724        let entry = match entry {
725            Ok(e) => e,
726            Err(_) => continue,
727        };
728
729        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
730            continue;
731        }
732
733        let path = entry.path();
734        let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
735
736        match file_name {
737            "Cargo.toml" => {
738                if let Some(manifest) = parse_cargo_toml(path) {
739                    result.merge(manifest);
740                }
741            }
742            "package.json" => {
743                if let Some(manifest) = parse_package_json(path) {
744                    result.merge(manifest);
745                }
746            }
747            "go.mod" => {
748                if let Some(manifest) = parse_go_mod(path) {
749                    result.merge(manifest);
750                }
751            }
752            "pyproject.toml" => {
753                if let Some(manifest) = parse_pyproject_toml(path) {
754                    result.merge(manifest);
755                }
756            }
757            "pom.xml" => {
758                if let Some(manifest) = parse_pom_xml(path) {
759                    result.merge(manifest);
760                }
761            }
762            "Gemfile" => {
763                if let Some(manifest) = parse_gemfile(path) {
764                    result.merge(manifest);
765                }
766            }
767            "composer.json" => {
768                if let Some(manifest) = parse_composer_json(path) {
769                    result.merge(manifest);
770                }
771            }
772            _ => {
773                // Check for .csproj files by extension
774                if file_name.ends_with(".csproj") {
775                    if let Some(manifest) = parse_csproj(path) {
776                        result.merge(manifest);
777                    }
778                }
779            }
780        }
781    }
782
783    result
784}
785
786#[cfg(test)]
787#[path = "tests/manifest_tests.rs"]
788mod tests;