1use std::collections::HashSet;
17use std::path::Path;
18
19use anyhow::Result;
20use tracing::warn;
21
22use super::walker::WalkedFile;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct DepEntry {
29 pub ecosystem: DepEcosystem,
31 pub name: String,
33 pub version: DepVersion,
35 pub manifest: ManifestKind,
37 pub dev: bool,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum DepEcosystem {
44 Cargo,
45 Npm,
46 Go,
47}
48
49impl DepEcosystem {
50 pub fn as_str(self) -> &'static str {
51 match self {
52 Self::Cargo => "cargo",
53 Self::Npm => "npm",
54 Self::Go => "go",
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum DepVersion {
62 Declared(String),
64 Workspace,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ManifestKind {
71 CargoToml,
72 PackageJson,
73 GoMod,
74}
75
76#[derive(Debug, Clone)]
78pub struct DepSignals {
79 pub deps: Vec<DepEntry>,
81 pub manifests_found: Vec<(ManifestKind, String)>,
83}
84
85pub fn dep_record_key(dep: &DepEntry) -> String {
87 format!("dep:{}:{}", dep.ecosystem.as_str(), dep.name)
88}
89
90pub fn dep_display_name_from_key(key: &str) -> &str {
95 let Some(rest) = key.strip_prefix("dep:") else {
96 return key;
97 };
98 match rest.split_once(':') {
99 Some(("cargo" | "npm" | "go", name)) => name,
100 _ => rest,
101 }
102}
103
104pub fn parse_dep_key(key: &str) -> Option<(Option<DepEcosystem>, &str)> {
106 let rest = key.strip_prefix("dep:")?;
107 match rest.split_once(':') {
108 Some(("cargo", name)) => Some((Some(DepEcosystem::Cargo), name)),
109 Some(("npm", name)) => Some((Some(DepEcosystem::Npm), name)),
110 Some(("go", name)) => Some((Some(DepEcosystem::Go), name)),
111 _ => Some((None, rest)),
112 }
113}
114
115impl DepSignals {
116 pub fn empty() -> Self {
118 Self {
119 deps: Vec::new(),
120 manifests_found: Vec::new(),
121 }
122 }
123}
124
125pub fn parse_dependencies(repo_path: &Path, walked_files: &[WalkedFile]) -> Result<DepSignals> {
135 let mut manifests: Vec<(ManifestKind, &str)> = walked_files
137 .iter()
138 .filter_map(|f| {
139 filename_to_manifest_kind(&f.rel_path).map(|kind| (kind, f.rel_path.as_str()))
140 })
141 .collect();
142
143 if manifests.is_empty() {
144 return Ok(DepSignals::empty());
145 }
146
147 manifests.sort_by_key(|(_, path)| path.matches('/').count());
149
150 let mut all_deps: Vec<DepEntry> = Vec::new();
151 let mut manifests_found: Vec<(ManifestKind, String)> = Vec::new();
152
153 for (kind, rel_path) in &manifests {
154 let abs_path = repo_path.join(rel_path);
155 let content = match std::fs::read_to_string(&abs_path) {
156 Ok(c) => c,
157 Err(e) => {
158 warn!("deps: cannot read {rel_path}: {e}");
159 continue;
160 }
161 };
162
163 let entries = match kind {
164 ManifestKind::CargoToml => parse_cargo_toml(&content),
165 ManifestKind::PackageJson => parse_package_json(&content),
166 ManifestKind::GoMod => parse_go_mod(&content),
167 };
168
169 all_deps.extend(entries);
170 manifests_found.push((*kind, rel_path.to_string()));
171 }
172
173 let mut seen = HashSet::new();
176 let mut deduped: Vec<DepEntry> = Vec::with_capacity(all_deps.len());
177
178 for dep in all_deps {
179 if seen.insert((dep.ecosystem, dep.name.clone())) {
180 deduped.push(dep);
181 }
182 }
183
184 deduped.sort_unstable_by(|a, b| a.name.cmp(&b.name));
186
187 Ok(DepSignals {
188 deps: deduped,
189 manifests_found,
190 })
191}
192
193fn filename_to_manifest_kind(rel_path: &str) -> Option<ManifestKind> {
197 let filename = rel_path.rsplit('/').next().unwrap_or(rel_path);
198 match filename {
199 "Cargo.toml" => Some(ManifestKind::CargoToml),
200 "package.json" => Some(ManifestKind::PackageJson),
201 "go.mod" => Some(ManifestKind::GoMod),
202 _ => None,
203 }
204}
205
206fn parse_cargo_toml(content: &str) -> Vec<DepEntry> {
212 let mut deps = Vec::new();
213
214 #[derive(Clone, Copy)]
215 enum Section {
216 None,
217 Dependencies,
218 DevDependencies,
219 BuildDependencies,
220 }
221
222 let mut section = Section::None;
223 let mut table_dep_name: Option<String> = None;
225 let mut table_dev = false;
226
227 for line in content.lines() {
228 let trimmed = line.trim();
229
230 if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
232 let header =
234 if let Some(inner2) = inner.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
235 if let Some(name) = table_dep_name.take() {
237 deps.push(DepEntry {
238 name,
239 ecosystem: DepEcosystem::Cargo,
240 version: DepVersion::Declared(String::new()),
241 manifest: ManifestKind::CargoToml,
242 dev: table_dev,
243 });
244 }
245 section = Section::None;
246 let _ = inner2;
247 continue;
248 } else {
249 inner.trim()
250 };
251
252 if let Some(name) = table_dep_name.take() {
254 deps.push(DepEntry {
255 ecosystem: DepEcosystem::Cargo,
256 name,
257 version: DepVersion::Declared(String::new()),
258 manifest: ManifestKind::CargoToml,
259 dev: table_dev,
260 });
261 }
262
263 if let Some(dep_name) = header.strip_prefix("dependencies.") {
265 section = Section::Dependencies;
266 table_dep_name = Some(dep_name.to_string());
267 table_dev = false;
268 continue;
269 }
270 if let Some(dep_name) = header.strip_prefix("dev-dependencies.") {
271 section = Section::DevDependencies;
272 table_dep_name = Some(dep_name.to_string());
273 table_dev = true;
274 continue;
275 }
276 if let Some(dep_name) = header.strip_prefix("build-dependencies.") {
277 section = Section::BuildDependencies;
278 table_dep_name = Some(dep_name.to_string());
279 table_dev = true;
280 continue;
281 }
282
283 section = match header {
284 "dependencies" => Section::Dependencies,
285 "dev-dependencies" => Section::DevDependencies,
286 "build-dependencies" => Section::BuildDependencies,
287 _ => Section::None,
288 };
289 continue;
290 }
291
292 let dev = match section {
294 Section::None => continue,
295 Section::Dependencies => false,
296 Section::DevDependencies => true,
297 Section::BuildDependencies => true,
298 };
299
300 if trimmed.is_empty() || trimmed.starts_with('#') {
302 continue;
303 }
304
305 if let Some(ref dep_name) = table_dep_name {
307 if let Some((key, val)) = trimmed.split_once('=') {
308 let key = key.trim();
309 let val = val.trim();
310 if key == "version" {
311 if let Some(version) = extract_quoted_string(val) {
312 deps.push(DepEntry {
313 ecosystem: DepEcosystem::Cargo,
314 name: dep_name.clone(),
315 version: DepVersion::Declared(version),
316 manifest: ManifestKind::CargoToml,
317 dev,
318 });
319 table_dep_name = None;
320 }
321 } else if key == "workspace" && val.trim() == "true" {
322 deps.push(DepEntry {
323 ecosystem: DepEcosystem::Cargo,
324 name: dep_name.clone(),
325 version: DepVersion::Workspace,
326 manifest: ManifestKind::CargoToml,
327 dev,
328 });
329 table_dep_name = None;
330 }
331 }
332 continue;
333 }
334
335 if let Some((name_part, value_part)) = trimmed.split_once('=') {
337 let name = name_part.trim();
338 let value = value_part.trim();
339
340 if let Some((dep_name, sub_key)) = name.split_once('.') {
342 let dep_name = dep_name.trim();
343 let sub_key = sub_key.trim();
344 if sub_key == "workspace" && !dep_name.is_empty() {
345 deps.push(DepEntry {
346 ecosystem: DepEcosystem::Cargo,
347 name: dep_name.to_string(),
348 version: DepVersion::Workspace,
349 manifest: ManifestKind::CargoToml,
350 dev,
351 });
352 }
353 continue;
354 }
355
356 if name.is_empty() {
357 continue;
358 }
359
360 let version = if value.starts_with('"') {
361 extract_quoted_string(value)
363 } else if value.starts_with('{') {
364 extract_version_from_inline_table(value)
366 } else {
367 continue;
369 };
370
371 if let Some(version) = version {
372 deps.push(DepEntry {
373 ecosystem: DepEcosystem::Cargo,
374 name: name.to_string(),
375 version: DepVersion::Declared(version),
376 manifest: ManifestKind::CargoToml,
377 dev,
378 });
379 }
380 }
381 }
382
383 if let Some(name) = table_dep_name {
385 deps.push(DepEntry {
386 name,
387 ecosystem: DepEcosystem::Cargo,
388 version: DepVersion::Declared(String::new()),
389 manifest: ManifestKind::CargoToml,
390 dev: table_dev,
391 });
392 }
393
394 deps
395}
396
397fn parse_package_json(content: &str) -> Vec<DepEntry> {
399 let parsed: serde_json::Value = match serde_json::from_str(content) {
400 Ok(v) => v,
401 Err(_) => return Vec::new(),
402 };
403
404 let mut deps = Vec::new();
405
406 if let Some(obj) = parsed.get("dependencies").and_then(|v| v.as_object()) {
407 for (name, version) in obj {
408 deps.push(DepEntry {
409 ecosystem: DepEcosystem::Npm,
410 name: name.clone(),
411 version: DepVersion::Declared(version.as_str().unwrap_or("*").to_string()),
412 manifest: ManifestKind::PackageJson,
413 dev: false,
414 });
415 }
416 }
417
418 if let Some(obj) = parsed.get("devDependencies").and_then(|v| v.as_object()) {
419 for (name, version) in obj {
420 deps.push(DepEntry {
421 ecosystem: DepEcosystem::Npm,
422 name: name.clone(),
423 version: DepVersion::Declared(version.as_str().unwrap_or("*").to_string()),
424 manifest: ManifestKind::PackageJson,
425 dev: true,
426 });
427 }
428 }
429
430 deps
431}
432
433fn parse_go_mod(content: &str) -> Vec<DepEntry> {
437 let mut deps = Vec::new();
438 let mut in_require_block = false;
439
440 for line in content.lines() {
441 let trimmed = line.trim();
442
443 if trimmed.starts_with("require (") || trimmed == "require(" {
444 in_require_block = true;
445 continue;
446 }
447
448 if in_require_block {
449 if trimmed == ")" {
450 in_require_block = false;
451 continue;
452 }
453
454 if let Some(dep) = parse_go_require_line(trimmed) {
456 deps.push(dep);
457 }
458 continue;
459 }
460
461 if let Some(rest) = trimmed.strip_prefix("require ") {
463 let rest = rest.trim();
464 if let Some(dep) = parse_go_require_line(rest) {
465 deps.push(dep);
466 }
467 }
468 }
469
470 deps
471}
472
473fn extract_quoted_string(s: &str) -> Option<String> {
477 let s = s.trim();
478 if s.starts_with('"') && s.len() > 1 {
479 if let Some(end) = s[1..].find('"') {
480 return Some(s[1..1 + end].to_string());
481 }
482 }
483 None
484}
485
486fn extract_version_from_inline_table(s: &str) -> Option<String> {
488 let inner = s.trim().trim_start_matches('{').trim_end_matches('}');
490 for part in inner.split(',') {
491 let part = part.trim();
492 if let Some((key, val)) = part.split_once('=') {
493 if key.trim() == "version" {
494 return extract_quoted_string(val);
495 }
496 }
497 }
498 None
499}
500
501fn parse_go_require_line(line: &str) -> Option<DepEntry> {
503 let line = line.trim();
504 if line.is_empty() || line.starts_with("//") {
505 return None;
506 }
507
508 let without_comment = if let Some(idx) = line.find("//") {
510 line[..idx].trim()
511 } else {
512 line
513 };
514
515 let mut parts = without_comment.split_whitespace();
516 let module = parts.next()?;
517 let version = parts.next().unwrap_or("").to_string();
518
519 Some(DepEntry {
520 ecosystem: DepEcosystem::Go,
521 name: module.to_string(),
522 version: DepVersion::Declared(version),
523 manifest: ManifestKind::GoMod,
524 dev: false,
525 })
526}
527
528#[cfg(test)]
531mod tests {
532 use super::*;
533 use std::fs;
534 use std::path::PathBuf;
535 use tempfile::TempDir;
536
537 fn find_dep<'a>(deps: &'a [DepEntry], name: &str) -> Option<&'a DepEntry> {
540 deps.iter().find(|d| d.name == name)
541 }
542
543 fn write(dir: &Path, rel: &str, content: &str) {
544 let full = dir.join(rel);
545 if let Some(parent) = full.parent() {
546 fs::create_dir_all(parent).unwrap();
547 }
548 fs::write(full, content).unwrap();
549 }
550
551 fn walked_file(rel_path: &str) -> WalkedFile {
552 WalkedFile {
553 abs_path: PathBuf::from(rel_path),
554 rel_path: rel_path.to_string(),
555 language: super::super::walker::Language::Unknown,
556 size_bytes: 0,
557 mtime_secs: 0,
558 }
559 }
560
561 #[test]
564 fn cargo_toml_basic() {
565 let deps = parse_cargo_toml(
566 r#"
567[package]
568name = "my-crate"
569version = "0.1.0"
570
571[dependencies]
572serde = "1.0"
573anyhow = "1.0"
574tokio = "1.40"
575"#,
576 );
577
578 assert_eq!(deps.len(), 3);
579 let serde = find_dep(&deps, "serde").unwrap();
580 assert_eq!(serde.ecosystem, DepEcosystem::Cargo);
581 assert_eq!(serde.version, DepVersion::Declared("1.0".into()));
582 assert_eq!(serde.manifest, ManifestKind::CargoToml);
583 assert!(!serde.dev);
584 }
585
586 #[test]
587 fn cargo_toml_inline_table() {
588 let deps = parse_cargo_toml(
589 r#"
590[dependencies]
591serde = { version = "1.0", features = ["derive"] }
592tokio = { version = "1.40", features = ["full"] }
593"#,
594 );
595
596 assert_eq!(deps.len(), 2);
597 let serde = find_dep(&deps, "serde").unwrap();
598 assert_eq!(serde.version, DepVersion::Declared("1.0".into()));
599 let tokio = find_dep(&deps, "tokio").unwrap();
600 assert_eq!(tokio.version, DepVersion::Declared("1.40".into()));
601 }
602
603 #[test]
604 fn cargo_toml_dev_deps() {
605 let deps = parse_cargo_toml(
606 r#"
607[dependencies]
608serde = "1.0"
609
610[dev-dependencies]
611tempfile = "3.10"
612criterion = "0.5"
613"#,
614 );
615
616 assert_eq!(deps.len(), 3);
617 let serde = find_dep(&deps, "serde").unwrap();
618 assert!(!serde.dev);
619 let tempfile = find_dep(&deps, "tempfile").unwrap();
620 assert!(tempfile.dev);
621 let criterion = find_dep(&deps, "criterion").unwrap();
622 assert!(criterion.dev);
623 }
624
625 #[test]
626 fn cargo_toml_build_deps() {
627 let deps = parse_cargo_toml(
628 r#"
629[build-dependencies]
630cc = "1.0"
631"#,
632 );
633
634 assert_eq!(deps.len(), 1);
635 let cc = find_dep(&deps, "cc").unwrap();
636 assert!(cc.dev, "build-dependencies should be flagged as dev");
637 }
638
639 #[test]
640 fn cargo_toml_workspace_dep() {
641 let deps = parse_cargo_toml(
642 r#"
643[dependencies]
644serde.workspace = true
645tokio.workspace = true
646"#,
647 );
648
649 assert_eq!(deps.len(), 2);
650 let serde = find_dep(&deps, "serde").unwrap();
651 assert_eq!(serde.version, DepVersion::Workspace);
652 }
653
654 #[test]
655 fn cargo_toml_table_form() {
656 let deps = parse_cargo_toml(
657 r#"
658[dependencies.serde]
659version = "1.0"
660features = ["derive"]
661
662[dependencies.tokio]
663version = "1.40"
664features = ["full"]
665
666[dev-dependencies.tempfile]
667version = "3.10"
668"#,
669 );
670
671 assert_eq!(deps.len(), 3);
672 let serde = find_dep(&deps, "serde").unwrap();
673 assert_eq!(serde.version, DepVersion::Declared("1.0".into()));
674 assert!(!serde.dev);
675 let tokio = find_dep(&deps, "tokio").unwrap();
676 assert_eq!(tokio.version, DepVersion::Declared("1.40".into()));
677 let tempfile = find_dep(&deps, "tempfile").unwrap();
678 assert!(tempfile.dev);
679 }
680
681 #[test]
682 fn cargo_toml_empty() {
683 let deps = parse_cargo_toml(
684 r#"
685[package]
686name = "empty"
687version = "0.1.0"
688"#,
689 );
690
691 assert!(deps.is_empty());
692 }
693
694 #[test]
697 fn package_json_basic() {
698 let deps = parse_package_json(
699 r#"{
700 "name": "my-app",
701 "dependencies": {
702 "react": "^18.0.0",
703 "express": "~4.18.0"
704 },
705 "devDependencies": {
706 "jest": "^29.0.0",
707 "typescript": "^5.0.0"
708 }
709}"#,
710 );
711
712 assert_eq!(deps.len(), 4);
713 let react = find_dep(&deps, "react").unwrap();
714 assert_eq!(react.ecosystem, DepEcosystem::Npm);
715 assert_eq!(react.version, DepVersion::Declared("^18.0.0".into()));
716 assert!(!react.dev);
717 assert_eq!(react.manifest, ManifestKind::PackageJson);
718
719 let jest = find_dep(&deps, "jest").unwrap();
720 assert!(jest.dev);
721 }
722
723 #[test]
724 fn package_json_no_deps() {
725 let deps = parse_package_json(r#"{"name": "empty-app", "version": "1.0.0"}"#);
726 assert!(deps.is_empty());
727 }
728
729 #[test]
730 fn package_json_malformed() {
731 let deps = parse_package_json("{ this is not json }");
732 assert!(
733 deps.is_empty(),
734 "malformed JSON should return empty, not error"
735 );
736 }
737
738 #[test]
741 fn go_mod_basic() {
742 let deps = parse_go_mod(
743 r#"
744module github.com/example/myapp
745
746go 1.21
747
748require (
749 github.com/gin-gonic/gin v1.9.1
750 github.com/lib/pq v1.10.9
751 golang.org/x/sync v0.5.0
752)
753"#,
754 );
755
756 assert_eq!(deps.len(), 3);
757 let gin = find_dep(&deps, "github.com/gin-gonic/gin").unwrap();
758 assert_eq!(gin.ecosystem, DepEcosystem::Go);
759 assert_eq!(gin.version, DepVersion::Declared("v1.9.1".into()));
760 assert_eq!(gin.manifest, ManifestKind::GoMod);
761 assert!(!gin.dev);
762 }
763
764 #[test]
765 fn go_mod_single_require() {
766 let deps = parse_go_mod(
767 r#"
768module github.com/example/myapp
769
770go 1.21
771
772require github.com/lib/pq v1.10.9
773"#,
774 );
775
776 assert_eq!(deps.len(), 1);
777 assert_eq!(deps[0].name, "github.com/lib/pq");
778 assert_eq!(deps[0].version, DepVersion::Declared("v1.10.9".into()));
779 }
780
781 #[test]
782 fn go_mod_indirect() {
783 let deps = parse_go_mod(
784 r#"
785require (
786 github.com/direct/dep v1.0.0
787 github.com/indirect/dep v2.0.0 // indirect
788)
789"#,
790 );
791
792 assert_eq!(deps.len(), 2, "indirect deps should still be included");
793 assert!(find_dep(&deps, "github.com/indirect/dep").is_some());
794 }
795
796 #[test]
797 fn go_mod_empty() {
798 let deps = parse_go_mod(
799 r#"
800module github.com/example/myapp
801
802go 1.21
803"#,
804 );
805
806 assert!(deps.is_empty());
807 }
808
809 #[test]
812 fn parse_dependencies_integration() {
813 let dir = TempDir::new().unwrap();
814
815 write(
816 dir.path(),
817 "Cargo.toml",
818 r#"
819[dependencies]
820serde = "1.0"
821anyhow = "1.0"
822"#,
823 );
824
825 write(
826 dir.path(),
827 "package.json",
828 r#"{"dependencies": {"react": "^18.0.0"}}"#,
829 );
830
831 write(
832 dir.path(),
833 "go.mod",
834 r#"
835module example.com/app
836
837require github.com/gin-gonic/gin v1.9.1
838"#,
839 );
840
841 let walked = vec![
842 walked_file("Cargo.toml"),
843 walked_file("package.json"),
844 walked_file("go.mod"),
845 ];
846
847 let signals = parse_dependencies(dir.path(), &walked).unwrap();
848
849 assert_eq!(signals.manifests_found.len(), 3);
850 assert_eq!(signals.deps.len(), 4);
851 assert!(find_dep(&signals.deps, "serde").is_some());
852 assert!(find_dep(&signals.deps, "react").is_some());
853 assert!(find_dep(&signals.deps, "github.com/gin-gonic/gin").is_some());
854 }
855
856 #[test]
857 fn no_manifests_returns_empty() {
858 let dir = TempDir::new().unwrap();
859 write(dir.path(), "src/main.rs", "fn main() {}");
860
861 let walked = vec![walked_file("src/main.rs")];
862 let signals = parse_dependencies(dir.path(), &walked).unwrap();
863
864 assert!(signals.deps.is_empty());
865 assert!(signals.manifests_found.is_empty());
866 }
867
868 #[test]
869 fn dedup_across_manifests() {
870 let dir = TempDir::new().unwrap();
871
872 write(
874 dir.path(),
875 "Cargo.toml",
876 r#"
877[dependencies]
878serde = "1.0"
879"#,
880 );
881
882 write(
884 dir.path(),
885 "subcrate/Cargo.toml",
886 r#"
887[dependencies]
888serde = "1.1"
889anyhow = "1.0"
890"#,
891 );
892
893 let walked = vec![
894 walked_file("Cargo.toml"),
895 walked_file("subcrate/Cargo.toml"),
896 ];
897
898 let signals = parse_dependencies(dir.path(), &walked).unwrap();
899
900 let serde_entries: Vec<&DepEntry> =
902 signals.deps.iter().filter(|d| d.name == "serde").collect();
903 assert_eq!(serde_entries.len(), 1, "serde should be deduplicated");
904 assert_eq!(
905 serde_entries[0].version,
906 DepVersion::Declared("1.0".into()),
907 "root manifest should win"
908 );
909
910 assert!(find_dep(&signals.deps, "anyhow").is_some());
912 }
913
914 #[test]
915 fn same_name_in_different_ecosystems_do_not_collapse() {
916 let dir = TempDir::new().unwrap();
917
918 write(
919 dir.path(),
920 "Cargo.toml",
921 r#"
922[dependencies]
923react = "1.0"
924"#,
925 );
926
927 write(
928 dir.path(),
929 "package.json",
930 r#"{"dependencies": {"react": "^18.0.0"}}"#,
931 );
932
933 let walked = vec![walked_file("Cargo.toml"), walked_file("package.json")];
934 let signals = parse_dependencies(dir.path(), &walked).unwrap();
935
936 let react_entries: Vec<&DepEntry> =
937 signals.deps.iter().filter(|d| d.name == "react").collect();
938 assert_eq!(
939 react_entries.len(),
940 2,
941 "cross-ecosystem names must not collapse"
942 );
943 assert!(react_entries
944 .iter()
945 .any(|d| d.ecosystem == DepEcosystem::Cargo));
946 assert!(react_entries
947 .iter()
948 .any(|d| d.ecosystem == DepEcosystem::Npm));
949 }
950
951 #[test]
952 fn dep_key_helpers_support_new_and_legacy_formats() {
953 let dep = DepEntry {
954 ecosystem: DepEcosystem::Cargo,
955 name: "serde".into(),
956 version: DepVersion::Declared("1.0".into()),
957 manifest: ManifestKind::CargoToml,
958 dev: false,
959 };
960
961 assert_eq!(dep_record_key(&dep), "dep:cargo:serde");
962 assert_eq!(dep_display_name_from_key("dep:cargo:serde"), "serde");
963 assert_eq!(dep_display_name_from_key("dep:serde"), "serde");
964 assert_eq!(
965 parse_dep_key("dep:npm:react"),
966 Some((Some(DepEcosystem::Npm), "react"))
967 );
968 assert_eq!(parse_dep_key("dep:serde"), Some((None, "serde")));
969 }
970}