mecha10-cli 0.8.0

Mecha10 CLI tool
Documentation
//! Watch mode, process cleanup, and lifecycle mode changes for dev mode

use super::registry::NodeRegistry;
use super::spawn::spawn_nodes_by_name;
use crate::context::CliContext;
use crate::dev::node_selection::NodeToRun;
use crate::paths;
use crate::types::ProjectConfig;
use crate::ui::LogBuffer;
use anyhow::Result;
use mecha10_cli_core::utils::strip_node_scope;
use std::collections::HashMap;

/// Run watch mode - monitors processes and rebuilds on file changes
pub async fn run_watch_mode(ctx: &mut CliContext, _pids: HashMap<String, u32>) -> Result<()> {
    use std::sync::atomic::Ordering;
    use std::time::Duration;

    println!("Watch mode enabled (file changes will trigger rebuild)");
    println!();
    println!("Press Ctrl+C to stop all nodes");
    println!();

    let running = crate::dev::termination::install_quit_signal()?;

    while running.load(Ordering::SeqCst) {
        tokio::time::sleep(Duration::from_secs(1)).await;

        let status = ctx.process().get_status();
        for (node, node_status) in &status {
            if !node_status.contains("running") {
                println!("⚠️  {} stopped unexpectedly", node);
            }
        }
    }

    println!();
    println!("Stopping all nodes...");
    cleanup(ctx)?;

    Ok(())
}

/// Cleanup dev mode - stop all processes in dependency order
pub fn cleanup(ctx: &mut CliContext) -> Result<()> {
    use std::time::Duration;
    use tracing::warn;

    println!();
    println!("Stopping all nodes in dependency order...");

    let shared_process = ctx.shared_process();
    let mut process_service = shared_process.lock().unwrap();
    let shutdown_order = process_service.get_shutdown_order();

    if shutdown_order.is_empty() {
        println!("✅ No nodes to stop");
        return Ok(());
    }

    for node in shutdown_order {
        println!("  Stopping {}...", node);

        if let Err(e) = process_service.stop_with_timeout(&node, Duration::from_secs(10)) {
            warn!("Failed to gracefully stop {}: {}", node, e);
            if let Err(kill_err) = process_service.force_kill(&node) {
                warn!("Failed to force kill {}: {}", node, kill_err);
            } else {
                println!("  ✅ {} stopped (force killed)", node);
            }
        } else {
            println!("  ✅ {} stopped", node);
        }
    }

    println!("✅ All nodes stopped");

    Ok(())
}

/// Handle lifecycle mode change.
///
/// Calculates node diff, stops old nodes, starts new nodes, updates lifecycle state. Returns the
/// `(node_name, error)` pairs for nodes the target mode requires but which failed to start.
#[allow(clippy::too_many_arguments)]
pub(super) async fn handle_mode_change(
    ctx: &mut CliContext,
    lifecycle: &mut crate::dev::lifecycle_adapter::CliLifecycleManager,
    target_mode: &str,
    registry: &mut NodeRegistry,
    project_name: &str,
    project_config: &ProjectConfig,
    nodes_to_run: &[NodeToRun],
    log_buffer: Option<&LogBuffer>,
) -> Result<Vec<(String, String)>> {
    println!();
    println!("⚡ Switching to {} mode...", target_mode);

    if let Some(buffer) = log_buffer {
        buffer.push(format!("MODE: Switching to {} mode", target_mode));
    }

    let diff = lifecycle.change_mode(target_mode)?;

    if !diff.stop.is_empty() {
        println!("🛑 Stopping: {}", diff.stop.join(", "));
        let shared_process = ctx.shared_process();
        let mut process_service = shared_process.lock().unwrap();
        for node_name in &diff.stop {
            if registry.mark_exited(node_name).is_some() {
                if let Err(e) = process_service.stop(node_name) {
                    eprintln!("  Warning: Failed to stop {}: {}", node_name, e);
                    if let Some(buffer) = log_buffer {
                        buffer.push(format!("ERROR: Failed to stop {}: {}", node_name, e));
                    }
                } else if let Some(buffer) = log_buffer {
                    buffer.push(format!("🛑 STOPPED: {}", node_name));
                }
            }
        }
        lifecycle.mark_nodes_stopped(&diff.stop);
    }

    let mut start_failures: Vec<(String, String)> = Vec::new();
    if !diff.start.is_empty() {
        println!("✅ Starting: {}", diff.start.join(", "));
        let outcome = spawn_nodes_by_name(ctx, &diff.start, project_name, project_config, log_buffer)?;
        let actually_started: Vec<String> = outcome.pids.keys().cloned().collect();
        for (node, pid) in &outcome.pids {
            registry.mark_started(node, *pid);
        }
        lifecycle.mark_nodes_running(&actually_started);
        start_failures = outcome.failures;

        start_failures.extend(recheck_liveness(ctx, &actually_started).await);
    }

    if start_failures.is_empty() {
        println!("✓ Mode changed to: {}", target_mode);
    } else {
        println!("⚠️  Mode changed to: {} — but some nodes failed to start:", target_mode);
        for (node, err) in &start_failures {
            println!("   ✗ {}: {}", node, err);
        }
    }
    println!();

    if let Some(buffer) = log_buffer {
        if start_failures.is_empty() {
            buffer.push(format!("MODE: Successfully changed to {} mode", target_mode));
        } else {
            let failed_names: Vec<&str> = start_failures.iter().map(|(name, _)| name.as_str()).collect();
            buffer.push(format!(
                "MODE: Changed to {} mode, but failed to start: {}",
                target_mode,
                failed_names.join(", ")
            ));
            for (node, err) in &start_failures {
                buffer.push(format!("ERROR: {} failed to start during mode change: {}", node, err));
            }
        }
    }

    let running: Vec<String> = registry.running_node_names();
    crate::ui::dev_banners::print_mode_status(target_mode, &running, nodes_to_run);

    Ok(start_failures)
}

/// Re-check whether nodes that just spawned successfully are still alive a short grace period
/// later, and fold any that already exited into the returned failure list - same shape as
/// [`crate::handlers::dev::ops::spawn::SpawnOutcome::failures`].
async fn recheck_liveness(ctx: &mut CliContext, started: &[String]) -> Vec<(String, String)> {
    if started.is_empty() {
        return Vec::new();
    }

    tokio::time::sleep(std::time::Duration::from_millis(1500)).await;

    let statuses = {
        let shared_process = ctx.shared_process();
        let mut process_service = shared_process.lock().unwrap();
        process_service.get_status()
    };

    let mut died_early = Vec::new();
    for node_name in started {
        let Some(status) = statuses.get(node_name) else {
            continue;
        };
        if status == "running" {
            continue;
        }

        let detail = tail_node_log(node_name);
        let reason = match detail {
            Some(detail) => format!("exited shortly after starting ({status}): {detail}"),
            None => format!(
                "exited shortly after starting ({status}) - see logs/{}.log for details \
                 (enable `environments.<env>.logging.disk_logging_enabled` in mecha10.json if that file is empty)",
                strip_node_scope(node_name)
            ),
        };
        died_early.push((node_name.clone(), reason));
    }

    died_early
}

/// Best-effort read of the last non-empty line of a node's on-disk log file - used by
/// [`recheck_liveness`] to explain *why* a node exited shortly after starting.
fn tail_node_log(node_name: &str) -> Option<String> {
    let log_path = std::path::Path::new(paths::project::LOGS_DIR).join(format!("{}.log", strip_node_scope(node_name)));
    let content = std::fs::read_to_string(log_path).ok()?;
    last_nonempty_line(&content)
}

/// Pure helper split out of [`tail_node_log`] so the "find the last non-empty, trimmed line"
/// logic is unit testable without touching the filesystem.
fn last_nonempty_line(content: &str) -> Option<String> {
    content
        .lines()
        .rev()
        .find(|line| !line.trim().is_empty())
        .map(|line| line.trim().to_string())
}

#[cfg(test)]
mod liveness_tests {
    use super::*;

    #[test]
    fn last_nonempty_line_skips_trailing_blank_lines() {
        let content = "line one\nline two\n\n   \n";
        assert_eq!(last_nonempty_line(content), Some("line two".to_string()));
    }

    #[test]
    fn last_nonempty_line_trims_whitespace() {
        let content = "first\n  last line with padding   \n";
        assert_eq!(last_nonempty_line(content), Some("last line with padding".to_string()));
    }

    #[test]
    fn last_nonempty_line_none_for_empty_content() {
        assert_eq!(last_nonempty_line(""), None);
        assert_eq!(last_nonempty_line("\n\n   \n"), None);
    }

    #[test]
    fn tail_node_log_returns_none_when_file_missing() {
        assert_eq!(tail_node_log("@mecha10/definitely-not-a-real-node-xyz"), None);
    }
}