1use std::collections::{HashMap, HashSet, BTreeMap};
15use std::path::{Path, PathBuf};
16
17#[derive(Debug, Clone)]
23pub enum DiscoveryError {
24 IoError { path: PathBuf, message: String },
26 ParseError { path: PathBuf, message: String },
28 CircularDependency { cycle: Vec<String> },
30}
31
32impl std::fmt::Display for DiscoveryError {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 match self {
35 Self::IoError { path, message } => {
36 write!(f, "IO error reading {}: {}", path.display(), message)
37 }
38 Self::ParseError { path, message } => {
39 write!(f, "Parse error in {}: {}", path.display(), message)
40 }
41 Self::CircularDependency { cycle } => {
42 write!(f, "Circular dependency: {}", cycle.join(" → "))
43 }
44 }
45 }
46}
47
48impl std::error::Error for DiscoveryError {}
49
50#[derive(Debug, Clone)]
73pub struct CapabilityManifest {
74 pub name: String,
76 pub version: String,
78 pub description: String,
80 pub category: String,
82 pub source_path: PathBuf,
84 pub integrations: HashMap<String, IntegrationSpec>,
86 pub exports: HashMap<String, String>,
88}
89
90#[derive(Debug, Clone)]
92pub struct IntegrationSpec {
93 pub kind: String,
95 pub reason: String,
97}
98
99#[derive(Debug, Clone, PartialEq)]
101pub struct IntegrationSuggestion {
102 pub crate_name: String,
104 pub reason: String,
106 pub priority: u32,
108 pub category: String,
110 pub synergizes_with: Vec<String>,
112}
113
114#[derive(Debug, Clone, Default)]
120pub struct DependencyGraph {
121 edges: BTreeMap<String, HashSet<String>>,
123}
124
125impl DependencyGraph {
126 pub fn new() -> Self {
128 Self::default()
129 }
130
131 pub fn add_node(&mut self, name: &str) {
133 self.edges.entry(name.to_string()).or_default();
134 }
135
136 pub fn add_edge(&mut self, from: &str, depends_on: &str) {
138 self.add_node(from);
139 self.add_node(depends_on);
140 self.edges.get_mut(from).unwrap().insert(depends_on.to_string());
141 }
142
143 pub fn nodes(&self) -> Vec<&str> {
145 self.edges.keys().map(|s| s.as_str()).collect()
146 }
147
148 pub fn dependencies_of(&self, node: &str) -> Vec<&str> {
150 self.edges
151 .get(node)
152 .map(|deps| deps.iter().map(|s| s.as_str()).collect())
153 .unwrap_or_default()
154 }
155
156 pub fn dependents_of(&self, node: &str) -> Vec<&str> {
158 self.edges
159 .iter()
160 .filter_map(|(k, deps)| {
161 if deps.contains(node) {
162 Some(k.as_str())
163 } else {
164 None
165 }
166 })
167 .collect()
168 }
169
170 pub fn topological_sort(&self) -> Result<Vec<String>, DiscoveryError> {
172 let mut in_degree: HashMap<&str, usize> = self
173 .edges
174 .keys()
175 .map(|k| (k.as_str(), 0usize))
176 .collect();
177
178 for deps in self.edges.values() {
179 for dep in deps {
180 }
185 }
186 for (node, deps) in &self.edges {
188 in_degree.insert(node.as_str(), deps.len());
189 }
190
191 let mut queue: Vec<&str> = in_degree
192 .iter()
193 .filter_map(|(&k, &v)| if v == 0 { Some(k) } else { None })
194 .collect();
195 queue.sort();
196
197 let mut result = Vec::new();
198 while let Some(node) = queue.pop() {
199 result.push(node.to_string());
200 for (other, deps) in &self.edges {
202 if deps.contains(node) {
203 if let Some(deg) = in_degree.get_mut(other.as_str()) {
204 *deg -= 1;
205 if *deg == 0 {
206 queue.push(other.as_str());
207 queue.sort();
208 }
209 }
210 }
211 }
212 }
213
214 if result.len() != self.edges.len() {
215 let remaining: HashSet<&str> = self.edges.keys().map(|s| s.as_str()).collect::<HashSet<_>>()
217 .difference(&result.iter().map(|s| s.as_str()).collect::<HashSet<_>>())
218 .copied().collect();
219 let cycle: Vec<String> = remaining.iter().map(|s| s.to_string()).collect();
220 return Err(DiscoveryError::CircularDependency { cycle });
221 }
222
223 Ok(result)
224 }
225
226 pub fn transitive_deps(&self, node: &str) -> HashSet<String> {
228 let mut visited = HashSet::new();
229 let mut stack = vec![node];
230 while let Some(current) = stack.pop() {
231 if visited.insert(current.to_string()) {
232 if let Some(deps) = self.edges.get(current) {
233 for dep in deps {
234 stack.push(dep.as_str());
235 }
236 }
237 }
238 }
239 visited.remove(node);
240 visited
241 }
242}
243
244pub struct CapabilityScanner {
251 manifests: Vec<CapabilityManifest>,
253}
254
255impl CapabilityScanner {
256 pub fn new() -> Self {
258 Self {
259 manifests: Vec::new(),
260 }
261 }
262
263 pub fn scan_directory<P: AsRef<Path>>(&mut self, path: P) -> Result<Vec<CapabilityManifest>, DiscoveryError> {
265 let root = path.as_ref();
266 let mut found = Vec::new();
267
268 self.walk_dir(root, &mut found)?;
269
270 self.manifests.extend(found.clone());
271 Ok(found)
272 }
273
274 fn walk_dir(&self, dir: &Path, results: &mut Vec<CapabilityManifest>) -> Result<(), DiscoveryError> {
275 let entries = std::fs::read_dir(dir).map_err(|e| DiscoveryError::IoError {
276 path: dir.to_path_buf(),
277 message: e.to_string(),
278 })?;
279
280 for entry in entries {
281 let entry = entry.map_err(|e| DiscoveryError::IoError {
282 path: dir.to_path_buf(),
283 message: e.to_string(),
284 })?;
285 let path = entry.path();
286
287 if path.is_dir() {
288 let name = path.file_name().unwrap_or_default().to_string_lossy();
290 if name.starts_with('.') || name == "target" || name == "node_modules" {
291 continue;
292 }
293 self.walk_dir(&path, results)?;
294 } else if path.file_name().unwrap_or_default() == "CAPABILITY.toml" {
295 match Self::parse_manifest(&path) {
296 Ok(manifest) => results.push(manifest),
297 Err(e) => return Err(e),
298 }
299 }
300 }
301 Ok(())
302 }
303
304 pub fn parse_manifest(path: &Path) -> Result<CapabilityManifest, DiscoveryError> {
306 let content = std::fs::read_to_string(path).map_err(|e| DiscoveryError::IoError {
307 path: path.to_path_buf(),
308 message: e.to_string(),
309 })?;
310
311 let toml_value: toml::Value = content.parse::<toml::Value>().map_err(|e: toml::de::Error| DiscoveryError::ParseError {
312 path: path.to_path_buf(),
313 message: e.to_string(),
314 })?;
315
316 let cap_table = toml_value
317 .get("capability")
318 .ok_or_else(|| DiscoveryError::ParseError {
319 path: path.to_path_buf(),
320 message: "missing [capability] section".into(),
321 })?
322 .as_table()
323 .ok_or_else(|| DiscoveryError::ParseError {
324 path: path.to_path_buf(),
325 message: "[capability] must be a table".into(),
326 })?;
327
328 let name = cap_table
329 .get("name")
330 .and_then(|v| v.as_str())
331 .ok_or_else(|| DiscoveryError::ParseError {
332 path: path.to_path_buf(),
333 message: "missing capability.name".into(),
334 })?
335 .to_string();
336
337 let version = cap_table
338 .get("version")
339 .and_then(|v| v.as_str())
340 .unwrap_or("0.0.0")
341 .to_string();
342
343 let description = cap_table
344 .get("description")
345 .and_then(|v| v.as_str())
346 .unwrap_or("")
347 .to_string();
348
349 let category = cap_table
350 .get("category")
351 .and_then(|v| v.as_str())
352 .unwrap_or("uncategorized")
353 .to_string();
354
355 let mut integrations = HashMap::new();
356 if let Some(int_table) = cap_table.get("integrations").and_then(|v| v.as_table()) {
357 for (key, val) in int_table {
358 let spec = if let Some(s) = val.as_str() {
359 IntegrationSpec {
360 kind: s.to_string(),
361 reason: String::new(),
362 }
363 } else if let Some(t) = val.as_table() {
364 IntegrationSpec {
365 kind: t.get("kind").and_then(|v| v.as_str()).unwrap_or("optional").to_string(),
366 reason: t.get("reason").and_then(|v| v.as_str()).unwrap_or("").to_string(),
367 }
368 } else {
369 IntegrationSpec {
370 kind: "optional".to_string(),
371 reason: String::new(),
372 }
373 };
374 integrations.insert(key.clone(), spec);
375 }
376 }
377
378 let mut exports = HashMap::new();
379 if let Some(exp_table) = cap_table.get("exports").and_then(|v| v.as_table()) {
380 for (key, val) in exp_table {
381 exports.insert(key.clone(), val.as_str().unwrap_or("unknown").to_string());
382 }
383 }
384
385 Ok(CapabilityManifest {
386 name,
387 version,
388 description,
389 category,
390 source_path: path.parent().unwrap_or(path).to_path_buf(),
391 integrations,
392 exports,
393 })
394 }
395
396 pub fn manifests(&self) -> &[CapabilityManifest] {
398 &self.manifests
399 }
400
401 pub fn find_integrations(&self, known: &[String]) -> Vec<IntegrationSuggestion> {
403 let known_set: HashSet<&str> = known.iter().map(|s| s.as_str()).collect();
404 let mut suggestions: Vec<IntegrationSuggestion> = Vec::new();
405 let mut seen_names: HashSet<String> = HashSet::new();
406
407 for manifest in &self.manifests {
409 if known_set.contains(manifest.name.as_str()) {
410 for (dep_name, spec) in &manifest.integrations {
412 if !known_set.contains(dep_name.as_str()) && seen_names.insert(dep_name.clone()) {
413 suggestions.push(IntegrationSuggestion {
414 crate_name: dep_name.clone(),
415 reason: if spec.reason.is_empty() {
416 format!("Required by {}", manifest.name)
417 } else {
418 spec.reason.clone()
419 },
420 priority: if spec.kind == "required" { 0 } else { 5 },
421 category: "dependency".to_string(),
422 synergizes_with: vec![manifest.name.clone()],
423 });
424 }
425 }
426 } else {
427 let synergies: Vec<String> = manifest
429 .integrations
430 .keys()
431 .filter(|k| known_set.contains(k.as_str()))
432 .cloned()
433 .collect();
434
435 if !synergies.is_empty() && seen_names.insert(manifest.name.clone()) {
436 suggestions.push(IntegrationSuggestion {
437 crate_name: manifest.name.clone(),
438 reason: format!(
439 "Synergizes with {} — {}",
440 synergies.join(", "),
441 manifest.description
442 ),
443 priority: 3,
444 category: manifest.category.clone(),
445 synergizes_with: synergies,
446 });
447 }
448 }
449 }
450
451 suggestions.sort_by_key(|s| s.priority);
452 suggestions
453 }
454
455 pub fn build_dependency_graph(&self, manifests: &[CapabilityManifest]) -> DependencyGraph {
457 let mut graph = DependencyGraph::new();
458
459 for manifest in manifests {
460 graph.add_node(&manifest.name);
461 for dep_name in manifest.integrations.keys() {
462 graph.add_edge(&manifest.name, dep_name);
463 }
464 }
465
466 graph
467 }
468}
469
470impl Default for CapabilityScanner {
471 fn default() -> Self {
472 Self::new()
473 }
474}
475
476#[cfg(test)]
481mod tests {
482 use super::*;
483 use std::io::Write;
484
485 struct TempRepo {
487 dir: tempfile::TempDir,
488 }
489
490 impl TempRepo {
491 fn new() -> Self {
492 Self {
493 dir: tempfile::tempdir().unwrap(),
494 }
495 }
496
497 fn root(&self) -> &Path {
498 self.dir.path()
499 }
500
501 fn add_capability(&self, subdir: &str, toml_content: &str) -> PathBuf {
502 let dir = self.root().join(subdir);
503 std::fs::create_dir_all(&dir).unwrap();
504 let file_path = dir.join("CAPABILITY.toml");
505 let mut f = std::fs::File::create(&file_path).unwrap();
506 f.write_all(toml_content.as_bytes()).unwrap();
507 file_path
508 }
509 }
510
511 const SPECTRAL_FLEET_TOML: &str = r#"
512[capability]
513name = "spectral-fleet"
514version = "0.2.0"
515description = "Eigenvalue-based agent ranking"
516category = "analytics"
517
518[capability.integrations]
519fleet-warden = { kind = "optional", reason = "Feed fleet health into spectral ranking" }
520conservation-law = { kind = "required", reason = "Energy budgets constrain eigenvalue computation" }
521
522[capability.exports]
523eigenvalues = "Vec<f64>"
524rankings = "Vec<AgentRank>"
525"#;
526
527 const FLEET_WARDEN_TOML: &str = r#"
528[capability]
529name = "fleet-warden"
530version = "0.1.0"
531description = "Agent fleet health monitoring"
532category = "governance"
533
534[capability.integrations]
535conservation-law = { kind = "optional", reason = "Track energy budget health" }
536
537[capability.exports]
538health_status = "FleetHealth"
539agent_count = "usize"
540"#;
541
542 const CONSERVATION_TOML: &str = r#"
543[capability]
544name = "conservation-law"
545version = "0.3.0"
546description = "Energy conservation law enforcement for agents"
547category = "physics"
548
549[capability.integrations]
550
551[capability.exports]
552total_energy = "f64"
553budget = "EnergyBudget"
554"#;
555
556 #[test]
559 fn test_parse_minimal_manifest() {
560 let dir = tempfile::tempdir().unwrap();
561 let path = dir.path().join("CAPABILITY.toml");
562 std::fs::write(&path, r#"
563[capability]
564name = "test-crate"
565"#).unwrap();
566 let m = CapabilityScanner::parse_manifest(&path).unwrap();
567 assert_eq!(m.name, "test-crate");
568 assert_eq!(m.version, "0.0.0");
569 assert_eq!(m.category, "uncategorized");
570 }
571
572 #[test]
573 fn test_parse_full_manifest() {
574 let dir = tempfile::tempdir().unwrap();
575 let path = dir.path().join("CAPABILITY.toml");
576 std::fs::write(&path, SPECTRAL_FLEET_TOML).unwrap();
577 let m = CapabilityScanner::parse_manifest(&path).unwrap();
578 assert_eq!(m.name, "spectral-fleet");
579 assert_eq!(m.version, "0.2.0");
580 assert_eq!(m.description, "Eigenvalue-based agent ranking");
581 assert_eq!(m.category, "analytics");
582 assert_eq!(m.integrations.len(), 2);
583 assert!(m.integrations.contains_key("fleet-warden"));
584 assert!(m.integrations.contains_key("conservation-law"));
585 assert_eq!(m.exports.len(), 2);
586 assert_eq!(m.exports["eigenvalues"], "Vec<f64>");
587 }
588
589 #[test]
590 fn test_parse_missing_capability_section() {
591 let dir = tempfile::tempdir().unwrap();
592 let path = dir.path().join("CAPABILITY.toml");
593 std::fs::write(&path, "[other]\nkey = 'val'").unwrap();
594 let result = CapabilityScanner::parse_manifest(&path);
595 assert!(matches!(result, Err(DiscoveryError::ParseError { .. })));
596 }
597
598 #[test]
599 fn test_parse_missing_name() {
600 let dir = tempfile::tempdir().unwrap();
601 let path = dir.path().join("CAPABILITY.toml");
602 std::fs::write(&path, "[capability]\nversion = \"1.0\"").unwrap();
603 let result = CapabilityScanner::parse_manifest(&path);
604 assert!(matches!(result, Err(DiscoveryError::ParseError { .. })));
605 }
606
607 #[test]
608 fn test_parse_invalid_toml() {
609 let dir = tempfile::tempdir().unwrap();
610 let path = dir.path().join("CAPABILITY.toml");
611 std::fs::write(&path, "this is not {{{{ valid toml").unwrap();
612 let result = CapabilityScanner::parse_manifest(&path);
613 assert!(matches!(result, Err(DiscoveryError::ParseError { .. })));
614 }
615
616 #[test]
617 fn test_parse_nonexistent_file() {
618 let result = CapabilityScanner::parse_manifest(Path::new("/no/such/file.toml"));
619 assert!(matches!(result, Err(DiscoveryError::IoError { .. })));
620 }
621
622 #[test]
623 fn test_parse_string_integration_shorthand() {
624 let dir = tempfile::tempdir().unwrap();
625 let path = dir.path().join("CAPABILITY.toml");
626 std::fs::write(&path, r#"
627[capability]
628name = "shorthand-test"
629[capability.integrations]
630some-dep = "optional"
631"#).unwrap();
632 let m = CapabilityScanner::parse_manifest(&path).unwrap();
633 assert_eq!(m.integrations["some-dep"].kind, "optional");
634 }
635
636 #[test]
639 fn test_scan_empty_directory() {
640 let dir = tempfile::tempdir().unwrap();
641 let mut scanner = CapabilityScanner::new();
642 let result = scanner.scan_directory(dir.path()).unwrap();
643 assert!(result.is_empty());
644 }
645
646 #[test]
647 fn test_scan_single_capability() {
648 let repo = TempRepo::new();
649 repo.add_capability("spectral-fleet", SPECTRAL_FLEET_TOML);
650 let mut scanner = CapabilityScanner::new();
651 let result = scanner.scan_directory(repo.root()).unwrap();
652 assert_eq!(result.len(), 1);
653 assert_eq!(result[0].name, "spectral-fleet");
654 }
655
656 #[test]
657 fn test_scan_multiple_capabilities() {
658 let repo = TempRepo::new();
659 repo.add_capability("spectral-fleet", SPECTRAL_FLEET_TOML);
660 repo.add_capability("fleet-warden", FLEET_WARDEN_TOML);
661 repo.add_capability("conservation-law", CONSERVATION_TOML);
662 let mut scanner = CapabilityScanner::new();
663 let result = scanner.scan_directory(repo.root()).unwrap();
664 assert_eq!(result.len(), 3);
665 let names: HashSet<&str> = result.iter().map(|m| m.name.as_str()).collect();
666 assert!(names.contains("spectral-fleet"));
667 assert!(names.contains("fleet-warden"));
668 assert!(names.contains("conservation-law"));
669 }
670
671 #[test]
672 fn test_scan_skips_hidden_dirs() {
673 let repo = TempRepo::new();
674 repo.add_capability(".hidden/repo", SPECTRAL_FLEET_TOML);
675 let mut scanner = CapabilityScanner::new();
676 let result = scanner.scan_directory(repo.root()).unwrap();
677 assert!(result.is_empty());
678 }
679
680 #[test]
681 fn test_scan_skips_target_dir() {
682 let repo = TempRepo::new();
683 repo.add_capability("target/debug", SPECTRAL_FLEET_TOML);
684 let mut scanner = CapabilityScanner::new();
685 let result = scanner.scan_directory(repo.root()).unwrap();
686 assert!(result.is_empty());
687 }
688
689 #[test]
690 fn test_scan_accumulates_manifests() {
691 let repo = TempRepo::new();
692 repo.add_capability("a", SPECTRAL_FLEET_TOML);
693 let mut scanner = CapabilityScanner::new();
694 scanner.scan_directory(repo.root()).unwrap();
695 assert_eq!(scanner.manifests().len(), 1);
696 let repo2 = TempRepo::new();
698 repo2.add_capability("b", FLEET_WARDEN_TOML);
699 scanner.scan_directory(repo2.root()).unwrap();
700 assert_eq!(scanner.manifests().len(), 2);
701 }
702
703 #[test]
706 fn test_find_integrations_no_known_crates() {
707 let repo = TempRepo::new();
708 repo.add_capability("spectral-fleet", SPECTRAL_FLEET_TOML);
709 repo.add_capability("fleet-warden", FLEET_WARDEN_TOML);
710 let mut scanner = CapabilityScanner::new();
711 scanner.scan_directory(repo.root()).unwrap();
712 let suggestions = scanner.find_integrations(&[]);
713 assert!(suggestions.is_empty());
714 }
715
716 #[test]
717 fn test_find_integrations_discovers_dependencies() {
718 let repo = TempRepo::new();
719 repo.add_capability("spectral-fleet", SPECTRAL_FLEET_TOML);
720 repo.add_capability("fleet-warden", FLEET_WARDEN_TOML);
721 repo.add_capability("conservation-law", CONSERVATION_TOML);
722 let mut scanner = CapabilityScanner::new();
723 scanner.scan_directory(repo.root()).unwrap();
724
725 let known = vec!["spectral-fleet".to_string()];
726 let suggestions = scanner.find_integrations(&known);
727
728 let suggested_names: Vec<&str> = suggestions.iter().map(|s| s.crate_name.as_str()).collect();
730 assert!(suggested_names.contains(&"conservation-law"));
731 assert!(suggested_names.contains(&"fleet-warden"));
732 }
733
734 #[test]
735 fn test_find_integrations_required_has_higher_priority() {
736 let repo = TempRepo::new();
737 repo.add_capability("spectral-fleet", SPECTRAL_FLEET_TOML);
738 repo.add_capability("conservation-law", CONSERVATION_TOML);
739 let mut scanner = CapabilityScanner::new();
740 scanner.scan_directory(repo.root()).unwrap();
741
742 let known = vec!["spectral-fleet".to_string()];
743 let suggestions = scanner.find_integrations(&known);
744
745 let conservation = suggestions.iter().find(|s| s.crate_name == "conservation-law").unwrap();
746 assert_eq!(conservation.priority, 0); }
748
749 #[test]
750 fn test_find_integrations_synergy_discovery() {
751 let repo = TempRepo::new();
752 repo.add_capability("spectral-fleet", SPECTRAL_FLEET_TOML);
753 repo.add_capability("fleet-warden", FLEET_WARDEN_TOML);
754 let mut scanner = CapabilityScanner::new();
755 scanner.scan_directory(repo.root()).unwrap();
756
757 let known = vec!["conservation-law".to_string()];
759 let suggestions = scanner.find_integrations(&known);
760
761 let warden = suggestions.iter().find(|s| s.crate_name == "fleet-warden");
763 let spectral = suggestions.iter().find(|s| s.crate_name == "spectral-fleet");
765 assert!(warden.is_some() || spectral.is_some());
766 }
767
768 #[test]
771 fn test_build_dependency_graph() {
772 let repo = TempRepo::new();
773 repo.add_capability("spectral-fleet", SPECTRAL_FLEET_TOML);
774 repo.add_capability("fleet-warden", FLEET_WARDEN_TOML);
775 repo.add_capability("conservation-law", CONSERVATION_TOML);
776 let mut scanner = CapabilityScanner::new();
777 scanner.scan_directory(repo.root()).unwrap();
778
779 let graph = scanner.build_dependency_graph(&scanner.manifests().to_vec());
780 let deps = graph.dependencies_of("spectral-fleet");
781 assert!(deps.contains(&"fleet-warden"));
782 assert!(deps.contains(&"conservation-law"));
783 assert!(graph.dependencies_of("conservation-law").is_empty());
784 }
785
786 #[test]
787 fn test_dependency_graph_topological_sort() {
788 let mut graph = DependencyGraph::new();
789 graph.add_edge("spectral-fleet", "conservation-law");
790 graph.add_edge("fleet-warden", "conservation-law");
791 let sorted = graph.topological_sort().unwrap();
792 let cl_pos = sorted.iter().position(|s| s == "conservation-law").unwrap();
793 let sf_pos = sorted.iter().position(|s| s == "spectral-fleet").unwrap();
794 assert!(cl_pos < sf_pos);
795 }
796
797 #[test]
798 fn test_dependency_graph_circular_detection() {
799 let mut graph = DependencyGraph::new();
800 graph.add_edge("a", "b");
801 graph.add_edge("b", "c");
802 graph.add_edge("c", "a");
803 let result = graph.topological_sort();
804 assert!(matches!(result, Err(DiscoveryError::CircularDependency { .. })));
805 }
806
807 #[test]
808 fn test_dependency_graph_transitive_deps() {
809 let mut graph = DependencyGraph::new();
810 graph.add_edge("a", "b");
811 graph.add_edge("b", "c");
812 let deps = graph.transitive_deps("a");
813 assert!(deps.contains("b"));
814 assert!(deps.contains("c"));
815 assert!(!deps.contains("a"));
816 }
817
818 #[test]
819 fn test_dependency_graph_dependents_of() {
820 let mut graph = DependencyGraph::new();
821 graph.add_edge("spectral-fleet", "conservation-law");
822 graph.add_edge("fleet-warden", "conservation-law");
823 let deps = graph.dependents_of("conservation-law");
824 assert!(deps.contains(&"spectral-fleet"));
825 assert!(deps.contains(&"fleet-warden"));
826 }
827
828 #[test]
831 fn test_error_display_io() {
832 let err = DiscoveryError::IoError {
833 path: PathBuf::from("/foo/CAPABILITY.toml"),
834 message: "permission denied".into(),
835 };
836 assert!(err.to_string().contains("/foo/CAPABILITY.toml"));
837 assert!(err.to_string().contains("permission denied"));
838 }
839
840 #[test]
841 fn test_error_display_parse() {
842 let err = DiscoveryError::ParseError {
843 path: PathBuf::from("/bar/CAPABILITY.toml"),
844 message: "missing name".into(),
845 };
846 assert!(err.to_string().contains("Parse error"));
847 }
848
849 #[test]
850 fn test_error_display_circular() {
851 let err = DiscoveryError::CircularDependency {
852 cycle: vec!["a".into(), "b".into(), "a".into()],
853 };
854 assert!(err.to_string().contains("a → b → a"));
855 }
856
857 #[test]
860 fn test_empty_dependency_graph_topo_sort() {
861 let graph = DependencyGraph::new();
862 let sorted = graph.topological_sort().unwrap();
863 assert!(sorted.is_empty());
864 }
865
866 #[test]
867 fn test_single_node_topo_sort() {
868 let mut graph = DependencyGraph::new();
869 graph.add_node("solo");
870 let sorted = graph.topological_sort().unwrap();
871 assert_eq!(sorted, vec!["solo".to_string()]);
872 }
873
874 #[test]
875 fn test_manifest_source_path() {
876 let repo = TempRepo::new();
877 let path = repo.add_capability("my-crate", SPECTRAL_FLEET_TOML);
878 let m = CapabilityScanner::parse_manifest(&path).unwrap();
879 assert_eq!(m.source_path, repo.root().join("my-crate"));
880 }
881}