Skip to main content

open_mind_capability_discovery/
lib.rs

1//! # Capability Discovery Engine for Open-Mind
2//!
3//! Reads CAPABILITY.toml files from SuperInstance repositories, builds an
4//! integration graph, and suggests which crates to add based on what's already
5//! present in a project.
6//!
7//! ## Core Types
8//!
9//! - [`CapabilityManifest`] — parsed representation of a CAPABILITY.toml
10//! - [`CapabilityScanner`] — scans directories for capability manifests
11//! - [`DependencyGraph`] — directed graph of crate dependencies
12//! - [`IntegrationSuggestion`] — a recommended integration action
13
14use std::collections::{HashMap, HashSet, BTreeMap};
15use std::path::{Path, PathBuf};
16
17// ---------------------------------------------------------------------------
18// Error type
19// ---------------------------------------------------------------------------
20
21/// Errors that can occur during capability discovery.
22#[derive(Debug, Clone)]
23pub enum DiscoveryError {
24    /// The CAPABILITY.toml file could not be read.
25    IoError { path: PathBuf, message: String },
26    /// The TOML was malformed or missing required fields.
27    ParseError { path: PathBuf, message: String },
28    /// A circular dependency was detected.
29    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// ---------------------------------------------------------------------------
51// Core data structures
52// ---------------------------------------------------------------------------
53
54/// A parsed CAPABILITY.toml manifest.
55///
56/// Example TOML:
57/// ```toml
58/// [capability]
59/// name = "spectral-fleet"
60/// version = "0.2.0"
61/// description = "Eigenvalue-based agent ranking"
62/// category = "analytics"
63///
64/// [capability.integrations]
65/// fleet-warden = { kind = "optional", reason = "Feed fleet health into spectral ranking" }
66/// conservation-law = { kind = "required", reason = "Energy budgets constrain eigenvalue computation" }
67///
68/// [capability.exports]
69/// eigenvalues = "Vec<f64>"
70/// rankings = "Vec<AgentRank>"
71/// ```
72#[derive(Debug, Clone)]
73pub struct CapabilityManifest {
74    /// Unique crate / capability name.
75    pub name: String,
76    /// Semantic version string.
77    pub version: String,
78    /// Human-readable description.
79    pub description: String,
80    /// Category tag (e.g. "analytics", "governance", "physics").
81    pub category: String,
82    /// Path to the directory containing this manifest.
83    pub source_path: PathBuf,
84    /// Integration requirements keyed by crate name.
85    pub integrations: HashMap<String, IntegrationSpec>,
86    /// Typed symbols this capability exports.
87    pub exports: HashMap<String, String>,
88}
89
90/// Specification for a single integration requirement.
91#[derive(Debug, Clone)]
92pub struct IntegrationSpec {
93    /// "required", "optional", or "conflicts".
94    pub kind: String,
95    /// Why this integration exists.
96    pub reason: String,
97}
98
99/// A suggestion produced by the discovery engine.
100#[derive(Debug, Clone, PartialEq)]
101pub struct IntegrationSuggestion {
102    /// The crate to add.
103    pub crate_name: String,
104    /// Why it should be added.
105    pub reason: String,
106    /// Priority: lower = higher priority.
107    pub priority: u32,
108    /// Category of the suggested crate.
109    pub category: String,
110    /// Which existing crates benefit from this integration.
111    pub synergizes_with: Vec<String>,
112}
113
114// ---------------------------------------------------------------------------
115// Dependency graph
116// ---------------------------------------------------------------------------
117
118/// Directed acyclic graph of crate dependencies.
119#[derive(Debug, Clone, Default)]
120pub struct DependencyGraph {
121    /// Adjacency list: node → set of nodes it depends on.
122    edges: BTreeMap<String, HashSet<String>>,
123}
124
125impl DependencyGraph {
126    /// Create an empty graph.
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    /// Insert a node (no-op if it already exists).
132    pub fn add_node(&mut self, name: &str) {
133        self.edges.entry(name.to_string()).or_default();
134    }
135
136    /// Add a directed edge `from → depends_on`.
137    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    /// All node names.
144    pub fn nodes(&self) -> Vec<&str> {
145        self.edges.keys().map(|s| s.as_str()).collect()
146    }
147
148    /// Direct dependencies of `node`.
149    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    /// Reverse lookup: who depends on `node`.
157    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    /// Topological sort. Returns `Err` with a cycle on failure.
171    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                // dep is depended upon; the *source* has the outgoing edge,
181                // so in-degree counts how many things depend on a node.
182                // For a conventional topo sort (deps before dependents),
183                // in_degree[node] = number of things node depends on.
184            }
185        }
186        // Recompute: in_degree[node] = len(node.deps)
187        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 every node that depends on `node`, decrement in_degree.
201            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            // Find a cycle for the error message
216            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    /// Transitive closure of dependencies for `node`.
227    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
244// ---------------------------------------------------------------------------
245// Scanner
246// ---------------------------------------------------------------------------
247
248/// Scans directories for CAPABILITY.toml files and produces integration
249/// suggestions.
250pub struct CapabilityScanner {
251    /// Manifests discovered so far.
252    manifests: Vec<CapabilityManifest>,
253}
254
255impl CapabilityScanner {
256    /// Create a new scanner with an empty manifest list.
257    pub fn new() -> Self {
258        Self {
259            manifests: Vec::new(),
260        }
261    }
262
263    /// Scan a directory recursively for CAPABILITY.toml files.
264    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                // Skip hidden dirs and common non-source dirs
289                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    /// Parse a single CAPABILITY.toml into a CapabilityManifest.
305    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    /// Return all discovered manifests.
397    pub fn manifests(&self) -> &[CapabilityManifest] {
398        &self.manifests
399    }
400
401    /// Find integration suggestions for a project that already has the given crates.
402    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 each known crate, find manifests that integrate with it
408        for manifest in &self.manifests {
409            if known_set.contains(manifest.name.as_str()) {
410                // Already have this one; suggest its dependencies
411                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                // Unknown crate — check if it integrates with known ones
428                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    /// Build a dependency graph from discovered manifests.
456    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// ---------------------------------------------------------------------------
477// Tests
478// ---------------------------------------------------------------------------
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use std::io::Write;
484
485    /// Helper: create a temp dir with CAPABILITY.toml files.
486    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    // ---- Basic parsing tests ----
557
558    #[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    // ---- Scanner / directory scanning tests ----
637
638    #[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        // Scan again from different dir
697        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    // ---- Integration suggestion tests ----
704
705    #[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        // Should suggest fleet-warden (optional dep of spectral-fleet) and conservation-law (required dep)
729        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); // required → priority 0
747    }
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        // fleet-warden integrates with nothing we have → but spectral-fleet references it
758        let known = vec!["conservation-law".to_string()];
759        let suggestions = scanner.find_integrations(&known);
760
761        // fleet-warden has optional dep on conservation-law → synergy
762        let warden = suggestions.iter().find(|s| s.crate_name == "fleet-warden");
763        // spectral-fleet requires conservation-law → synergy
764        let spectral = suggestions.iter().find(|s| s.crate_name == "spectral-fleet");
765        assert!(warden.is_some() || spectral.is_some());
766    }
767
768    // ---- Dependency graph tests ----
769
770    #[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    // ---- Error display tests ----
829
830    #[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    // ---- Edge case tests ----
858
859    #[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}