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 mode/node config from mecha10.json's `targets`
9//! - Determines which nodes to spawn based on current mode
10//! - Provides mode change logic without runtime dependency
11
12use crate::types::project::{ProjectConfig, TargetsConfig};
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    /// Per-mode node lists, from mecha10.json's `targets.<mode>.nodes`
29    mode_config: HashMap<String, Vec<String>>,
30}
31
32/// Result of calculating mode transition
33#[derive(Debug)]
34#[allow(dead_code)]
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 any `targets` configured
47    ///
48    /// The initial mode is always `"dev"` — callers are responsible for verifying a `"dev"`
49    /// target exists in `targets` 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 mode_config: HashMap<String, Vec<String>> = config
53            .targets
54            .present_entries()
55            .into_iter()
56            .map(|(name, nodes)| (name.to_string(), nodes.to_vec()))
57            .collect();
58
59        if mode_config.is_empty() {
60            return None;
61        }
62
63        Some(Self {
64            current_mode: "dev".to_string(),
65            running_nodes: HashSet::new(),
66            mode_config,
67        })
68    }
69
70    /// Get the current mode
71    pub fn current_mode(&self) -> &str {
72        &self.current_mode
73    }
74
75    // duplicate: false positive (LAB-2570) — matches `NodeRegistry::is_running`
76    // (handlers/dev/ops/registry.rs) and `ComponentCatalogService::exists`
77    // (mecha10-cli-core/src/services/component_catalog.rs) purely on AST shape: all three are
78    // single-line `self.<map>.contains_key(..)`/`.get(..).is_some()` boolean lookups. The maps
79    // themselves (mode config, running-PID registry, component catalog) have nothing to do with
80    // each other, so there's no shared behavior to pull into a common trait/helper.
81    /// Check if a mode exists in the configuration
82    pub fn has_mode(&self, mode: &str) -> bool {
83        self.mode_config.contains_key(mode)
84    }
85
86    /// Get list of nodes that should run in the current mode
87    pub fn nodes_for_current_mode(&self) -> Vec<String> {
88        self.mode_config.get(&self.current_mode).cloned().unwrap_or_default()
89    }
90
91    /// Calculate what changes are needed to transition to target mode
92    ///
93    /// Returns the diff (nodes to start/stop) without changing state.
94    // duplicate: false positive — matches `runtime::lifecycle::LifecycleManager::calculate_mode_diff`
95    // because both are thin wrappers around the actual shared logic, `diff_nodes` (imported
96    // above), already extracted and used by both call sites. What's left differs on purpose:
97    // this looks up a plain node list from the CLI's dev-only `targets` config and returns
98    // `ModeTransitionDiff`, while the runtime version looks up a `ModeConfig` (extra fields
99    // beyond the node list) and returns `ModeDiff` (see that method's matching note).
100    #[allow(dead_code)]
101    pub fn calculate_mode_diff(&self, target_mode: &str) -> Result<ModeTransitionDiff> {
102        let target_nodes = self
103            .mode_config
104            .get(target_mode)
105            .ok_or_else(|| anyhow::anyhow!("Mode '{}' not found", target_mode))?;
106
107        let (start, stop) = diff_nodes(target_nodes, &self.running_nodes);
108
109        Ok(ModeTransitionDiff { start, stop })
110    }
111
112    /// Mark nodes as running (after spawning them)
113    pub fn mark_nodes_running(&mut self, nodes: &[String]) {
114        for node in nodes {
115            self.running_nodes.insert(node.clone());
116        }
117    }
118
119    /// Mark nodes as stopped (after killing them)
120    #[allow(dead_code)]
121    pub fn mark_nodes_stopped(&mut self, nodes: &[String]) {
122        for node in nodes {
123            self.running_nodes.remove(node);
124        }
125    }
126
127    /// Change to a new mode (updates internal state only)
128    ///
129    /// Returns the diff of what needs to change. Caller is responsible
130    /// for actually spawning/killing processes.
131    #[allow(dead_code)]
132    pub fn change_mode(&mut self, target_mode: &str) -> Result<ModeTransitionDiff> {
133        let diff = self.calculate_mode_diff(target_mode)?;
134        self.current_mode = target_mode.to_string();
135        Ok(diff)
136    }
137
138    /// Get available modes
139    #[allow(dead_code)]
140    pub fn available_modes(&self) -> Vec<&str> {
141        self.mode_config.keys().map(|s| s.as_str()).collect()
142    }
143
144    /// Validate target configuration
145    ///
146    /// Checks that:
147    /// - All node references exist in project config
148    #[allow(dead_code)]
149    pub fn validate(targets: &TargetsConfig, available_nodes: &[String]) -> Result<()> {
150        let node_set: HashSet<_> = available_nodes.iter().map(|s| s.as_str()).collect();
151
152        for (mode_name, nodes) in targets.present_entries() {
153            for node in nodes {
154                if !node_set.contains(node.as_str()) {
155                    return Err(anyhow::anyhow!(
156                        "Mode '{}' references unknown node '{}'",
157                        mode_name,
158                        node
159                    ));
160                }
161            }
162        }
163
164        Ok(())
165    }
166}