use super::{InteractiveModeConfig, InteractiveModeState};
use crate::context::CliContext;
use crate::dev::lifecycle_adapter::CliLifecycleManager;
use crate::dev::termination::ScreenOutcome;
use crate::handlers::dev::ops::lifecycle::handle_mode_change;
use crate::handlers::dev::ops::teleop::enter_teleop_mode;
use crate::ui::DevModeTui;
use anyhow::Result;
use crossterm::execute;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use std::collections::HashMap;
use std::io::{stdout, Stdout, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
type DevTerminal = Terminal<CrosstermBackend<Stdout>>;
fn resolve_teleop_failure(
start_failures: Vec<(String, String)>,
last_known_failures: &HashMap<String, String>,
) -> Option<(String, String)> {
start_failures
.into_iter()
.find(|(name, _)| super::super::strip_node_scope(name) == "teleop")
.or_else(|| {
last_known_failures
.iter()
.find(|(name, _)| super::super::strip_node_scope(name) == "teleop")
.map(|(name, err)| (name.clone(), err.clone()))
})
}
#[derive(Debug, PartialEq, Eq)]
enum SimulationKeyAction {
NoLifecycleConfig,
ModeNotConfigured,
SwitchToSimulation,
}
fn resolve_simulation_key_action(lifecycle: Option<&CliLifecycleManager>) -> SimulationKeyAction {
match lifecycle {
None => SimulationKeyAction::NoLifecycleConfig,
Some(lifecycle_mgr) if lifecycle_mgr.available_modes().contains(&"simulation") => {
SimulationKeyAction::SwitchToSimulation
}
Some(_) => SimulationKeyAction::ModeNotConfigured,
}
}
fn resolve_stop_simulation_target(lifecycle: Option<&CliLifecycleManager>) -> Option<String> {
const TARGET_MODE: &str = "dev";
let lifecycle_mgr = lifecycle?;
lifecycle_mgr
.available_modes()
.contains(&TARGET_MODE)
.then(|| TARGET_MODE.to_string())
}
fn apply_standalone_teleop_outcome(outcome: ScreenOutcome, running: &Arc<AtomicBool>) {
if outcome == ScreenOutcome::QuitAll {
running.store(false, Ordering::SeqCst);
}
}
fn refresh_status(state: &InteractiveModeState, dev_tui: &mut DevModeTui) {
let current_mode = state
.lifecycle
.as_ref()
.map(|l| l.current_mode().to_string())
.unwrap_or_else(|| "startup".to_string());
let running_nodes: Vec<String> = state.node_registry.running_node_names();
dev_tui.update_status(current_mode, running_nodes);
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn handle_simulation_key(
ctx: &mut CliContext,
config: &InteractiveModeConfig<'_>,
state: &mut InteractiveModeState,
terminal: &mut DevTerminal,
dev_tui: &mut DevModeTui,
redis_url: &str,
running: &Arc<AtomicBool>,
) -> Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
print!("\x1B[2J\x1B[1;1H");
stdout().flush()?;
println!("╔════════════════════════════════════════════════════════════╗");
println!("║ SWITCHING TO SIMULATION MODE ║");
println!("╚════════════════════════════════════════════════════════════╝");
println!();
match resolve_simulation_key_action(state.lifecycle.as_ref()) {
SimulationKeyAction::SwitchToSimulation => {
let lifecycle_mgr = state
.lifecycle
.as_mut()
.expect("SwitchToSimulation is only returned when lifecycle is configured");
let start_failures = handle_mode_change(
ctx,
lifecycle_mgr,
"simulation",
&mut state.node_registry,
config.project_name,
config.project_config,
config.nodes_to_run,
Some(&state.log_buffer),
)
.await?;
state.node_registry.record_failures(&start_failures);
let teleop_failure = resolve_teleop_failure(start_failures, state.node_registry.last_known_failures());
let outcome = enter_teleop_mode(
config.project_config,
&state.node_registry,
redis_url,
running,
&state.log_buffer,
teleop_failure.as_ref(),
)
.await?;
match outcome {
ScreenOutcome::ReturnToParent => {
handle_stop_simulation_key(ctx, config, state, terminal, dev_tui).await?;
return Ok(());
}
ScreenOutcome::QuitAll => {
running.store(false, Ordering::SeqCst);
}
ScreenOutcome::Continue => {}
}
}
SimulationKeyAction::ModeNotConfigured => {
println!("⚠️ No 'simulation' target configured in targets");
state
.log_buffer
.push("MODE: No 'simulation' target in targets".to_string());
}
SimulationKeyAction::NoLifecycleConfig => {
println!("❌ Target configuration is required. Please add a 'targets' section to mecha10.json");
state.log_buffer.push("MODE: No targets config found".to_string());
}
}
enable_raw_mode()?;
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
terminal.clear()?;
refresh_status(state, dev_tui);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn handle_teleop_key(
ctx: &mut CliContext,
config: &InteractiveModeConfig<'_>,
state: &mut InteractiveModeState,
terminal: &mut DevTerminal,
dev_tui: &mut DevModeTui,
redis_url: &str,
running: &Arc<AtomicBool>,
) -> Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
print!("\x1B[2J\x1B[1;1H");
stdout().flush()?;
println!("╔════════════════════════════════════════════════════════════╗");
println!("║ SWITCHING TO TELEOP MODE ║");
println!("╚════════════════════════════════════════════════════════════╝");
println!();
if let Some(ref mut lifecycle_mgr) = state.lifecycle {
if lifecycle_mgr.available_modes().contains(&"teleop") {
let start_failures = handle_mode_change(
ctx,
lifecycle_mgr,
"teleop",
&mut state.node_registry,
config.project_name,
config.project_config,
config.nodes_to_run,
Some(&state.log_buffer),
)
.await?;
state.node_registry.record_failures(&start_failures);
let teleop_failure = resolve_teleop_failure(start_failures, state.node_registry.last_known_failures());
let outcome = enter_teleop_mode(
config.project_config,
&state.node_registry,
redis_url,
running,
&state.log_buffer,
teleop_failure.as_ref(),
)
.await?;
apply_standalone_teleop_outcome(outcome, running);
} else {
let teleop_failure = state
.node_registry
.last_known_failures()
.iter()
.find(|(name, _)| super::super::strip_node_scope(name) == "teleop")
.map(|(name, err)| (name.clone(), err.clone()));
let outcome = enter_teleop_mode(
config.project_config,
&state.node_registry,
redis_url,
running,
&state.log_buffer,
teleop_failure.as_ref(),
)
.await?;
apply_standalone_teleop_outcome(outcome, running);
}
} else {
println!("❌ Lifecycle configuration is required. Please add a 'lifecycle' section to mecha10.json");
}
enable_raw_mode()?;
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
terminal.clear()?;
refresh_status(state, dev_tui);
Ok(())
}
pub(super) async fn handle_stop_simulation_key(
ctx: &mut CliContext,
config: &InteractiveModeConfig<'_>,
state: &mut InteractiveModeState,
terminal: &mut DevTerminal,
dev_tui: &mut DevModeTui,
) -> Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
print!("\x1B[2J\x1B[1;1H");
stdout().flush()?;
println!("╔════════════════════════════════════════════════════════════╗");
println!("║ STOPPING SIMULATION MODE ║");
println!("╚════════════════════════════════════════════════════════════╝");
println!();
state.log_buffer.push("MODE: Stopping simulation mode...".to_string());
if let Some(target_mode) = resolve_stop_simulation_target(state.lifecycle.as_ref()) {
let lifecycle_mgr = state
.lifecycle
.as_mut()
.expect("resolve_stop_simulation_target only returns Some when lifecycle is configured");
let start_failures = handle_mode_change(
ctx,
lifecycle_mgr,
&target_mode,
&mut state.node_registry,
config.project_name,
config.project_config,
config.nodes_to_run,
Some(&state.log_buffer),
)
.await?;
state.node_registry.record_failures(&start_failures);
}
enable_raw_mode()?;
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
terminal.clear()?;
refresh_status(state, dev_tui);
Ok(())
}
#[cfg(test)]
mod resolve_teleop_failure_tests {
use super::*;
#[test]
fn prefers_this_keypress_own_fresh_failure() {
let start_failures = vec![("@mecha10/teleop".to_string(), "fresh error".to_string())];
let mut last_known_failures = HashMap::new();
last_known_failures.insert("@mecha10/teleop".to_string(), "stale error".to_string());
let resolved = resolve_teleop_failure(start_failures, &last_known_failures);
assert_eq!(
resolved,
Some(("@mecha10/teleop".to_string(), "fresh error".to_string()))
);
}
#[test]
fn falls_back_to_last_known_failure_when_this_keypress_did_not_touch_teleop() {
let start_failures = vec![("@mecha10/simulator".to_string(), "unrelated failure".to_string())];
let mut last_known_failures = HashMap::new();
last_known_failures.insert(
"@mecha10/teleop".to_string(),
"binary download failed: HTTP 404".to_string(),
);
let resolved = resolve_teleop_failure(start_failures, &last_known_failures);
assert_eq!(
resolved,
Some((
"@mecha10/teleop".to_string(),
"binary download failed: HTTP 404".to_string()
))
);
}
#[test]
fn none_when_neither_this_keypress_nor_history_has_a_teleop_failure() {
let resolved = resolve_teleop_failure(Vec::new(), &HashMap::new());
assert_eq!(resolved, None);
}
#[test]
fn matches_scoped_key_against_bare_teleop_lookup_in_start_failures() {
let start_failures = vec![("@mecha10/teleop".to_string(), "binary download failed".to_string())];
let resolved = resolve_teleop_failure(start_failures, &HashMap::new());
assert_eq!(
resolved,
Some(("@mecha10/teleop".to_string(), "binary download failed".to_string()))
);
}
}
#[cfg(test)]
mod simulation_key_branch_tests {
use super::*;
use crate::types::project::{NodesConfig, ProjectConfig, RobotConfig, TargetEntry, TargetsConfig};
use std::collections::HashMap as StdHashMap;
fn project_config_with_targets(targets: TargetsConfig) -> ProjectConfig {
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: None,
nodes: NodesConfig(StdHashMap::new()),
targets,
behaviors: None,
docker: Default::default(),
environments: Default::default(),
actuator: Default::default(),
}
}
fn targets_config(mode_names: &[&str]) -> TargetsConfig {
let has = |name: &str| mode_names.contains(&name);
let entry = || Some(TargetEntry::default());
TargetsConfig {
dev: if has("dev") { entry() } else { None },
simulation: if has("simulation") { entry() } else { None },
robot: if has("robot") { entry() } else { None },
remote: if has("remote") { entry() } else { None },
rl: if has("rl") { entry() } else { None },
}
}
fn lifecycle_manager(mode_names: &[&str]) -> CliLifecycleManager {
let config = project_config_with_targets(targets_config(mode_names));
CliLifecycleManager::from_project_config(&config).expect("targets section is present")
}
#[test]
fn simulation_key_reports_missing_lifecycle_config_when_none_configured() {
let action = resolve_simulation_key_action(None);
assert_eq!(action, SimulationKeyAction::NoLifecycleConfig);
}
#[test]
fn simulation_key_reports_mode_not_configured_when_simulation_mode_absent() {
let lifecycle_mgr = lifecycle_manager(&["dev", "robot"]);
let action = resolve_simulation_key_action(Some(&lifecycle_mgr));
assert_eq!(action, SimulationKeyAction::ModeNotConfigured);
}
#[test]
fn simulation_key_switches_when_simulation_mode_is_available() {
let lifecycle_mgr = lifecycle_manager(&["dev", "simulation"]);
let action = resolve_simulation_key_action(Some(&lifecycle_mgr));
assert_eq!(action, SimulationKeyAction::SwitchToSimulation);
}
#[test]
fn stop_simulation_key_is_a_no_op_when_no_lifecycle_config() {
let target = resolve_stop_simulation_target(None);
assert_eq!(target, None, "nothing to switch back to without a lifecycle config");
}
#[test]
fn stop_simulation_key_is_a_no_op_when_dev_not_available() {
let lifecycle_mgr = lifecycle_manager(&["simulation"]);
let target = resolve_stop_simulation_target(Some(&lifecycle_mgr));
assert_eq!(target, None);
}
#[test]
fn stop_simulation_key_targets_dev() {
let lifecycle_mgr = lifecycle_manager(&["dev", "simulation"]);
let target = resolve_stop_simulation_target(Some(&lifecycle_mgr));
assert_eq!(target, Some("dev".to_string()));
}
#[test]
fn standalone_teleop_return_to_parent_does_not_touch_running_flag() {
let running = Arc::new(AtomicBool::new(true));
apply_standalone_teleop_outcome(ScreenOutcome::ReturnToParent, &running);
assert!(
running.load(Ordering::SeqCst),
"backing out of standalone teleop must not quit `mecha10 dev`"
);
}
#[test]
fn standalone_teleop_quit_all_clears_running_flag() {
let running = Arc::new(AtomicBool::new(true));
apply_standalone_teleop_outcome(ScreenOutcome::QuitAll, &running);
assert!(
!running.load(Ordering::SeqCst),
"Ctrl+C inside standalone teleop must propagate to the outer dev-mode loop's quit flag"
);
}
#[test]
fn standalone_teleop_continue_does_not_touch_running_flag() {
let running = Arc::new(AtomicBool::new(true));
apply_standalone_teleop_outcome(ScreenOutcome::Continue, &running);
assert!(running.load(Ordering::SeqCst));
}
}