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}