1use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Dependency {
14 pub name: String,
16 pub version: String,
18 pub dev: bool,
20 pub manifest_path: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Workspace {
27 pub root: String,
29 pub members: Vec<String>,
31 pub kind: String,
33}
34
35#[derive(Debug, Clone)]
37pub struct ManifestResult {
38 pub workspaces: Vec<Workspace>,
40 pub dependencies: Vec<Dependency>,
42 pub packages: HashMap<String, String>,
44}
45
46impl ManifestResult {
47 pub fn new() -> Self {
49 Self {
50 workspaces: Vec::new(),
51 dependencies: Vec::new(),
52 packages: HashMap::new(),
53 }
54 }
55
56 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
70pub 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 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 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 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 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 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
156fn 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
170pub 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 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 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 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 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 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 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
266pub 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 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 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 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 if in_require_block {
313 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
326fn parse_go_require_line(line: &str, manifest_path: &str) -> Option<Dependency> {
328 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 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
343pub 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 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 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 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 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 if let Some(deps) = poetry.get("dependencies").and_then(|v| v.as_table()) {
404 for (name, value) in deps {
405 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 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
437fn 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 let name_end = spec
447 .find(['>', '<', '=', '!', '~', ';', '['])
448 .unwrap_or(spec.len());
449 let name = spec[..name_end].trim();
450
451 let version_part = &spec[name_end..];
453 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 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
473fn 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
486pub 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 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 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
537fn 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
544fn 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
554pub 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 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 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 let re2 =
588 regex::Regex::new(r#"<PackageReference\s+Include="([^"]+)"\s*Version="([^"]*)"[^>]*>"#)
589 .ok()?;
590 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
608pub 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 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 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
654pub 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 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 if let Some(deps) = obj.get("require").and_then(|v| v.as_object()) {
675 for (name, value) in deps {
676 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 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
706pub fn scan_manifests(root: &Path) -> ManifestResult {
714 let mut result = ManifestResult::new();
715
716 let walker = ignore::WalkBuilder::new(root)
717 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .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 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;