mecha10_cli/dev/
lifecycle_adapter.rs

1//! Lifecycle adapter for CLI process management
2//!
3//! This module adapts the runtime lifecycle system for the CLI's process-based
4//! node management. While the runtime manages nodes in-process, the CLI spawns
5//! nodes as separate processes via node-runner.
6//!
7//! This adapter:
8//! - Reads lifecycle config from mecha10.json
9//! - Determines which nodes to spawn based on current mode
10//! - Provides mode change logic without runtime dependency
11
12use crate::types::project::{LifecycleConfig, ModeConfig, ProjectConfig};
13use anyhow::Result;
14use std::collections::{HashMap, HashSet};
15
16/// Lifecycle mode manager for CLI
17///
18/// Manages mode transitions and determines which nodes should run in each mode.
19/// This is a lightweight adapter that doesn't depend on the full runtime.
20pub struct CliLifecycleManager {
21    /// Current operational mode
22    current_mode: String,
23
24    /// Set of currently running node names
25    running_nodes: HashSet<String>,
26
27    /// Mode configurations from mecha10.json
28    mode_config: HashMap<String, ModeConfig>,
29}
30
31/// Result of calculating mode transition
32#[derive(Debug)]
33#[allow(dead_code)] // Will be used for mode change commands
34pub struct ModeTransitionDiff {
35    /// Nodes that need to be started
36    pub start: Vec<String>,
37
38    /// Nodes that need to be stopped
39    pub stop: Vec<String>,
40}
41
42impl CliLifecycleManager {
43    /// Create a new CLI lifecycle manager from project config
44    ///
45    /// Returns None if project doesn't have lifecycle configuration
46    pub fn from_project_config(config: &ProjectConfig) -> Option<Self> {
47        let lifecycle = config.lifecycle.as_ref()?;
48
49        Some(Self {
50            current_mode: lifecycle.default_mode.clone(),
51            running_nodes: HashSet::new(),
52            mode_config: lifecycle.modes.clone(),
53        })
54    }
55
56    /// Get the current mode
57    pub fn current_mode(&self) -> &str {
58        &self.current_mode
59    }
60
61    /// Get list of nodes that should run in the current mode
62    pub fn nodes_for_current_mode(&self) -> Vec<String> {
63        self.mode_config
64            .get(&self.current_mode)
65            .map(|config| config.nodes.clone())
66            .unwrap_or_default()
67    }
68
69    /// Calculate what changes are needed to transition to target mode
70    ///
71    /// Returns the diff (nodes to start/stop) without changing state.
72    #[allow(dead_code)] // Will be used for mode change commands
73    pub fn calculate_mode_diff(&self, target_mode: &str) -> Result<ModeTransitionDiff> {
74        // Validate mode exists
75        let target_config = self
76            .mode_config
77            .get(target_mode)
78            .ok_or_else(|| anyhow::anyhow!("Mode '{}' not found", target_mode))?;
79
80        // Nodes that should run in target mode
81        let target_nodes: HashSet<_> = target_config.nodes.iter().map(|s| s.as_str()).collect();
82
83        // Nodes explicitly marked to stop
84        let stop_nodes: HashSet<_> = target_config.stop_nodes.iter().map(|s| s.as_str()).collect();
85
86        // Nodes to start: in target but not currently running
87        let start: Vec<_> = target_nodes
88            .iter()
89            .filter(|n| !self.running_nodes.contains(**n))
90            .map(|s| s.to_string())
91            .collect();
92
93        // Nodes to stop: running but not in target, or explicitly in stop_nodes
94        let stop: Vec<_> = self
95            .running_nodes
96            .iter()
97            .filter(|n| !target_nodes.contains(n.as_str()) || stop_nodes.contains(n.as_str()))
98            .cloned()
99            .collect();
100
101        Ok(ModeTransitionDiff { start, stop })
102    }
103
104    /// Mark nodes as running (after spawning them)
105    pub fn mark_nodes_running(&mut self, nodes: &[String]) {
106        for node in nodes {
107            self.running_nodes.insert(node.clone());
108        }
109    }
110
111    /// Mark nodes as stopped (after killing them)
112    #[allow(dead_code)] // Will be used for mode change commands
113    pub fn mark_nodes_stopped(&mut self, nodes: &[String]) {
114        for node in nodes {
115            self.running_nodes.remove(node);
116        }
117    }
118
119    /// Change to a new mode (updates internal state only)
120    ///
121    /// Returns the diff of what needs to change. Caller is responsible
122    /// for actually spawning/killing processes.
123    #[allow(dead_code)] // Will be used for mode change commands
124    pub fn change_mode(&mut self, target_mode: &str) -> Result<ModeTransitionDiff> {
125        let diff = self.calculate_mode_diff(target_mode)?;
126        self.current_mode = target_mode.to_string();
127        Ok(diff)
128    }
129
130    /// Get available modes
131    #[allow(dead_code)] // Will be used for mode change commands
132    pub fn available_modes(&self) -> Vec<&str> {
133        self.mode_config.keys().map(|s| s.as_str()).collect()
134    }
135
136    /// Validate lifecycle configuration
137    ///
138    /// Checks that:
139    /// - All node references exist in project config
140    /// - Default mode exists
141    #[allow(dead_code)] // Will be used for validation on project init
142    pub fn validate(lifecycle: &LifecycleConfig, available_nodes: &[String]) -> Result<()> {
143        // Check default mode exists
144        if !lifecycle.modes.contains_key(&lifecycle.default_mode) {
145            return Err(anyhow::anyhow!(
146                "Default mode '{}' not found in modes",
147                lifecycle.default_mode
148            ));
149        }
150
151        // Check all node references are valid
152        let node_set: HashSet<_> = available_nodes.iter().map(|s| s.as_str()).collect();
153
154        for (mode_name, mode_config) in &lifecycle.modes {
155            for node in &mode_config.nodes {
156                if !node_set.contains(node.as_str()) {
157                    return Err(anyhow::anyhow!(
158                        "Mode '{}' references unknown node '{}'",
159                        mode_name,
160                        node
161                    ));
162                }
163            }
164
165            for node in &mode_config.stop_nodes {
166                if !node_set.contains(node.as_str()) {
167                    return Err(anyhow::anyhow!(
168                        "Mode '{}' stop_nodes references unknown node '{}'",
169                        mode_name,
170                        node
171                    ));
172                }
173            }
174        }
175
176        Ok(())
177    }
178}