use crate::dev::node_selection::NodeToRun;
use crate::paths;
use crate::sim::python_env::{check_mecha10_rl_importable, resolve_python_executable, DEFAULT_PYTHON_EXECUTABLE};
use crate::types::ProjectConfig;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::time::UNIX_EPOCH;
const SIMULATOR_NODE_NAME: &str = "simulator";
const DEFAULT_ENVIRONMENT_NAME: &str = "basic_arena";
#[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,
project_config: &ProjectConfig,
schemas_dir: Option<&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 let Err(reason) = check_simulation_preset(project_root, project_config, schemas_dir) {
failures.push((SIMULATOR_NODE_NAME, reason));
}
if failures.is_empty() {
Ok(())
} else {
Err(format_failure_message(&failures))
}
}
fn check_simulation_preset(
project_root: &Path,
project_config: &ProjectConfig,
schemas_dir: Option<&Path>,
) -> Result<(), String> {
let Some(preset_name) = project_config.simulation.as_ref().and_then(|sim| sim.preset.as_deref()) else {
return Ok(());
};
let environment_name = project_config
.simulation
.as_ref()
.and_then(|sim| sim.environment.clone())
.unwrap_or_else(|| DEFAULT_ENVIRONMENT_NAME.to_string());
let preset_path = project_root
.join(paths::project::SIMULATION_ENVIRONMENTS_DIR)
.join(&environment_name)
.join("presets")
.join(preset_name)
.join("preset.json");
let Ok(preset_content) = std::fs::read_to_string(&preset_path) else {
return Err(format!(
"mecha10.json's simulation.preset references preset '{preset_name}' (environment \
'{environment_name}'), but {} does not exist",
preset_path.display()
));
};
let preset_value: serde_json::Value = serde_json::from_str(&preset_content)
.map_err(|e| format!("{} is not valid JSON: {e}", preset_path.display()))?;
let schemas_dir = schemas_dir.ok_or_else(|| {
format!(
"No mecha10-assets schemas directory available to validate {} against",
preset_path.display()
)
})?;
crate::config_validation::validate_preset_config_json(&preset_value, schemas_dir)
.map_err(|e| format!("{} fails preset-config schema validation: {e}", preset_path.display()))
}
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 schemas_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/schemas")
}
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,
}
}
fn project_config(environment: Option<&str>, preset: Option<&str>) -> ProjectConfig {
use crate::types::project::{NodesConfig, ProjectSimulationConfig, RobotConfig};
ProjectConfig {
name: "test".to_string(),
version: "0.1.0".to_string(),
template_version: None,
robot: RobotConfig {
id: "test".to_string(),
name: "test".to_string(),
platform: None,
description: None,
},
simulation: Some(ProjectSimulationConfig {
scenario: None,
model: None,
environment: environment.map(str::to_string),
preset: preset.map(str::to_string),
}),
nodes: NodesConfig::new(),
targets: Default::default(),
behaviors: None,
docker: Default::default(),
environments: Default::default(),
actuator: Default::default(),
}
}
#[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, &project_config(None, None), None).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, &project_config(None, None), None)
.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, &project_config(None, None), None).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, &project_config(None, None), None).await;
assert!(second.is_ok(), "cache hit should short-circuit to Ok");
}
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn check_simulation_preset_is_a_noop_without_a_configured_preset() {
let root = unique_temp_dir("preset-not-configured");
assert!(check_simulation_preset(&root, &project_config(None, None), None).is_ok());
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn check_simulation_preset_fails_actionably_when_the_referenced_preset_is_missing() {
let root = unique_temp_dir("preset-missing");
let err = check_simulation_preset(&root, &project_config(None, Some("cats")), None)
.expect_err("a simulation.preset referencing a nonexistent preset should fail the gate");
assert!(err.contains("cats"));
assert!(err.contains("basic_arena"));
assert!(err.contains("does not exist"));
}
#[test]
fn check_simulation_preset_resolves_against_the_project_configured_environment() {
let root = unique_temp_dir("preset-custom-env");
let preset_dir = root.join("configs/simulation/environments/custom_env/presets/cats");
fs::create_dir_all(&preset_dir).unwrap();
fs::write(
preset_dir.join("preset.json"),
r#"{"id": "cats", "version": "1.0.0", "objects": []}"#,
)
.unwrap();
assert!(check_simulation_preset(
&root,
&project_config(Some("custom_env"), Some("cats")),
Some(&schemas_dir())
)
.is_ok());
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn check_simulation_preset_fails_when_the_preset_file_fails_schema_validation() {
let root = unique_temp_dir("preset-invalid-schema");
let preset_dir = root.join("configs/simulation/environments/basic_arena/presets/cats");
fs::create_dir_all(&preset_dir).unwrap();
fs::write(preset_dir.join("preset.json"), r#"{"id": "cats"}"#).unwrap();
let err = check_simulation_preset(&root, &project_config(None, Some("cats")), Some(&schemas_dir()))
.expect_err("a preset.json failing schema validation should fail the gate");
assert!(err.contains("schema validation"));
}
#[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"));
}
}