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}