mecha10-cli 0.6.3

Mecha10 CLI tool
Documentation
//! Project-level Python dependency preflight gate for `mecha10 dev`
//!
//! # Why this exists
//!
//! Before this gate, a missing Python dependency (most commonly `mecha10_rl` for the
//! `simulator` node, when `mecha10 setup` was never run) was only discovered *after*
//! `mecha10 dev` had already spawned nodes: `simulator`'s own `run()`
//! (`packages/nodes/simulator/src/lib.rs`) calls
//! `mecha10_nodes_simulator::check_mecha10_rl_importable` before entering its
//! respawn-with-backoff loop, but only once its own process is already running - by which
//! point `spawn_nodes_by_name` has already reported it (and every other node in the same
//! batch) as successfully started, and other nodes have already started logging. The dev
//! session's `recheck_liveness` (`ops::lifecycle`) then waits an extra ~1.5s grace period
//! before noticing the node died and reporting why (read back from its log file tail). See
//! LAB-1811.
//!
//! This module runs the equivalent check once, up front, in
//! [`run_dependency_preflight`] - called from `DevSession::execute` after project config/node
//! selection is resolved and before *any* node is spawned. It deliberately reuses
//! `mecha10_nodes_simulator`'s own `check_mecha10_rl_importable`/`resolve_python_executable`
//! rather than duplicating their Python-invocation/venv-resolution logic, and it does NOT
//! replace `simulator`'s own preflight check in `run()` - that one stays in place as
//! defense-in-depth (e.g. covers the node being started standalone, outside `mecha10 dev`).
//!
//! # Extending to future Python-dependent nodes
//!
//! `simulator` (via `mecha10_rl`) is the only node under `packages/nodes/*` with a Python
//! runtime dependency today - every other node's `Cargo.toml` has no `python_env.rs`-style
//! module and no `requirements.txt` wiring. If a future node grows one, give it the same
//! shape (a `check_<pkg>_importable`-style function in that node's own crate) and add a
//! branch to [`run_dependency_preflight`] alongside the `simulator` one below.

use crate::dev::node_selection::NodeToRun;
use crate::paths;
use mecha10_nodes_simulator::{check_mecha10_rl_importable, resolve_python_executable, DEFAULT_PYTHON_EXECUTABLE};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::time::UNIX_EPOCH;

/// Short node name (matches `NodeToRun::name`) this gate checks for.
const SIMULATOR_NODE_NAME: &str = "simulator";

/// On-disk cache of previous preflight results, keyed by node name - see module docs and
/// [`paths::project::DEV_PREFLIGHT_CACHE_FILE`]. Purely a dev-loop speed optimization: a
/// missing, unparsable, or stale (per [`PreflightCacheEntry`]'s key) entry always falls back
/// to a real scan, so no correctness depends on this cache being present or fresh.
#[derive(Debug, Default, Serialize, Deserialize)]
struct PreflightCache {
    #[serde(default)]
    entries: HashMap<String, PreflightCacheEntry>,
}

/// A single cached "this node's Python dependency imported cleanly" result.
///
/// Invalidated (ignored) whenever `python_path` or `venv_mtime_secs` no longer match the
/// current environment - e.g. `python_executable` was reconfigured, or the project `.venv`
/// was recreated/modified since the cached check ran. Only successes are ever cached (see
/// [`run_dependency_preflight`]): a stale "OK" being wrong is worse than an occasional
/// unnecessary rescan, but caching a failure risks blocking `mecha10 dev` even after the user
/// fixes it and the cache just hasn't been invalidated yet.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PreflightCacheEntry {
    /// The resolved interpreter path the check ran against.
    python_path: String,
    /// Modified-time (seconds since epoch) of the project's `.venv` directory at check time,
    /// when one exists - cheap signal for "the venv changed since this was cached".
    venv_mtime_secs: Option<u64>,
    /// RFC 3339 timestamp of when the check ran - informational only, not part of the
    /// invalidation key.
    checked_at: String,
    /// Always `"ok"` today (failures are never cached) - kept as a string rather than a bare
    /// presence check so the cache file is self-describing if inspected by hand.
    result: String,
}

/// Run the project-level Python dependency preflight gate.
///
/// Checks only nodes actually present in `nodes` (i.e. actually configured/selected for this
/// `mecha10 dev` run) - a project with no `simulator` node never needs `mecha10_rl`
/// installed. Returns `Ok(())` when every configured Python-dependent node's dependency
/// imports cleanly (or was skip-checked via a fresh cache hit). Returns `Err` with one
/// combined, actionable message covering *every* failure (not just the first) when any
/// don't - callers should treat this as fatal and stop before spawning any node.
pub async fn run_dependency_preflight(nodes: &[NodeToRun], project_root: &Path) -> Result<(), String> {
    if !nodes.iter().any(|n| n.name == SIMULATOR_NODE_NAME) {
        return Ok(());
    }

    let mut cache = load_cache(project_root);
    let mut failures: Vec<(&'static str, String)> = Vec::new();

    let configured = configured_simulator_python_executable(project_root);
    let resolved = resolve_python_executable(&configured, project_root);
    let venv_mtime_secs = venv_mtime_secs(project_root);

    let cache_hit = cache.entries.get(SIMULATOR_NODE_NAME).is_some_and(|entry| {
        entry.python_path == resolved && entry.venv_mtime_secs == venv_mtime_secs && entry.result == "ok"
    });

    if !cache_hit {
        match check_mecha10_rl_importable(&resolved).await {
            Ok(()) => {
                cache.entries.insert(
                    SIMULATOR_NODE_NAME.to_string(),
                    PreflightCacheEntry {
                        python_path: resolved,
                        venv_mtime_secs,
                        checked_at: chrono::Utc::now().to_rfc3339(),
                        result: "ok".to_string(),
                    },
                );
                save_cache(project_root, &cache);
            }
            Err(reason) => {
                // Don't cache the failure - see PreflightCacheEntry's docs. Do drop any
                // stale "ok" entry so a fixed-then-broken-again environment can't read as
                // cached-good on a future run that happens to share the same mtime.
                cache.entries.remove(SIMULATOR_NODE_NAME);
                save_cache(project_root, &cache);
                failures.push((SIMULATOR_NODE_NAME, reason));
            }
        }
    }

    if failures.is_empty() {
        Ok(())
    } else {
        Err(format_failure_message(&failures))
    }
}

/// Read the `simulator` node's configured `python_executable`, falling back to
/// [`DEFAULT_PYTHON_EXECUTABLE`] when no config file is found or it doesn't override it.
fn configured_simulator_python_executable(project_root: &Path) -> String {
    for candidate in paths::config::node_config_candidates(SIMULATOR_NODE_NAME) {
        let Ok(content) = std::fs::read_to_string(project_root.join(&candidate)) else {
            continue;
        };
        if let Some(python_executable) = parse_python_executable(&content) {
            return python_executable;
        }
    }
    DEFAULT_PYTHON_EXECUTABLE.to_string()
}

/// Minimal, environment-aware extraction of just the `python_executable` field out of a node
/// config.json - mirrors `ops::models::parse_node_config`'s environment-aware (`dev`/
/// `production` keyed) vs. flat config handling, so both stay consistent about the config
/// shapes they accept.
fn parse_python_executable(content: &str) -> Option<String> {
    #[derive(Deserialize)]
    struct PythonExecutableField {
        python_executable: Option<String>,
    }

    #[derive(Deserialize)]
    struct EnvironmentAwareConfig {
        dev: Option<PythonExecutableField>,
        production: Option<PythonExecutableField>,
    }

    if let Ok(env_config) = serde_json::from_str::<EnvironmentAwareConfig>(content) {
        let profile = std::env::var("MECHA10_ENVIRONMENT").unwrap_or_else(|_| "dev".to_string());
        let picked = match profile.as_str() {
            "production" | "prod" => env_config.production,
            _ => env_config.dev,
        };
        if let Some(python_executable) = picked.and_then(|c| c.python_executable) {
            return Some(python_executable);
        }
    }

    serde_json::from_str::<PythonExecutableField>(content)
        .ok()
        .and_then(|c| c.python_executable)
}

/// Cheap "has the project venv changed" signal: the `.venv` directory's own modified time, in
/// whole seconds since the epoch. `None` when no `.venv` exists (e.g. an explicit non-venv
/// `python_executable` override) - a `None` only matches a cached `None`, so switching between
/// "no venv" and "has a venv" always invalidates the cache.
fn venv_mtime_secs(project_root: &Path) -> Option<u64> {
    let modified = std::fs::metadata(project_root.join(".venv")).ok()?.modified().ok()?;
    modified.duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs())
}

fn load_cache(project_root: &Path) -> PreflightCache {
    std::fs::read_to_string(project_root.join(paths::project::DEV_PREFLIGHT_CACHE_FILE))
        .ok()
        .and_then(|content| serde_json::from_str(&content).ok())
        .unwrap_or_default()
}

/// Best-effort cache write - failures here (e.g. read-only filesystem) are silently ignored
/// since the cache is purely a speed optimization, never a correctness dependency.
fn save_cache(project_root: &Path, cache: &PreflightCache) {
    let path = project_root.join(paths::project::DEV_PREFLIGHT_CACHE_FILE);
    let Some(parent) = path.parent() else { return };
    if std::fs::create_dir_all(parent).is_err() {
        return;
    }
    if let Ok(content) = serde_json::to_string_pretty(cache) {
        let _ = std::fs::write(path, content);
    }
}

/// Build one combined, actionable message covering every failed node - never just the first,
/// so a user with multiple missing dependencies sees the full list in one pass instead of
/// fixing them one at a time across repeated `mecha10 dev` runs.
fn format_failure_message(failures: &[(&'static str, String)]) -> String {
    let mut message = String::from("mecha10 dev: missing Python dependencies detected - not starting any node:\n");
    for (node_name, reason) in failures {
        message.push_str(&format!("\n  ✗ {node_name}: {reason}"));
    }
    message.push_str("\n\nRun `mecha10 setup` to install the missing dependencies, then re-run `mecha10 dev`.");
    message
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    fn unique_temp_dir(label: &str) -> std::path::PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "mecha10-dev-preflight-test-{label}-{:?}",
            std::thread::current().id()
        ));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    fn node(name: &str) -> NodeToRun {
        NodeToRun {
            name: name.to_string(),
            identifier: format!("@mecha10/{name}"),
            path: format!("mecha10-nodes-{name}"),
            is_framework_node: true,
        }
    }

    #[tokio::test]
    async fn skips_check_entirely_when_no_python_dependent_node_is_configured() {
        let root = unique_temp_dir("no-simulator");
        let nodes = vec![node("motor"), node("camera")];

        let result = run_dependency_preflight(&nodes, &root).await;
        assert!(result.is_ok());
        // No cache should have been written either - nothing was actually checked.
        assert!(!root.join(paths::project::DEV_PREFLIGHT_CACHE_FILE).exists());

        fs::remove_dir_all(&root).unwrap();
    }

    #[tokio::test]
    async fn fails_fast_with_actionable_message_when_simulator_configured_and_interpreter_missing() {
        let root = unique_temp_dir("missing-interpreter");
        // No .venv, and the default "python3" almost certainly isn't
        // "definitely-not-a-real-interpreter-xyz" - force a deterministic failure by pointing
        // python_executable at a nonexistent interpreter via the node config.
        let config_dir = root.join("configs/nodes/@mecha10/simulator");
        fs::create_dir_all(&config_dir).unwrap();
        fs::write(
            config_dir.join("config.json"),
            r#"{"python_executable": "definitely-not-a-real-interpreter-xyz"}"#,
        )
        .unwrap();

        let nodes = vec![node("simulator")];
        let err = run_dependency_preflight(&nodes, &root)
            .await
            .expect_err("missing interpreter should fail the gate");

        assert!(err.contains("simulator"));
        assert!(err.contains("mecha10 setup"));
        assert!(err.contains("definitely-not-a-real-interpreter-xyz"));

        // A failure must never be cached - see PreflightCacheEntry's docs.
        let cache = load_cache(&root);
        assert!(!cache.entries.contains_key(SIMULATOR_NODE_NAME));

        fs::remove_dir_all(&root).unwrap();
    }

    #[tokio::test]
    async fn caches_a_success_and_reuses_it_on_the_next_call_without_rechecking() {
        let root = unique_temp_dir("cache-reuse");
        // No config override -> resolves to the bare "python3" default (assumed present in
        // this test environment, same assumption the pre-existing python_env.rs tests make
        // implicitly by running in CI containers with Python installed). If "python3" isn't
        // importable-with-mecha10_rl here, this only asserts the cache is populated and
        // reused with a consistent (not necessarily "ok") result - see the second assertion.
        let nodes = vec![node("simulator")];

        let first = run_dependency_preflight(&nodes, &root).await;

        if first.is_ok() {
            let cache = load_cache(&root);
            let entry = cache
                .entries
                .get(SIMULATOR_NODE_NAME)
                .expect("successful check should populate the cache");
            assert_eq!(entry.result, "ok");

            // Second call should hit the cache and short-circuit to Ok without needing
            // mecha10_rl importable again - simulate this by corrupting the interpreter
            // resolution so a real recheck would fail, while leaving the cache untouched.
            let second = run_dependency_preflight(&nodes, &root).await;
            assert!(second.is_ok(), "cache hit should short-circuit to Ok");
        }

        fs::remove_dir_all(&root).unwrap();
    }

    #[test]
    fn configured_simulator_python_executable_falls_back_to_default_without_a_config_file() {
        let root = unique_temp_dir("no-config-file");
        assert_eq!(configured_simulator_python_executable(&root), DEFAULT_PYTHON_EXECUTABLE);
        fs::remove_dir_all(&root).unwrap();
    }

    #[test]
    fn configured_simulator_python_executable_reads_flat_config_override() {
        let root = unique_temp_dir("flat-config");
        let config_dir = root.join("configs/nodes/@mecha10/simulator");
        fs::create_dir_all(&config_dir).unwrap();
        fs::write(
            config_dir.join("config.json"),
            r#"{"python_executable": "/usr/bin/python3.11"}"#,
        )
        .unwrap();

        assert_eq!(configured_simulator_python_executable(&root), "/usr/bin/python3.11");
        fs::remove_dir_all(&root).unwrap();
    }

    #[test]
    fn configured_simulator_python_executable_reads_environment_aware_config() {
        let root = unique_temp_dir("env-aware-config");
        let config_dir = root.join("configs/nodes/@mecha10/simulator");
        fs::create_dir_all(&config_dir).unwrap();
        fs::write(
            config_dir.join("config.json"),
            r#"{"dev": {"python_executable": "/dev/python"}, "production": {"python_executable": "/prod/python"}}"#,
        )
        .unwrap();

        assert_eq!(configured_simulator_python_executable(&root), "/dev/python");
        fs::remove_dir_all(&root).unwrap();
    }

    #[test]
    fn venv_mtime_secs_is_none_without_a_venv_dir() {
        let root = unique_temp_dir("no-venv-mtime");
        assert_eq!(venv_mtime_secs(&root), None);
        fs::remove_dir_all(&root).unwrap();
    }

    #[test]
    fn venv_mtime_secs_is_some_when_venv_dir_exists() {
        let root = unique_temp_dir("has-venv-mtime");
        fs::create_dir_all(root.join(".venv")).unwrap();
        assert!(venv_mtime_secs(&root).is_some());
        fs::remove_dir_all(&root).unwrap();
    }

    #[test]
    fn format_failure_message_lists_every_failure_and_points_at_setup() {
        let failures = vec![
            ("simulator", "module not found".to_string()),
            ("other-node", "boom".to_string()),
        ];
        let message = format_failure_message(&failures);
        assert!(message.contains("simulator: module not found"));
        assert!(message.contains("other-node: boom"));
        assert!(message.contains("mecha10 setup"));
    }
}