Skip to main content

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 mecha10_runtime::lifecycle::diff_nodes;
15use std::collections::{HashMap, HashSet};
16
17/// Lifecycle mode manager for CLI
18///
19/// Manages mode transitions and determines which nodes should run in each mode.
20/// This is a lightweight adapter that doesn't depend on the full runtime.
21pub struct CliLifecycleManager {
22    /// Current operational mode
23    current_mode: String,
24
25    /// Set of currently running node names
26    running_nodes: HashSet<String>,
27
28    /// Mode configurations from mecha10.json
29    mode_config: HashMap<String, ModeConfig>,
30}
31
32/// Result of calculating mode transition
33#[derive(Debug)]
34#[allow(dead_code)] // Will be used for mode change commands
35pub struct ModeTransitionDiff {
36    /// Nodes that need to be started
37    pub start: Vec<String>,
38
39    /// Nodes that need to be stopped
40    pub stop: Vec<String>,
41}
42
43impl CliLifecycleManager {
44    /// Create a new CLI lifecycle manager from project config
45    ///
46    /// Returns None if project doesn't have lifecycle configuration
47    ///
48    /// The initial mode is always `"dev"` — callers are responsible for verifying a `"dev"`
49    /// mode exists in `lifecycle.modes` before constructing this (see `DevSession::initialize`,
50    /// which is `mecha10 dev`'s only production call site for this constructor).
51    pub fn from_project_config(config: &ProjectConfig) -> Option<Self> {
52        let lifecycle = config.lifecycle.as_ref()?;
53
54        Some(Self {
55            current_mode: "dev".to_string(),
56            running_nodes: HashSet::new(),
57            mode_config: lifecycle.modes.clone(),
58        })
59    }
60
61    /// Get the current mode
62    pub fn current_mode(&self) -> &str {
63        &self.current_mode
64    }
65
66    /// Check if a mode exists in the configuration
67    pub fn has_mode(&self, mode: &str) -> bool {
68        self.mode_config.contains_key(mode)
69    }
70
71    /// Get list of nodes that should run in the current mode
72    pub fn nodes_for_current_mode(&self) -> Vec<String> {
73        self.mode_config
74            .get(&self.current_mode)
75            .map(|config| config.nodes.clone())
76            .unwrap_or_default()
77    }
78
79    /// Calculate what changes are needed to transition to target mode
80    ///
81    /// Returns the diff (nodes to start/stop) without changing state.
82    #[allow(dead_code)] // Will be used for mode change commands
83    pub fn calculate_mode_diff(&self, target_mode: &str) -> Result<ModeTransitionDiff> {
84        // Validate mode exists
85        let target_config = self
86            .mode_config
87            .get(target_mode)
88            .ok_or_else(|| anyhow::anyhow!("Mode '{}' not found", target_mode))?;
89
90        let (start, stop) = diff_nodes(&target_config.nodes, &self.running_nodes);
91
92        Ok(ModeTransitionDiff { start, stop })
93    }
94
95    /// Mark nodes as running (after spawning them)
96    pub fn mark_nodes_running(&mut self, nodes: &[String]) {
97        for node in nodes {
98            self.running_nodes.insert(node.clone());
99        }
100    }
101
102    /// Mark nodes as stopped (after killing them)
103    #[allow(dead_code)] // Will be used for mode change commands
104    pub fn mark_nodes_stopped(&mut self, nodes: &[String]) {
105        for node in nodes {
106            self.running_nodes.remove(node);
107        }
108    }
109
110    /// Change to a new mode (updates internal state only)
111    ///
112    /// Returns the diff of what needs to change. Caller is responsible
113    /// for actually spawning/killing processes.
114    #[allow(dead_code)] // Will be used for mode change commands
115    pub fn change_mode(&mut self, target_mode: &str) -> Result<ModeTransitionDiff> {
116        let diff = self.calculate_mode_diff(target_mode)?;
117        self.current_mode = target_mode.to_string();
118        Ok(diff)
119    }
120
121    /// Get available modes
122    #[allow(dead_code)] // Will be used for mode change commands
123    pub fn available_modes(&self) -> Vec<&str> {
124        self.mode_config.keys().map(|s| s.as_str()).collect()
125    }
126
127    /// Validate lifecycle configuration
128    ///
129    /// Checks that:
130    /// - All node references exist in project config
131    #[allow(dead_code)] // Will be used for validation on project init
132    pub fn validate(lifecycle: &LifecycleConfig, available_nodes: &[String]) -> Result<()> {
133        // Check all node references are valid
134        let node_set: HashSet<_> = available_nodes.iter().map(|s| s.as_str()).collect();
135
136        for (mode_name, mode_config) in &lifecycle.modes {
137            for node in &mode_config.nodes {
138                if !node_set.contains(node.as_str()) {
139                    return Err(anyhow::anyhow!(
140                        "Mode '{}' references unknown node '{}'",
141                        mode_name,
142                        node
143                    ));
144                }
145            }
146        }
147
148        Ok(())
149    }
150}