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;
const SIMULATOR_NODE_NAME: &str = "simulator";
#[derive(Debug, Default, Serialize, Deserialize)]
struct PreflightCache {
#[serde(default)]
entries: HashMap<String, PreflightCacheEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PreflightCacheEntry {
python_path: String,
venv_mtime_secs: Option<u64>,
checked_at: String,
result: String,
}
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) => {
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))
}
}
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()
}
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)
}
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()
}
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);
}
}
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());
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");
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"));
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");
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");
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"));
}
}