diff --git a/Cargo.lock b/Cargo.lock index 2697ec9..c4c1965 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -326,6 +326,8 @@ checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -554,6 +556,7 @@ dependencies = [ "chrono", "clap", "crossterm", + "indexmap", "nix", "ratatui", "ropey", diff --git a/rote/Cargo.toml b/rote/Cargo.toml index 83abb4e..3e4dbb7 100644 --- a/rote/Cargo.toml +++ b/rote/Cargo.toml @@ -21,6 +21,7 @@ nix = { version = "0.28", features = ["signal"] } tokio = { version = "1.37", features = ["macros", "rt-multi-thread", "process", "io-util", "time", "sync"] } ratatui = "0.26" ropey = "1.6" +indexmap = { version = "2.7", features = ["serde"] } serde = { version = "1.0.228", features = ["derive"] } serde_yaml = "0.9.34" shell-words = "1.1.1" diff --git a/rote/src/app.rs b/rote/src/app.rs index aec76dd..dd78e72 100644 --- a/rote/src/app.rs +++ b/rote/src/app.rs @@ -74,12 +74,12 @@ pub async fn run_with_input( let tasks_list = resolve_dependencies(&config, &target_tasks)?; // Create panels for ALL tasks with actions (not just those being started) - // Sort by task name for consistent ordering + // Panels are ordered according to their order in the YAML config file let mut panels = Vec::new(); let mut task_to_panel: HashMap = HashMap::new(); - // Collect and sort task names for deterministic panel order - let mut task_names: Vec<_> = config + // Collect task names, preserving YAML file order (IndexMap preserves insertion order) + let task_names: Vec<_> = config .tasks .iter() .filter(|(_, cfg)| { @@ -90,7 +90,6 @@ pub async fn run_with_input( }) .map(|(name, _)| name.clone()) .collect(); - task_names.sort(); for task_name in &task_names { let task_config = config.tasks.get(task_name).unwrap(); @@ -142,7 +141,7 @@ pub async fn run_with_input( return Ok(()); } - // Initialize status panel with all tasks that have actions (sorted order) + // Initialize status panel with all tasks that have actions (YAML file order) let mut status_panel = StatusPanel::new(); for task_name in &task_names { let task_config = config.tasks.get(task_name).unwrap(); @@ -784,6 +783,92 @@ fn toggle_stream_visibility(panel: &mut Panel, show: bool) { #[cfg(test)] mod tests { use super::*; + use indexmap::IndexMap; + + use crate::config::{CommandValue, TaskConfiguration}; + use std::borrow::Cow; + + /// Helper to extract panel names from a config in the order they would be created. + /// This mirrors the logic in run_with_input for creating panels. + fn get_panel_order(config: &Config) -> Vec { + config + .tasks + .iter() + .filter(|(_, cfg)| { + matches!( + cfg.action, + Some(TaskAction::Run { .. }) | Some(TaskAction::Ensure { .. }) + ) + }) + .map(|(name, _)| name.clone()) + .collect() + } + + #[test] + fn test_panel_order_matches_yaml_order() { + let mut tasks = IndexMap::new(); + tasks.insert( + "third".to_string(), + TaskConfiguration { + action: Some(TaskAction::Run { + command: CommandValue::String(Cow::Borrowed("echo third")), + }), + cwd: None, + display: None, + require: vec![], + autorestart: false, + timestamps: false, + }, + ); + tasks.insert( + "first".to_string(), + TaskConfiguration { + action: Some(TaskAction::Run { + command: CommandValue::String(Cow::Borrowed("echo first")), + }), + cwd: None, + display: None, + require: vec![], + autorestart: false, + timestamps: false, + }, + ); + tasks.insert( + "second".to_string(), + TaskConfiguration { + action: Some(TaskAction::Ensure { + command: CommandValue::String(Cow::Borrowed("echo second")), + }), + cwd: None, + display: None, + require: vec![], + autorestart: false, + timestamps: false, + }, + ); + // Task without action should be excluded from panels + tasks.insert( + "no-action".to_string(), + TaskConfiguration { + action: None, + cwd: None, + display: None, + require: vec!["first".to_string()], + autorestart: false, + timestamps: false, + }, + ); + + let config = Config { + default: None, + tasks, + }; + + let panel_order = get_panel_order(&config); + // Panels should be in insertion order (third, first, second), not alphabetical + // The "no-action" task should be excluded since it has no run/ensure action + assert_eq!(panel_order, vec!["third", "first", "second"]); + } #[test] fn test_visible_len_empty_panel() { @@ -849,7 +934,7 @@ mod tests { fn test_resolve_dependencies_empty() { let config = Config { default: None, - tasks: HashMap::new(), + tasks: IndexMap::new(), }; let result = resolve_dependencies(&config, &[]).unwrap(); assert!(result.is_empty()); @@ -857,7 +942,7 @@ mod tests { #[test] fn test_resolve_dependencies_no_deps() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { @@ -881,7 +966,7 @@ mod tests { #[test] fn test_resolve_dependencies_with_deps() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { @@ -916,7 +1001,7 @@ mod tests { #[test] fn test_resolve_dependencies_multiple_deps() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { @@ -965,7 +1050,7 @@ mod tests { #[test] fn test_resolve_dependencies_nested_deps() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { @@ -1011,7 +1096,7 @@ mod tests { #[test] fn test_resolve_dependencies_circular() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { @@ -1054,7 +1139,7 @@ mod tests { fn test_resolve_dependencies_task_not_found() { let config = Config { default: None, - tasks: HashMap::new(), + tasks: IndexMap::new(), }; let result = resolve_dependencies(&config, &["nonexistent".to_string()]); @@ -1069,7 +1154,7 @@ mod tests { #[test] fn test_resolve_dependencies_dep_not_found() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { @@ -1093,7 +1178,7 @@ mod tests { #[test] fn test_resolve_dependencies_multiple_targets() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { @@ -1143,7 +1228,7 @@ mod tests { #[test] fn test_resolve_dependencies_diamond_graph() { - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); tasks.insert( "task1".to_string(), crate::config::TaskConfiguration { diff --git a/rote/src/config.rs b/rote/src/config.rs index a1db66a..0cad99c 100644 --- a/rote/src/config.rs +++ b/rote/src/config.rs @@ -1,12 +1,13 @@ +use indexmap::IndexMap; use serde::Deserialize; -use std::{borrow::Cow, collections::HashMap}; +use std::borrow::Cow; #[derive(Debug, Deserialize)] pub struct Config { /// The default task to run when none is specified. pub default: Option, - /// A mapping of task names to their configurations. - pub tasks: HashMap, + /// A mapping of task names to their configurations (preserves YAML order). + pub tasks: IndexMap, } #[derive(Debug, Deserialize)] @@ -152,6 +153,44 @@ mod tests { ); } + #[test] + fn test_task_order_preserved_from_yaml() { + let yaml = r#" +default: main +tasks: + first: + run: echo first + second: + run: echo second + third: + run: echo third + fourth: + ensure: true +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let task_names: Vec<_> = config.tasks.keys().collect(); + assert_eq!(task_names, vec!["first", "second", "third", "fourth"]); + } + + #[test] + fn test_example_yaml_task_order() { + let config = load_config(); + let task_names: Vec<_> = config.tasks.keys().collect(); + // Tasks should be in the order they appear in example.yaml + assert_eq!( + task_names, + vec![ + "google-ping", + "cloudflare-ping", + "github-ping", + "short-lived", + "auto-restarting", + "setup-task", + "ping-demo" + ] + ); + } + #[test] fn test_missing_optional_fields() { let yaml = r#" diff --git a/rote/src/task_manager.rs b/rote/src/task_manager.rs index b51455c..3401978 100644 --- a/rote/src/task_manager.rs +++ b/rote/src/task_manager.rs @@ -130,10 +130,11 @@ pub fn resolve_dependencies(config: &Config, targets: &[String]) -> Result, Vec<&str>)>) -> Config { - let mut task_map = HashMap::new(); + let mut task_map = IndexMap::new(); for (name, action, require) in tasks { task_map.insert( name.to_string(), diff --git a/rote/tests/integration_test.rs b/rote/tests/integration_test.rs index b0e89e4..ff1068d 100644 --- a/rote/tests/integration_test.rs +++ b/rote/tests/integration_test.rs @@ -1,6 +1,6 @@ +use indexmap::IndexMap; use rote_mux::panel::PanelIndex; use rote_mux::{Config, UiEvent}; -use std::collections::HashMap; use std::time::Duration; use tokio::time::timeout; @@ -100,7 +100,7 @@ async fn test_ensure_dependency_blocks_until_complete() { use rote_mux::config::{CommandValue, TaskAction, TaskConfiguration}; use std::borrow::Cow; - let mut tasks = HashMap::new(); + let mut tasks = IndexMap::new(); // An Ensure task that completes quickly tasks.insert(