Skip to main content

batuta/stack/
tree.rs

1//! Stack Tree View - Visual hierarchical representation of PAIML stack
2//!
3//! Implements spec: docs/specifications/stack-tree-view.md
4
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8// ============================================================================
9// TREE-001: Core Types
10// ============================================================================
11
12/// Health status of a component
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum HealthStatus {
16    /// Local and remote versions match
17    Synced,
18    /// Local version is behind remote
19    Behind,
20    /// Local version is ahead of remote
21    Ahead,
22    /// Crate not found on crates.io
23    NotFound,
24    /// Error checking status
25    Error(String),
26}
27
28impl fmt::Display for HealthStatus {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Self::Synced => write!(f, "✓"),
32            Self::Behind => write!(f, "⚠"),
33            Self::Ahead => write!(f, "↑"),
34            Self::NotFound => write!(f, "?"),
35            Self::Error(_) => write!(f, "✗"),
36        }
37    }
38}
39
40/// A component in the PAIML stack
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Component {
43    /// Crate name
44    pub name: String,
45    /// Short description
46    pub description: String,
47    /// Local version if found
48    pub version_local: Option<semver::Version>,
49    /// Remote version from crates.io
50    pub version_remote: Option<semver::Version>,
51    /// Health status
52    pub health: HealthStatus,
53}
54
55impl Component {
56    /// Create a new component
57    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
58        Self {
59            name: name.into(),
60            description: description.into(),
61            version_local: None,
62            version_remote: None,
63            health: HealthStatus::NotFound,
64        }
65    }
66
67    /// Set local version
68    pub fn with_local_version(mut self, version: semver::Version) -> Self {
69        self.version_local = Some(version);
70        self.update_health();
71        self
72    }
73
74    /// Set remote version
75    pub fn with_remote_version(mut self, version: semver::Version) -> Self {
76        self.version_remote = Some(version);
77        self.update_health();
78        self
79    }
80
81    /// Update health based on versions
82    fn update_health(&mut self) {
83        self.health = match (&self.version_local, &self.version_remote) {
84            (Some(local), Some(remote)) => {
85                if local == remote {
86                    HealthStatus::Synced
87                } else if local < remote {
88                    HealthStatus::Behind
89                } else {
90                    HealthStatus::Ahead
91                }
92            }
93            (Some(_), None) => HealthStatus::NotFound,
94            (None, Some(_)) => HealthStatus::NotFound,
95            (None, None) => HealthStatus::NotFound,
96        };
97    }
98}
99
100/// A layer in the PAIML stack
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct StackLayer {
103    /// Layer name (e.g., "core", "ml")
104    pub name: String,
105    /// Components in this layer
106    pub components: Vec<Component>,
107}
108
109impl StackLayer {
110    /// Create a new layer
111    pub fn new(name: impl Into<String>) -> Self {
112        Self { name: name.into(), components: Vec::new() }
113    }
114
115    /// Add a component to this layer
116    pub fn add_component(mut self, component: Component) -> Self {
117        self.components.push(component);
118        self
119    }
120}
121
122/// The complete PAIML stack tree
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct StackTree {
125    /// Stack name
126    pub name: String,
127    /// Total crate count
128    pub total_crates: usize,
129    /// Layers in the stack
130    pub layers: Vec<StackLayer>,
131}
132
133impl StackTree {
134    /// Create a new stack tree
135    pub fn new(name: impl Into<String>) -> Self {
136        Self { name: name.into(), total_crates: 0, layers: Vec::new() }
137    }
138
139    /// Add a layer to the tree
140    pub fn add_layer(mut self, layer: StackLayer) -> Self {
141        self.total_crates += layer.components.len();
142        self.layers.push(layer);
143        self
144    }
145
146    /// Get total synced count
147    pub fn synced_count(&self) -> usize {
148        self.layers
149            .iter()
150            .flat_map(|l| &l.components)
151            .filter(|c| c.health == HealthStatus::Synced)
152            .count()
153    }
154
155    /// Get total behind count
156    pub fn behind_count(&self) -> usize {
157        self.layers
158            .iter()
159            .flat_map(|l| &l.components)
160            .filter(|c| c.health == HealthStatus::Behind)
161            .count()
162    }
163}
164
165// ============================================================================
166// TREE-002: Output Formats
167// ============================================================================
168
169/// Output format for the tree
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
171pub enum OutputFormat {
172    /// ASCII tree (default)
173    #[default]
174    Ascii,
175    /// JSON output
176    Json,
177    /// Graphviz DOT format
178    Dot,
179}
180
181impl std::str::FromStr for OutputFormat {
182    type Err = String;
183
184    fn from_str(s: &str) -> Result<Self, Self::Err> {
185        match s.to_lowercase().as_str() {
186            "ascii" => Ok(Self::Ascii),
187            "json" => Ok(Self::Json),
188            "dot" => Ok(Self::Dot),
189            _ => Err(format!("Unknown format: {s}")),
190        }
191    }
192}
193
194// ============================================================================
195// TREE-003: Formatters
196// ============================================================================
197
198/// Format a single component line for ASCII tree output.
199fn format_component_line(
200    comp: &Component,
201    show_health: bool,
202    comp_prefix: &str,
203    comp_branch: &str,
204) -> String {
205    if !show_health {
206        return format!("{}{}{}\n", comp_prefix, comp_branch, comp.name);
207    }
208    let version_str = match (&comp.version_local, &comp.version_remote) {
209        (Some(local), Some(remote)) if local != remote => {
210            format!("v{} → {}", local, remote)
211        }
212        (Some(local), _) => format!("v{}", local),
213        _ => String::new(),
214    };
215    format!("{}{}{} {} {}\n", comp_prefix, comp_branch, comp.name, comp.health, version_str)
216}
217
218/// Format tree as ASCII
219pub fn format_ascii(tree: &StackTree, show_health: bool) -> String {
220    let mut output = format!("{} ({} crates)\n", tree.name, tree.total_crates);
221
222    for (layer_idx, layer) in tree.layers.iter().enumerate() {
223        let is_last_layer = layer_idx == tree.layers.len() - 1;
224        let layer_prefix = if is_last_layer { "└── " } else { "├── " };
225        output.push_str(&format!("{}{}\n", layer_prefix, layer.name));
226
227        let comp_prefix = if is_last_layer { "    " } else { "│   " };
228        for (comp_idx, comp) in layer.components.iter().enumerate() {
229            let comp_branch =
230                if comp_idx == layer.components.len() - 1 { "└── " } else { "├── " };
231            output.push_str(&format_component_line(comp, show_health, comp_prefix, comp_branch));
232        }
233    }
234
235    output
236}
237
238/// Format tree as JSON
239pub fn format_json(tree: &StackTree) -> Result<String, serde_json::Error> {
240    serde_json::to_string_pretty(tree)
241}
242
243/// Format tree as Graphviz DOT
244pub fn format_dot(tree: &StackTree) -> String {
245    let mut output = String::from("digraph paiml_stack {\n");
246    output.push_str("  rankdir=TB;\n");
247    output.push_str("  node [shape=box];\n\n");
248
249    for layer in &tree.layers {
250        output.push_str(&format!("  subgraph cluster_{} {{\n", layer.name.replace(' ', "_")));
251        output.push_str(&format!("    label=\"{}\";\n", layer.name));
252
253        for comp in &layer.components {
254            let color = match comp.health {
255                HealthStatus::Synced => "green",
256                HealthStatus::Behind => "orange",
257                HealthStatus::Ahead => "blue",
258                HealthStatus::NotFound => "gray",
259                HealthStatus::Error(_) => "red",
260            };
261            output.push_str(&format!("    {} [color={}];\n", comp.name.replace('-', "_"), color));
262        }
263
264        output.push_str("  }\n\n");
265    }
266
267    output.push_str("}\n");
268    output
269}
270
271// ============================================================================
272// TREE-004: Builder
273// ============================================================================
274
275/// Layer definitions for PAIML stack
276pub const LAYER_DEFINITIONS: &[(&str, &[&str])] = &[
277    ("core", &["trueno", "trueno-viz", "trueno-db", "trueno-graph", "trueno-rag", "trueno-zram"]),
278    ("ml", &["aprender", "aprender-shell", "aprender-tsp"]),
279    ("inference", &["realizar", "renacer", "alimentar", "entrenar"]),
280    ("orchestration", &["batuta", "certeza", "presentar", "pacha"]),
281    ("distributed", &["repartir", "pepita"]),
282    ("inference", &["whisper-apr"]),
283    ("transpilation", &["ruchy", "decy", "depyler", "bashrs"]),
284    ("docs", &["sovereign-ai-stack-book"]),
285];
286
287/// Component descriptions
288pub fn get_component_description(name: &str) -> &'static str {
289    match name {
290        "trueno" => "SIMD tensor operations",
291        "trueno-viz" => "Visualization",
292        "trueno-db" => "Vector database",
293        "trueno-graph" => "Graph algorithms",
294        "trueno-rag" => "RAG framework",
295        "trueno-zram" => "SIMD memory compression",
296        "aprender" => "ML algorithms",
297        "aprender-shell" => "REPL",
298        "aprender-tsp" => "TSP solver",
299        "realizar" => "Inference engine",
300        "renacer" => "Model lifecycle",
301        "alimentar" => "Data pipelines",
302        "entrenar" => "Experiment tracking",
303        "batuta" => "Orchestrator",
304        "certeza" => "Quality gates",
305        "presentar" => "Presentation",
306        "pacha" => "Knowledge base",
307        "repartir" => "Distributed computing",
308        "pepita" => "io_uring kernel interfaces",
309        "whisper-apr" => "Speech-to-text inference",
310        "ruchy" => "Rust-Python bridge",
311        "decy" => "Decision engine",
312        "depyler" => "Python→Rust transpiler",
313        "bashrs" => "Bash→Rust transpiler",
314        "sovereign-ai-stack-book" => "Documentation",
315        _ => "Unknown component",
316    }
317}
318
319/// Build the default PAIML stack tree (without version info)
320pub fn build_tree() -> StackTree {
321    let mut tree = StackTree::new("PAIML Stack");
322
323    for (layer_name, components) in LAYER_DEFINITIONS {
324        let mut layer = StackLayer::new(*layer_name);
325        for comp_name in *components {
326            let component = Component::new(*comp_name, get_component_description(comp_name));
327            layer = layer.add_component(component);
328        }
329        tree = tree.add_layer(layer);
330    }
331
332    tree
333}
334
335// ============================================================================
336// Tests - Extreme TDD
337// ============================================================================
338
339#[cfg(test)]
340#[allow(non_snake_case)]
341mod tests {
342    use super::*;
343
344    // ========================================================================
345    // TREE-001: HealthStatus Tests
346    // ========================================================================
347
348    #[test]
349    fn test_TREE_001_health_status_display_synced() {
350        assert_eq!(format!("{}", HealthStatus::Synced), "✓");
351    }
352
353    #[test]
354    fn test_TREE_001_health_status_display_behind() {
355        assert_eq!(format!("{}", HealthStatus::Behind), "⚠");
356    }
357
358    #[test]
359    fn test_TREE_001_health_status_display_ahead() {
360        assert_eq!(format!("{}", HealthStatus::Ahead), "↑");
361    }
362
363    #[test]
364    fn test_TREE_001_health_status_display_not_found() {
365        assert_eq!(format!("{}", HealthStatus::NotFound), "?");
366    }
367
368    #[test]
369    fn test_TREE_001_health_status_display_error() {
370        assert_eq!(format!("{}", HealthStatus::Error("test".into())), "✗");
371    }
372
373    #[test]
374    fn test_TREE_001_health_status_serialize() {
375        let json = serde_json::to_string(&HealthStatus::Synced).expect("json serialize failed");
376        assert_eq!(json, "\"synced\"");
377    }
378
379    #[test]
380    fn test_TREE_001_health_status_deserialize() {
381        let status: HealthStatus =
382            serde_json::from_str("\"behind\"").expect("json deserialize failed");
383        assert_eq!(status, HealthStatus::Behind);
384    }
385
386    // ========================================================================
387    // TREE-002: Component Tests
388    // ========================================================================
389
390    #[test]
391    fn test_TREE_002_component_new() {
392        let comp = Component::new("trueno", "SIMD ops");
393        assert_eq!(comp.name, "trueno");
394        assert_eq!(comp.description, "SIMD ops");
395        assert_eq!(comp.health, HealthStatus::NotFound);
396    }
397
398    #[test]
399    fn test_TREE_002_component_with_local_version() {
400        let comp =
401            Component::new("trueno", "SIMD").with_local_version(semver::Version::new(1, 0, 0));
402        assert_eq!(comp.version_local, Some(semver::Version::new(1, 0, 0)));
403    }
404
405    #[test]
406    fn test_TREE_002_component_health_synced() {
407        let comp = Component::new("trueno", "SIMD")
408            .with_local_version(semver::Version::new(1, 0, 0))
409            .with_remote_version(semver::Version::new(1, 0, 0));
410        assert_eq!(comp.health, HealthStatus::Synced);
411    }
412
413    #[test]
414    fn test_TREE_002_component_health_behind() {
415        let comp = Component::new("trueno", "SIMD")
416            .with_local_version(semver::Version::new(1, 0, 0))
417            .with_remote_version(semver::Version::new(1, 1, 0));
418        assert_eq!(comp.health, HealthStatus::Behind);
419    }
420
421    #[test]
422    fn test_TREE_002_component_health_ahead() {
423        let comp = Component::new("trueno", "SIMD")
424            .with_local_version(semver::Version::new(2, 0, 0))
425            .with_remote_version(semver::Version::new(1, 0, 0));
426        assert_eq!(comp.health, HealthStatus::Ahead);
427    }
428
429    // ========================================================================
430    // TREE-003: StackLayer Tests
431    // ========================================================================
432
433    #[test]
434    fn test_TREE_003_stack_layer_new() {
435        let layer = StackLayer::new("core");
436        assert_eq!(layer.name, "core");
437        assert!(layer.components.is_empty());
438    }
439
440    #[test]
441    fn test_TREE_003_stack_layer_add_component() {
442        let layer =
443            StackLayer::new("core").add_component(Component::new("trueno", "SIMD tensor ops"));
444        assert_eq!(layer.components.len(), 1);
445        assert_eq!(layer.components[0].name, "trueno");
446    }
447
448    // ========================================================================
449    // TREE-004: StackTree Tests
450    // ========================================================================
451
452    #[test]
453    fn test_TREE_004_stack_tree_new() {
454        let tree = StackTree::new("PAIML Stack");
455        assert_eq!(tree.name, "PAIML Stack");
456        assert_eq!(tree.total_crates, 0);
457        assert!(tree.layers.is_empty());
458    }
459
460    #[test]
461    fn test_TREE_004_stack_tree_add_layer() {
462        let layer =
463            StackLayer::new("core").add_component(Component::new("trueno", "SIMD tensor ops"));
464        let tree = StackTree::new("PAIML Stack").add_layer(layer);
465        assert_eq!(tree.total_crates, 1);
466        assert_eq!(tree.layers.len(), 1);
467    }
468
469    #[test]
470    fn test_TREE_004_stack_tree_synced_count() {
471        let layer = StackLayer::new("core").add_component(
472            Component::new("trueno", "SIMD")
473                .with_local_version(semver::Version::new(1, 0, 0))
474                .with_remote_version(semver::Version::new(1, 0, 0)),
475        );
476        let tree = StackTree::new("Test").add_layer(layer);
477        assert_eq!(tree.synced_count(), 1);
478    }
479
480    #[test]
481    fn test_TREE_004_stack_tree_behind_count() {
482        let layer = StackLayer::new("core").add_component(
483            Component::new("trueno", "SIMD")
484                .with_local_version(semver::Version::new(1, 0, 0))
485                .with_remote_version(semver::Version::new(2, 0, 0)),
486        );
487        let tree = StackTree::new("Test").add_layer(layer);
488        assert_eq!(tree.behind_count(), 1);
489    }
490
491    // ========================================================================
492    // TREE-005: OutputFormat Tests
493    // ========================================================================
494
495    #[test]
496    fn test_TREE_005_output_format_from_str_ascii() {
497        assert_eq!("ascii".parse::<OutputFormat>().expect("parse failed"), OutputFormat::Ascii);
498    }
499
500    #[test]
501    fn test_TREE_005_output_format_from_str_json() {
502        assert_eq!("json".parse::<OutputFormat>().expect("parse failed"), OutputFormat::Json);
503    }
504
505    #[test]
506    fn test_TREE_005_output_format_from_str_dot() {
507        assert_eq!("dot".parse::<OutputFormat>().expect("parse failed"), OutputFormat::Dot);
508    }
509
510    #[test]
511    fn test_TREE_005_output_format_from_str_case_insensitive() {
512        assert_eq!("JSON".parse::<OutputFormat>().expect("parse failed"), OutputFormat::Json);
513    }
514
515    #[test]
516    fn test_TREE_005_output_format_from_str_invalid() {
517        assert!("xml".parse::<OutputFormat>().is_err());
518    }
519
520    #[test]
521    fn test_TREE_005_output_format_default() {
522        assert_eq!(OutputFormat::default(), OutputFormat::Ascii);
523    }
524
525    // ========================================================================
526    // TREE-006: ASCII Formatter Tests
527    // ========================================================================
528
529    #[test]
530    fn test_TREE_006_format_ascii_header() {
531        let tree = StackTree::new("Test Stack");
532        let output = format_ascii(&tree, false);
533        assert!(output.starts_with("Test Stack (0 crates)"));
534    }
535
536    #[test]
537    fn test_TREE_006_format_ascii_with_layer() {
538        let layer = StackLayer::new("core").add_component(Component::new("trueno", "SIMD"));
539        let tree = StackTree::new("Test").add_layer(layer);
540        let output = format_ascii(&tree, false);
541        assert!(output.contains("└── core"));
542        assert!(output.contains("trueno"));
543    }
544
545    #[test]
546    fn test_TREE_006_format_ascii_with_health() {
547        let layer = StackLayer::new("core").add_component(
548            Component::new("trueno", "SIMD")
549                .with_local_version(semver::Version::new(1, 0, 0))
550                .with_remote_version(semver::Version::new(1, 0, 0)),
551        );
552        let tree = StackTree::new("Test").add_layer(layer);
553        let output = format_ascii(&tree, true);
554        assert!(output.contains("✓"));
555        assert!(output.contains("v1.0.0"));
556    }
557
558    #[test]
559    fn test_TREE_006_format_ascii_version_diff() {
560        let layer = StackLayer::new("core").add_component(
561            Component::new("trueno", "SIMD")
562                .with_local_version(semver::Version::new(1, 0, 0))
563                .with_remote_version(semver::Version::new(2, 0, 0)),
564        );
565        let tree = StackTree::new("Test").add_layer(layer);
566        let output = format_ascii(&tree, true);
567        assert!(output.contains("v1.0.0 → 2.0.0"));
568    }
569
570    // ========================================================================
571    // TREE-007: JSON Formatter Tests
572    // ========================================================================
573
574    #[test]
575    fn test_TREE_007_format_json_valid() {
576        let tree = StackTree::new("Test");
577        let json = format_json(&tree).expect("unexpected failure");
578        assert!(json.contains("\"name\": \"Test\""));
579    }
580
581    #[test]
582    fn test_TREE_007_format_json_roundtrip() {
583        let layer = StackLayer::new("core").add_component(Component::new("trueno", "SIMD"));
584        let tree = StackTree::new("Test").add_layer(layer);
585        let json = format_json(&tree).expect("unexpected failure");
586        let parsed: StackTree = serde_json::from_str(&json).expect("json deserialize failed");
587        assert_eq!(parsed.name, "Test");
588        assert_eq!(parsed.layers[0].components[0].name, "trueno");
589    }
590
591    // ========================================================================
592    // TREE-008: DOT Formatter Tests
593    // ========================================================================
594
595    #[test]
596    fn test_TREE_008_format_dot_header() {
597        let tree = StackTree::new("Test");
598        let dot = format_dot(&tree);
599        assert!(dot.starts_with("digraph paiml_stack {"));
600        assert!(dot.contains("rankdir=TB"));
601    }
602
603    #[test]
604    fn test_TREE_008_format_dot_cluster() {
605        let layer = StackLayer::new("core").add_component(Component::new("trueno", "SIMD"));
606        let tree = StackTree::new("Test").add_layer(layer);
607        let dot = format_dot(&tree);
608        assert!(dot.contains("subgraph cluster_core"));
609        assert!(dot.contains("label=\"core\""));
610    }
611
612    #[test]
613    fn test_TREE_008_format_dot_health_colors() {
614        let layer = StackLayer::new("core").add_component(
615            Component::new("trueno", "SIMD")
616                .with_local_version(semver::Version::new(1, 0, 0))
617                .with_remote_version(semver::Version::new(1, 0, 0)),
618        );
619        let tree = StackTree::new("Test").add_layer(layer);
620        let dot = format_dot(&tree);
621        assert!(dot.contains("color=green"));
622    }
623
624    // ========================================================================
625    // TREE-009: Builder Tests
626    // ========================================================================
627
628    #[test]
629    fn test_TREE_009_build_tree_creates_all_layers() {
630        let tree = build_tree();
631        assert_eq!(tree.layers.len(), 8);
632    }
633
634    #[test]
635    fn test_TREE_009_build_tree_total_crates() {
636        let tree = build_tree();
637        assert_eq!(tree.total_crates, 25);
638    }
639
640    #[test]
641    fn test_TREE_009_build_tree_core_layer() {
642        let tree = build_tree();
643        let core = &tree.layers[0];
644        assert_eq!(core.name, "core");
645        assert_eq!(core.components.len(), 6);
646        assert_eq!(core.components[0].name, "trueno");
647    }
648
649    #[test]
650    fn test_TREE_009_get_component_description() {
651        assert_eq!(get_component_description("trueno"), "SIMD tensor operations");
652        assert_eq!(get_component_description("batuta"), "Orchestrator");
653        assert_eq!(get_component_description("unknown"), "Unknown component");
654    }
655
656    // ========================================================================
657    // TREE-010: Integration Tests
658    // ========================================================================
659
660    #[test]
661    fn test_TREE_010_full_tree_ascii_output() {
662        let tree = build_tree();
663        let output = format_ascii(&tree, false);
664        assert!(output.contains("PAIML Stack (25 crates)"));
665        assert!(output.contains("core"));
666        assert!(output.contains("ml"));
667        assert!(output.contains("orchestration"));
668        assert!(output.contains("trueno"));
669        assert!(output.contains("batuta"));
670    }
671
672    #[test]
673    fn test_TREE_010_full_tree_json_output() {
674        let tree = build_tree();
675        let json = format_json(&tree).expect("unexpected failure");
676        let parsed: serde_json::Value =
677            serde_json::from_str(&json).expect("json deserialize failed");
678        assert_eq!(parsed["total_crates"], 25);
679    }
680
681    #[test]
682    fn test_TREE_010_full_tree_dot_output() {
683        let tree = build_tree();
684        let dot = format_dot(&tree);
685        assert!(dot.contains("digraph"));
686        assert!(dot.contains("cluster_core"));
687        assert!(dot.contains("cluster_ml"));
688    }
689}