Skip to main content

simular/
discovery.rs

1//! Dynamic Stack Discovery for Sovereign AI Stack components.
2//!
3//! Per the Batuta Stack Review, hardcoded component lists introduce
4//! Muda of Processing (maintenance waste). This module uses dynamic
5//! discovery to detect available stack components from Cargo.toml.
6
7use serde::Deserialize;
8use std::collections::HashMap;
9use std::path::Path;
10
11use crate::error::{SimError, SimResult};
12
13/// Semantic version representation.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Version {
16    /// Major version number.
17    pub major: u32,
18    /// Minor version number.
19    pub minor: u32,
20    /// Patch version number.
21    pub patch: u32,
22    /// Pre-release identifier (e.g., "alpha", "beta").
23    pub pre_release: Option<String>,
24}
25
26impl Version {
27    /// Parse a version string like "1.2.3" or "1.2.3-beta".
28    #[must_use]
29    pub fn parse(s: &str) -> Option<Self> {
30        let s = s.trim();
31
32        // Handle version requirements like "^1.0", ">=1.2", "~1.0"
33        let s = s
34            .trim_start_matches('^')
35            .trim_start_matches('~')
36            .trim_start_matches(">=")
37            .trim_start_matches("<=")
38            .trim_start_matches('>')
39            .trim_start_matches('<')
40            .trim_start_matches('=')
41            .trim();
42
43        // Split on hyphen for pre-release
44        let (version_part, pre_release) = s
45            .find('-')
46            .map_or((s, None), |idx| (&s[..idx], Some(s[idx + 1..].to_string())));
47
48        let parts: Vec<&str> = version_part.split('.').collect();
49        if parts.is_empty() || parts.len() > 3 {
50            return None;
51        }
52
53        let major = parts[0].parse().ok()?;
54        let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
55        let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
56
57        Some(Self {
58            major,
59            minor,
60            patch,
61            pre_release,
62        })
63    }
64
65    /// Check if this version satisfies a minimum requirement.
66    #[must_use]
67    pub fn satisfies_minimum(&self, min: &Self) -> bool {
68        if self.major != min.major {
69            return self.major > min.major;
70        }
71        if self.minor != min.minor {
72            return self.minor > min.minor;
73        }
74        self.patch >= min.patch
75    }
76}
77
78impl std::fmt::Display for Version {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        if let Some(ref pre) = self.pre_release {
81            write!(f, "{}.{}.{}-{}", self.major, self.minor, self.patch, pre)
82        } else {
83            write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
84        }
85    }
86}
87
88/// Sovereign AI Stack components that simular can integrate with.
89#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
90pub enum StackComponent {
91    /// trueno: SIMD-accelerated tensor operations.
92    Trueno,
93    /// trueno-db: Analytics database.
94    TruenoDB,
95    /// trueno-graph: Code analysis graphs.
96    TruenoGraph,
97    /// trueno-rag: RAG pipeline.
98    TruenoRag,
99    /// aprender: ML algorithms (regression, trees, GNN).
100    Aprender,
101    /// entrenar: Training (autograd, `LoRA`, quantization).
102    Entrenar,
103    /// realizar: Inference (GGUF serving, `SafeTensors`).
104    Realizar,
105    /// alimentar: Data loading (Parquet, Arrow).
106    Alimentar,
107    /// pacha: Registry (Ed25519 signatures, versioning).
108    Pacha,
109    /// renacer: Tracing (syscall trace, source correlation).
110    Renacer,
111}
112
113impl StackComponent {
114    /// Get the crate name for this component.
115    #[must_use]
116    pub const fn crate_name(&self) -> &'static str {
117        match self {
118            Self::Trueno => "trueno",
119            Self::TruenoDB => "trueno-db",
120            Self::TruenoGraph => "trueno-graph",
121            Self::TruenoRag => "trueno-rag",
122            Self::Aprender => "aprender",
123            Self::Entrenar => "entrenar",
124            Self::Realizar => "realizar",
125            Self::Alimentar => "alimentar",
126            Self::Pacha => "pacha",
127            Self::Renacer => "renacer",
128        }
129    }
130
131    /// Get all known stack components.
132    #[must_use]
133    pub const fn all() -> &'static [Self] {
134        &[
135            Self::Trueno,
136            Self::TruenoDB,
137            Self::TruenoGraph,
138            Self::TruenoRag,
139            Self::Aprender,
140            Self::Entrenar,
141            Self::Realizar,
142            Self::Alimentar,
143            Self::Pacha,
144            Self::Renacer,
145        ]
146    }
147}
148
149impl std::fmt::Display for StackComponent {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        write!(f, "{}", self.crate_name())
152    }
153}
154
155/// Dynamic discovery of Sovereign AI Stack components.
156///
157/// Eliminates hardcoded lists (Batuta Review ยง2.2) by parsing
158/// Cargo.toml at runtime to detect available integrations.
159#[derive(Debug, Clone, Default)]
160pub struct StackDiscovery {
161    /// Discovered stack crates and versions.
162    components: HashMap<StackComponent, Version>,
163}
164
165impl StackDiscovery {
166    /// Create an empty discovery instance.
167    #[must_use]
168    pub fn new() -> Self {
169        Self {
170            components: HashMap::new(),
171        }
172    }
173
174    /// Discover available stack components from a Cargo.toml file.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if the file cannot be read or parsed.
179    pub fn from_cargo_toml(path: &Path) -> SimResult<Self> {
180        let content = std::fs::read_to_string(path)
181            .map_err(|e| SimError::config(format!("Failed to read Cargo.toml: {e}")))?;
182
183        Self::from_toml_str(&content)
184    }
185
186    /// Discover available stack components from TOML content.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if the TOML cannot be parsed.
191    pub fn from_toml_str(content: &str) -> SimResult<Self> {
192        let manifest: CargoManifest = toml::from_str(content)
193            .map_err(|e| SimError::config(format!("Failed to parse Cargo.toml: {e}")))?;
194
195        let mut discovery = Self::new();
196
197        // Parse dependencies section
198        if let Some(deps) = manifest.dependencies {
199            discovery.parse_dependencies(&deps);
200        }
201
202        // Parse dev-dependencies section
203        if let Some(dev_deps) = manifest.dev_dependencies {
204            discovery.parse_dependencies(&dev_deps);
205        }
206
207        Ok(discovery)
208    }
209
210    /// Parse a dependencies table for stack components.
211    fn parse_dependencies(&mut self, deps: &HashMap<String, toml::Value>) {
212        for (name, value) in deps {
213            if let Some(component) = Self::parse_stack_component(name) {
214                if let Some(version) = Self::extract_version(value) {
215                    self.components.insert(component, version);
216                }
217            }
218        }
219    }
220
221    /// Parse component name with fuzzy matching.
222    ///
223    /// Handles both hyphenated and underscored variants.
224    #[must_use]
225    pub fn parse_stack_component(name: &str) -> Option<StackComponent> {
226        let normalized = name.to_lowercase().replace('_', "-");
227        match normalized.as_str() {
228            "trueno" => Some(StackComponent::Trueno),
229            "trueno-db" => Some(StackComponent::TruenoDB),
230            "trueno-graph" => Some(StackComponent::TruenoGraph),
231            "trueno-rag" => Some(StackComponent::TruenoRag),
232            "aprender" => Some(StackComponent::Aprender),
233            "entrenar" => Some(StackComponent::Entrenar),
234            "realizar" => Some(StackComponent::Realizar),
235            "alimentar" => Some(StackComponent::Alimentar),
236            "pacha" => Some(StackComponent::Pacha),
237            "renacer" => Some(StackComponent::Renacer),
238            _ => None,
239        }
240    }
241
242    /// Extract version from a TOML dependency value.
243    fn extract_version(value: &toml::Value) -> Option<Version> {
244        match value {
245            // Simple version string: dependency = "1.0"
246            toml::Value::String(s) => Version::parse(s),
247            // Table format: dependency = { version = "1.0", ... }
248            toml::Value::Table(t) => t
249                .get("version")
250                .and_then(|v| v.as_str())
251                .and_then(Version::parse),
252            _ => None,
253        }
254    }
255
256    /// Check if a component is available.
257    #[must_use]
258    pub fn has(&self, component: StackComponent) -> bool {
259        self.components.contains_key(&component)
260    }
261
262    /// Get component version if available.
263    #[must_use]
264    pub fn version(&self, component: StackComponent) -> Option<&Version> {
265        self.components.get(&component)
266    }
267
268    /// Get all discovered components.
269    #[must_use]
270    pub fn discovered(&self) -> &HashMap<StackComponent, Version> {
271        &self.components
272    }
273
274    /// Get count of discovered components.
275    #[must_use]
276    pub fn count(&self) -> usize {
277        self.components.len()
278    }
279
280    /// Check if no components were discovered.
281    #[must_use]
282    pub fn is_empty(&self) -> bool {
283        self.components.is_empty()
284    }
285
286    /// Manually register a component (for testing or manual configuration).
287    pub fn register(&mut self, component: StackComponent, version: Version) {
288        self.components.insert(component, version);
289    }
290
291    /// Check version compatibility for a component.
292    ///
293    /// Returns `true` if the component is available and satisfies
294    /// the minimum version requirement.
295    #[must_use]
296    pub fn check_version(&self, component: StackComponent, min_version: &Version) -> bool {
297        self.version(component)
298            .is_some_and(|v| v.satisfies_minimum(min_version))
299    }
300
301    /// Get a summary of available integrations.
302    #[must_use]
303    pub fn summary(&self) -> String {
304        if self.is_empty() {
305            return String::from("No Sovereign AI Stack components detected");
306        }
307
308        let mut lines = vec![format!("Detected {} stack components:", self.count())];
309
310        for component in StackComponent::all() {
311            if let Some(version) = self.version(*component) {
312                lines.push(format!("  - {}: v{version}", component.crate_name()));
313            }
314        }
315
316        lines.join("\n")
317    }
318}
319
320/// Minimal Cargo.toml structure for parsing.
321#[derive(Deserialize)]
322struct CargoManifest {
323    #[serde(default)]
324    dependencies: Option<HashMap<String, toml::Value>>,
325    #[serde(default, rename = "dev-dependencies")]
326    dev_dependencies: Option<HashMap<String, toml::Value>>,
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_version_parse_simple() {
335        let v = Version::parse("1.2.3").unwrap();
336        assert_eq!(v.major, 1);
337        assert_eq!(v.minor, 2);
338        assert_eq!(v.patch, 3);
339        assert!(v.pre_release.is_none());
340    }
341
342    #[test]
343    fn test_version_parse_with_prerelease() {
344        let v = Version::parse("2.0.0-beta").unwrap();
345        assert_eq!(v.major, 2);
346        assert_eq!(v.minor, 0);
347        assert_eq!(v.patch, 0);
348        assert_eq!(v.pre_release.as_deref(), Some("beta"));
349    }
350
351    #[test]
352    fn test_version_parse_partial() {
353        let v = Version::parse("1.5").unwrap();
354        assert_eq!(v.major, 1);
355        assert_eq!(v.minor, 5);
356        assert_eq!(v.patch, 0);
357
358        let v = Version::parse("3").unwrap();
359        assert_eq!(v.major, 3);
360        assert_eq!(v.minor, 0);
361        assert_eq!(v.patch, 0);
362    }
363
364    #[test]
365    fn test_version_parse_with_prefix() {
366        let v = Version::parse("^1.2.3").unwrap();
367        assert_eq!(v.major, 1);
368        assert_eq!(v.minor, 2);
369        assert_eq!(v.patch, 3);
370
371        let v = Version::parse(">=2.0").unwrap();
372        assert_eq!(v.major, 2);
373        assert_eq!(v.minor, 0);
374    }
375
376    #[test]
377    fn test_version_satisfies_minimum() {
378        let v1 = Version::parse("1.2.3").unwrap();
379        let v2 = Version::parse("1.2.0").unwrap();
380        let v3 = Version::parse("1.3.0").unwrap();
381        let v4 = Version::parse("2.0.0").unwrap();
382
383        assert!(v1.satisfies_minimum(&v2)); // 1.2.3 >= 1.2.0
384        assert!(!v1.satisfies_minimum(&v3)); // 1.2.3 < 1.3.0
385        assert!(!v1.satisfies_minimum(&v4)); // 1.2.3 < 2.0.0
386    }
387
388    #[test]
389    fn test_version_display() {
390        let v = Version::parse("1.2.3").unwrap();
391        assert_eq!(v.to_string(), "1.2.3");
392
393        let v = Version::parse("1.0.0-alpha").unwrap();
394        assert_eq!(v.to_string(), "1.0.0-alpha");
395    }
396
397    #[test]
398    fn test_stack_component_crate_name() {
399        assert_eq!(StackComponent::Trueno.crate_name(), "trueno");
400        assert_eq!(StackComponent::TruenoDB.crate_name(), "trueno-db");
401        assert_eq!(StackComponent::Aprender.crate_name(), "aprender");
402    }
403
404    #[test]
405    fn test_parse_stack_component() {
406        assert_eq!(
407            StackDiscovery::parse_stack_component("trueno"),
408            Some(StackComponent::Trueno)
409        );
410        assert_eq!(
411            StackDiscovery::parse_stack_component("trueno-db"),
412            Some(StackComponent::TruenoDB)
413        );
414        assert_eq!(
415            StackDiscovery::parse_stack_component("trueno_db"),
416            Some(StackComponent::TruenoDB)
417        );
418        assert_eq!(
419            StackDiscovery::parse_stack_component("APRENDER"),
420            Some(StackComponent::Aprender)
421        );
422        assert_eq!(StackDiscovery::parse_stack_component("unknown"), None);
423    }
424
425    #[test]
426    fn test_discovery_from_toml_simple() {
427        let toml = r#"
428[package]
429name = "test"
430version = "0.1.0"
431
432[dependencies]
433trueno = "1.0.0"
434aprender = "0.5.0"
435serde = "1.0"
436"#;
437
438        let discovery = StackDiscovery::from_toml_str(toml).unwrap();
439
440        assert!(discovery.has(StackComponent::Trueno));
441        assert!(discovery.has(StackComponent::Aprender));
442        assert!(!discovery.has(StackComponent::Entrenar));
443
444        let trueno_v = discovery.version(StackComponent::Trueno).unwrap();
445        assert_eq!(trueno_v.major, 1);
446        assert_eq!(trueno_v.minor, 0);
447    }
448
449    #[test]
450    fn test_discovery_from_toml_table_format() {
451        let toml = r#"
452[dependencies]
453trueno = { version = "2.1.0", features = ["simd"] }
454entrenar = { version = "0.3.0", optional = true }
455"#;
456
457        let discovery = StackDiscovery::from_toml_str(toml).unwrap();
458
459        assert!(discovery.has(StackComponent::Trueno));
460        assert!(discovery.has(StackComponent::Entrenar));
461
462        let trueno_v = discovery.version(StackComponent::Trueno).unwrap();
463        assert_eq!(trueno_v.to_string(), "2.1.0");
464    }
465
466    #[test]
467    fn test_discovery_dev_dependencies() {
468        let toml = r#"
469[dev-dependencies]
470renacer = "0.1.0"
471"#;
472
473        let discovery = StackDiscovery::from_toml_str(toml).unwrap();
474        assert!(discovery.has(StackComponent::Renacer));
475    }
476
477    #[test]
478    fn test_discovery_empty() {
479        let toml = r#"
480[package]
481name = "test"
482version = "0.1.0"
483
484[dependencies]
485serde = "1.0"
486"#;
487
488        let discovery = StackDiscovery::from_toml_str(toml).unwrap();
489
490        assert!(discovery.is_empty());
491        assert_eq!(discovery.count(), 0);
492    }
493
494    #[test]
495    fn test_discovery_check_version() {
496        let toml = r#"
497[dependencies]
498trueno = "1.5.0"
499"#;
500
501        let discovery = StackDiscovery::from_toml_str(toml).unwrap();
502
503        let min_ok = Version::parse("1.0.0").unwrap();
504        let min_exact = Version::parse("1.5.0").unwrap();
505        let min_too_high = Version::parse("2.0.0").unwrap();
506
507        assert!(discovery.check_version(StackComponent::Trueno, &min_ok));
508        assert!(discovery.check_version(StackComponent::Trueno, &min_exact));
509        assert!(!discovery.check_version(StackComponent::Trueno, &min_too_high));
510        assert!(!discovery.check_version(StackComponent::Aprender, &min_ok)); // Not present
511    }
512
513    #[test]
514    fn test_discovery_register() {
515        let mut discovery = StackDiscovery::new();
516        assert!(discovery.is_empty());
517
518        discovery.register(StackComponent::Trueno, Version::parse("1.0.0").unwrap());
519
520        assert!(discovery.has(StackComponent::Trueno));
521        assert_eq!(discovery.count(), 1);
522    }
523
524    #[test]
525    fn test_discovery_summary() {
526        let toml = r#"
527[dependencies]
528trueno = "1.0.0"
529aprender = "0.5.0"
530"#;
531
532        let discovery = StackDiscovery::from_toml_str(toml).unwrap();
533        let summary = discovery.summary();
534
535        assert!(summary.contains("2 stack components"));
536        assert!(summary.contains("trueno: v1.0.0"));
537        assert!(summary.contains("aprender: v0.5.0"));
538    }
539
540    #[test]
541    fn test_discovery_summary_empty() {
542        let discovery = StackDiscovery::new();
543        let summary = discovery.summary();
544
545        assert!(summary.contains("No Sovereign AI Stack components detected"));
546    }
547
548    #[test]
549    fn test_stack_component_all() {
550        let all = StackComponent::all();
551        assert_eq!(all.len(), 10);
552
553        // Verify all components are unique
554        let mut seen = std::collections::HashSet::new();
555        for component in all {
556            assert!(seen.insert(*component));
557        }
558    }
559
560    #[test]
561    fn test_stack_component_display() {
562        assert_eq!(format!("{}", StackComponent::Trueno), "trueno");
563        assert_eq!(format!("{}", StackComponent::TruenoGraph), "trueno-graph");
564        assert_eq!(format!("{}", StackComponent::TruenoRag), "trueno-rag");
565        assert_eq!(format!("{}", StackComponent::Entrenar), "entrenar");
566        assert_eq!(format!("{}", StackComponent::Realizar), "realizar");
567        assert_eq!(format!("{}", StackComponent::Alimentar), "alimentar");
568        assert_eq!(format!("{}", StackComponent::Pacha), "pacha");
569        assert_eq!(format!("{}", StackComponent::Renacer), "renacer");
570    }
571
572    #[test]
573    fn test_version_clone() {
574        let v = Version::parse("1.2.3-beta").unwrap();
575        let cloned = v.clone();
576        assert_eq!(cloned.major, v.major);
577        assert_eq!(cloned.pre_release, v.pre_release);
578    }
579
580    #[test]
581    fn test_version_eq() {
582        let v1 = Version::parse("1.2.3").unwrap();
583        let v2 = Version::parse("1.2.3").unwrap();
584        let v3 = Version::parse("1.2.4").unwrap();
585        assert_eq!(v1, v2);
586        assert_ne!(v1, v3);
587    }
588
589    #[test]
590    fn test_version_parse_invalid() {
591        // Empty string
592        assert!(Version::parse("").is_none());
593        // Too many parts
594        assert!(Version::parse("1.2.3.4.5").is_none());
595        // Non-numeric
596        assert!(Version::parse("abc.def.ghi").is_none());
597    }
598
599    #[test]
600    fn test_version_satisfies_major_gt() {
601        let v_new = Version::parse("2.0.0").unwrap();
602        let v_old = Version::parse("1.5.0").unwrap();
603        assert!(v_new.satisfies_minimum(&v_old));
604    }
605
606    #[test]
607    fn test_version_satisfies_minor_gt() {
608        let v_new = Version::parse("1.5.0").unwrap();
609        let v_old = Version::parse("1.3.0").unwrap();
610        assert!(v_new.satisfies_minimum(&v_old));
611    }
612
613    #[test]
614    fn test_stack_component_clone_eq() {
615        let c1 = StackComponent::Trueno;
616        let c2 = c1.clone();
617        assert_eq!(c1, c2);
618    }
619
620    #[test]
621    fn test_stack_component_hash() {
622        use std::collections::HashSet;
623        let mut set = HashSet::new();
624        set.insert(StackComponent::Trueno);
625        set.insert(StackComponent::Aprender);
626        set.insert(StackComponent::Trueno); // Duplicate
627        assert_eq!(set.len(), 2);
628    }
629
630    #[test]
631    fn test_version_parse_with_tilde() {
632        let v = Version::parse("~1.2.3").unwrap();
633        assert_eq!(v.major, 1);
634        assert_eq!(v.minor, 2);
635        assert_eq!(v.patch, 3);
636    }
637
638    #[test]
639    fn test_version_parse_with_lt_gt() {
640        let v = Version::parse(">1.0.0").unwrap();
641        assert_eq!(v.major, 1);
642
643        let v = Version::parse("<2.0.0").unwrap();
644        assert_eq!(v.major, 2);
645
646        let v = Version::parse("<=3.0.0").unwrap();
647        assert_eq!(v.major, 3);
648    }
649}