mecha10-cli 0.6.5

Mecha10 CLI tool
Documentation
//! Lifecycle adapter for CLI process management
//!
//! This module adapts the runtime lifecycle system for the CLI's process-based
//! node management. While the runtime manages nodes in-process, the CLI spawns
//! nodes as separate processes via node-runner.
//!
//! This adapter:
//! - Reads mode/node config from mecha10.json's `targets`
//! - Determines which nodes to spawn based on current mode
//! - Provides mode change logic without runtime dependency

use crate::types::project::{ProjectConfig, TargetsConfig};
use anyhow::Result;
use mecha10_runtime::lifecycle::diff_nodes;
use std::collections::{HashMap, HashSet};

/// Lifecycle mode manager for CLI
///
/// Manages mode transitions and determines which nodes should run in each mode.
/// This is a lightweight adapter that doesn't depend on the full runtime.
pub struct CliLifecycleManager {
    /// Current operational mode
    current_mode: String,

    /// Set of currently running node names
    running_nodes: HashSet<String>,

    /// Per-mode node lists, from mecha10.json's `targets.<mode>.nodes`
    mode_config: HashMap<String, Vec<String>>,
}

/// Result of calculating mode transition
#[derive(Debug)]
#[allow(dead_code)]
pub struct ModeTransitionDiff {
    /// Nodes that need to be started
    pub start: Vec<String>,

    /// Nodes that need to be stopped
    pub stop: Vec<String>,
}

impl CliLifecycleManager {
    /// Create a new CLI lifecycle manager from project config
    ///
    /// Returns None if project doesn't have any `targets` configured
    ///
    /// The initial mode is always `"dev"` — callers are responsible for verifying a `"dev"`
    /// target exists in `targets` before constructing this (see `DevSession::initialize`,
    /// which is `mecha10 dev`'s only production call site for this constructor).
    pub fn from_project_config(config: &ProjectConfig) -> Option<Self> {
        let mode_config: HashMap<String, Vec<String>> = config
            .targets
            .present_entries()
            .into_iter()
            .map(|(name, nodes)| (name.to_string(), nodes.to_vec()))
            .collect();

        if mode_config.is_empty() {
            return None;
        }

        Some(Self {
            current_mode: "dev".to_string(),
            running_nodes: HashSet::new(),
            mode_config,
        })
    }

    /// Get the current mode
    pub fn current_mode(&self) -> &str {
        &self.current_mode
    }

    // duplicate: false positive (LAB-2570) — matches `NodeRegistry::is_running`
    // (handlers/dev/ops/registry.rs) and `ComponentCatalogService::exists`
    // (mecha10-cli-core/src/services/component_catalog.rs) purely on AST shape: all three are
    // single-line `self.<map>.contains_key(..)`/`.get(..).is_some()` boolean lookups. The maps
    // themselves (mode config, running-PID registry, component catalog) have nothing to do with
    // each other, so there's no shared behavior to pull into a common trait/helper.
    /// Check if a mode exists in the configuration
    pub fn has_mode(&self, mode: &str) -> bool {
        self.mode_config.contains_key(mode)
    }

    /// Get list of nodes that should run in the current mode
    pub fn nodes_for_current_mode(&self) -> Vec<String> {
        self.mode_config.get(&self.current_mode).cloned().unwrap_or_default()
    }

    /// Calculate what changes are needed to transition to target mode
    ///
    /// Returns the diff (nodes to start/stop) without changing state.
    // duplicate: false positive — matches `runtime::lifecycle::LifecycleManager::calculate_mode_diff`
    // because both are thin wrappers around the actual shared logic, `diff_nodes` (imported
    // above), already extracted and used by both call sites. What's left differs on purpose:
    // this looks up a plain node list from the CLI's dev-only `targets` config and returns
    // `ModeTransitionDiff`, while the runtime version looks up a `ModeConfig` (extra fields
    // beyond the node list) and returns `ModeDiff` (see that method's matching note).
    #[allow(dead_code)]
    pub fn calculate_mode_diff(&self, target_mode: &str) -> Result<ModeTransitionDiff> {
        let target_nodes = self
            .mode_config
            .get(target_mode)
            .ok_or_else(|| anyhow::anyhow!("Mode '{}' not found", target_mode))?;

        let (start, stop) = diff_nodes(target_nodes, &self.running_nodes);

        Ok(ModeTransitionDiff { start, stop })
    }

    /// Mark nodes as running (after spawning them)
    pub fn mark_nodes_running(&mut self, nodes: &[String]) {
        for node in nodes {
            self.running_nodes.insert(node.clone());
        }
    }

    /// Mark nodes as stopped (after killing them)
    #[allow(dead_code)]
    pub fn mark_nodes_stopped(&mut self, nodes: &[String]) {
        for node in nodes {
            self.running_nodes.remove(node);
        }
    }

    /// Change to a new mode (updates internal state only)
    ///
    /// Returns the diff of what needs to change. Caller is responsible
    /// for actually spawning/killing processes.
    #[allow(dead_code)]
    pub fn change_mode(&mut self, target_mode: &str) -> Result<ModeTransitionDiff> {
        let diff = self.calculate_mode_diff(target_mode)?;
        self.current_mode = target_mode.to_string();
        Ok(diff)
    }

    /// Get available modes
    #[allow(dead_code)]
    pub fn available_modes(&self) -> Vec<&str> {
        self.mode_config.keys().map(|s| s.as_str()).collect()
    }

    /// Validate target configuration
    ///
    /// Checks that:
    /// - All node references exist in project config
    #[allow(dead_code)]
    pub fn validate(targets: &TargetsConfig, available_nodes: &[String]) -> Result<()> {
        let node_set: HashSet<_> = available_nodes.iter().map(|s| s.as_str()).collect();

        for (mode_name, nodes) in targets.present_entries() {
            for node in nodes {
                if !node_set.contains(node.as_str()) {
                    return Err(anyhow::anyhow!(
                        "Mode '{}' references unknown node '{}'",
                        mode_name,
                        node
                    ));
                }
            }
        }

        Ok(())
    }
}