Skip to main content

oo_ide/
task_config.rs

1//! Task configuration file format — `.oo/tasks.yaml`.
2//!
3//! Defines the types, parser, and validator for user-defined task
4//! definitions.  Each task is a named unit of execution that can be a shell
5//! command, an ordered sequence, or an unordered parallel composition of
6//! other tasks.
7//!
8//! # File format
9//!
10//! ```yaml
11//! version: 1
12//!
13//! tasks:
14//!   build:
15//!     type: shell
16//!     queue: build
17//!     command: "cargo build --{mode}"
18//!     inputs:
19//!       mode:
20//!         type: select
21//!         options: [debug, release]
22//!         default: debug
23//!
24//!   check:
25//!     type: parallel
26//!     steps:
27//!       - build
28//!       - lint
29//! ```
30//!
31//! # Validation
32//!
33//! [`parse_str`] and [`load`] both run a post-parse validation pass that
34//! checks:
35//!
36//! * `version == 1`
37//! * All step references point to existing tasks
38//! * Step input-overrides reference declared inputs on the target task
39//! * Shell command `{placeholder}` interpolations match declared inputs
40//! * `select` inputs declare a non-empty `options` list; `default` must be
41//!   one of the options
42//! * `text` / `number` validation bounds are consistent; regex patterns
43//!   compile
44//! * No cyclic dependencies in the task graph
45
46use std::collections::{HashMap, HashSet, VecDeque};
47use std::path::Path;
48
49use once_cell::sync::Lazy;
50use regex::Regex;
51use serde::Deserialize;
52
53// ---------------------------------------------------------------------------
54// Static helpers
55// ---------------------------------------------------------------------------
56
57/// Matches `{identifier}` interpolation placeholders inside shell commands.
58static INTERPOLATION_RE: Lazy<Regex> =
59    Lazy::new(|| Regex::new(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}").unwrap());
60
61// ---------------------------------------------------------------------------
62// Top-level file structure
63// ---------------------------------------------------------------------------
64
65/// Parsed and validated contents of a `tasks.yaml` file.
66#[derive(Debug, Clone, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct TasksFile {
69    /// Must be `1`.
70    pub version: u32,
71    /// Map of task ID → task definition.
72    pub tasks: HashMap<String, TaskDef>,
73}
74
75impl TasksFile {
76    /// Return the definition for `task_id`, if it exists.
77    pub fn get(&self, task_id: &str) -> Option<&TaskDef> {
78        self.tasks.get(task_id)
79    }
80
81    /// Iterate over all `(task_id, task_def)` pairs.
82    pub fn iter(&self) -> impl Iterator<Item = (&str, &TaskDef)> {
83        self.tasks.iter().map(|(k, v)| (k.as_str(), v))
84    }
85}
86
87// ---------------------------------------------------------------------------
88// Task definitions
89// ---------------------------------------------------------------------------
90
91/// A single task definition.  The `type` field selects the variant.
92#[derive(Debug, Clone, Deserialize)]
93#[serde(tag = "type", rename_all = "snake_case")]
94pub enum TaskDef {
95    /// Executes a shell command.
96    Shell(ShellTaskDef),
97    /// Runs steps in insertion order, waiting for each to finish.
98    Sequence(CompositeTaskDef),
99    /// Runs steps without ordering constraints.
100    Parallel(CompositeTaskDef),
101}
102
103impl TaskDef {
104    /// Returns optional UI metadata, regardless of variant.
105    pub fn ui(&self) -> Option<&UiMeta> {
106        match self {
107            TaskDef::Shell(s) => s.ui.as_ref(),
108            TaskDef::Sequence(c) | TaskDef::Parallel(c) => c.ui.as_ref(),
109        }
110    }
111}
112
113// ---------------------------------------------------------------------------
114// Shell task
115// ---------------------------------------------------------------------------
116
117/// A task that runs a shell command.
118#[derive(Debug, Clone, Deserialize)]
119#[serde(deny_unknown_fields)]
120pub struct ShellTaskDef {
121    /// Shell command template; may contain `{input_name}` placeholders.
122    pub command: String,
123    /// Queue name that serialises this task against other tasks on the same
124    /// queue.  If omitted, the task ID is used as its own singleton queue.
125    #[serde(default)]
126    pub queue: Option<String>,
127    /// Cancellation policy when a new task is scheduled onto the same queue.
128    #[serde(default)]
129    pub cancel: Option<CancelPolicy>,
130    /// Declared parameterisable inputs.
131    #[serde(default)]
132    pub inputs: Option<HashMap<String, InputDef>>,
133    /// Optional UI display metadata.
134    #[serde(default)]
135    pub ui: Option<UiMeta>,
136}
137
138impl ShellTaskDef {
139    /// Resolve the effective queue name: explicit `queue` field or fall back
140    /// to `task_id`.
141    pub fn effective_queue<'a>(&'a self, task_id: &'a str) -> &'a str {
142        self.queue.as_deref().unwrap_or(task_id)
143    }
144}
145
146/// When a new task is scheduled on the same queue, how to handle the current
147/// one.
148#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum CancelPolicy {
151    /// Cancel all currently running or queued tasks on the same queue.
152    Queue,
153    /// Do not cancel; let the existing task finish first.
154    None,
155}
156
157// ---------------------------------------------------------------------------
158// Composite tasks (sequence / parallel)
159// ---------------------------------------------------------------------------
160
161/// Shared structure for `sequence` and `parallel` tasks.
162#[derive(Debug, Clone, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct CompositeTaskDef {
165    /// Ordered list of steps to execute.
166    pub steps: Vec<StepRef>,
167    /// If `true`, continue executing remaining steps after a step fails.
168    /// Defaults to `false`.
169    #[serde(default)]
170    pub continue_on_error: bool,
171    /// Optional UI display metadata.
172    #[serde(default)]
173    pub ui: Option<UiMeta>,
174}
175
176// ---------------------------------------------------------------------------
177// Step references
178// ---------------------------------------------------------------------------
179
180/// A reference to a task inside a `steps` list.
181///
182/// Either a plain task ID or a task ID with input overrides:
183///
184/// ```yaml
185/// steps:
186///   - build             # simple reference
187///   - build:            # reference with overrides
188///       mode: release
189/// ```
190#[derive(Debug, Clone, Deserialize)]
191#[serde(untagged)]
192pub enum StepRef {
193    /// Plain task name.
194    Simple(String),
195    /// `{ task_name: { input_key: value, … } }`
196    WithOverrides(HashMap<String, HashMap<String, InputValue>>),
197}
198
199impl StepRef {
200    /// The task ID this step references.
201    pub fn task_id(&self) -> Option<&str> {
202        match self {
203            Self::Simple(s) => Some(s.as_str()),
204            Self::WithOverrides(map) => map.keys().next().map(String::as_str),
205        }
206    }
207
208    /// Input overrides, if any were specified.
209    pub fn overrides(&self) -> Option<&HashMap<String, InputValue>> {
210        match self {
211            Self::Simple(_) => None,
212            Self::WithOverrides(map) => map.values().next(),
213        }
214    }
215}
216
217// ---------------------------------------------------------------------------
218// Inputs
219// ---------------------------------------------------------------------------
220
221/// A runtime-configurable input value for a task.
222#[derive(Debug, Clone)]
223pub enum InputValue {
224    Bool(bool),
225    Number(f64),
226    Str(String),
227}
228
229impl<'de> serde::Deserialize<'de> for InputValue {
230    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
231        use serde::de::Visitor;
232        struct V;
233        impl<'de> Visitor<'de> for V {
234            type Value = InputValue;
235            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
236                write!(f, "a boolean, number, or string")
237            }
238            fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<InputValue, E> {
239                Ok(InputValue::Bool(v))
240            }
241            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<InputValue, E> {
242                Ok(InputValue::Number(v as f64))
243            }
244            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<InputValue, E> {
245                Ok(InputValue::Number(v as f64))
246            }
247            fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<InputValue, E> {
248                Ok(InputValue::Number(v))
249            }
250            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<InputValue, E> {
251                Ok(InputValue::Str(v.to_string()))
252            }
253            fn visit_string<E: serde::de::Error>(self, v: String) -> Result<InputValue, E> {
254                Ok(InputValue::Str(v))
255            }
256        }
257        d.deserialize_any(V)
258    }
259}
260
261impl InputValue {
262    /// Coerce to a string suitable for command interpolation.
263    pub fn to_string_value(&self) -> String {
264        match self {
265            InputValue::Str(s) => s.clone(),
266            InputValue::Number(n) => n.to_string(),
267            InputValue::Bool(b) => b.to_string(),
268        }
269    }
270
271    /// Return the inner `&str` if this is a string variant.
272    pub fn as_str(&self) -> Option<&str> {
273        match self {
274            InputValue::Str(s) => Some(s.as_str()),
275            _ => None,
276        }
277    }
278}
279
280/// Declaration of a single parameterisable input.
281#[derive(Debug, Clone, Deserialize)]
282#[serde(deny_unknown_fields)]
283pub struct InputDef {
284    /// Data type of this input.
285    #[serde(rename = "type")]
286    pub input_type: InputType,
287    /// Default value applied when the user does not override the input.
288    #[serde(default)]
289    pub default: Option<InputValue>,
290    /// Hint text shown in UI input fields.
291    #[serde(default)]
292    pub placeholder: Option<String>,
293    /// Allowed values for `select` inputs.
294    #[serde(default)]
295    pub options: Option<Vec<String>>,
296    /// Optional validation constraints.
297    #[serde(default)]
298    pub validate: Option<ValidationRules>,
299}
300
301/// Data type of a task input.
302#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
303#[serde(rename_all = "snake_case")]
304pub enum InputType {
305    Text,
306    Select,
307    Boolean,
308    Number,
309}
310
311/// Validation constraints for an input.
312///
313/// Only the fields applicable to the input's [`InputType`] are used; the
314/// rest are ignored.
315#[derive(Debug, Clone, Deserialize)]
316#[serde(deny_unknown_fields)]
317pub struct ValidationRules {
318    /// Minimum string length (for `text` inputs).
319    #[serde(default)]
320    pub min_length: Option<usize>,
321    /// Maximum string length (for `text` inputs).
322    #[serde(default)]
323    pub max_length: Option<usize>,
324    /// Regular expression the value must match (for `text` inputs).
325    #[serde(default)]
326    pub pattern: Option<String>,
327    /// Minimum numeric value (for `number` inputs).
328    /// Accepts both integers and floating-point in YAML.
329    #[serde(default, deserialize_with = "de_opt_number")]
330    pub min: Option<f64>,
331    /// Maximum numeric value (for `number` inputs).
332    /// Accepts both integers and floating-point in YAML.
333    #[serde(default, deserialize_with = "de_opt_number")]
334    pub max: Option<f64>,
335}
336
337/// Deserialise an optional number field, accepting both YAML integers and
338/// floats (e.g. `100` or `1.5`).
339fn de_opt_number<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
340    use serde::de::Visitor;
341    struct V;
342    impl<'de> Visitor<'de> for V {
343        type Value = f64;
344        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
345            write!(f, "a number")
346        }
347        fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<f64, E> {
348            Ok(v as f64)
349        }
350        fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<f64, E> {
351            Ok(v as f64)
352        }
353        fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<f64, E> {
354            Ok(v)
355        }
356    }
357    d.deserialize_any(V).map(Some)
358}
359
360// ---------------------------------------------------------------------------
361// UI metadata
362// ---------------------------------------------------------------------------
363
364/// Optional human-readable display information for a task.
365#[derive(Debug, Clone, Deserialize)]
366#[serde(deny_unknown_fields)]
367pub struct UiMeta {
368    /// Display title.  Defaults to `"Run {task_id}"`.
369    #[serde(default)]
370    pub title: Option<String>,
371    /// Short description shown in the command palette.
372    #[serde(default)]
373    pub description: Option<String>,
374    /// Grouping category for the command palette.
375    #[serde(default)]
376    pub category: Option<String>,
377}
378
379// ---------------------------------------------------------------------------
380// Error type
381// ---------------------------------------------------------------------------
382
383/// A validation error produced while loading or validating a `tasks.yaml`.
384#[derive(Debug, Clone)]
385pub struct ConfigError {
386    /// Dot-separated path to the field that caused the error
387    /// (e.g. `"tasks.build.inputs.mode.options"`).
388    pub path: String,
389    /// Human-readable description of the problem.
390    pub message: String,
391}
392
393impl ConfigError {
394    fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
395        Self { path: path.into(), message: message.into() }
396    }
397}
398
399impl std::fmt::Display for ConfigError {
400    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
401        if self.path.is_empty() {
402            write!(f, "{}", self.message)
403        } else {
404            write!(f, "{}: {}", self.path, self.message)
405        }
406    }
407}
408
409// ---------------------------------------------------------------------------
410// Loading
411// ---------------------------------------------------------------------------
412
413/// Load and validate `tasks.yaml` from the given path.
414///
415/// * Returns `Ok(None)` if the file does not exist — a missing tasks file is
416///   not an error.
417/// * Returns `Ok(Some(file))` if parsing and validation both succeed.
418/// * Returns `Err(errors)` with one or more [`ConfigError`]s on failure.
419pub fn load(path: &Path) -> Result<Option<TasksFile>, Vec<ConfigError>> {
420    let content = match std::fs::read_to_string(path) {
421        Ok(s) => s,
422        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
423        Err(e) => {
424            return Err(vec![ConfigError::new(
425                "",
426                format!("could not read {path:?}: {e}"),
427            )]);
428        }
429    };
430
431    parse_str(&content).map(Some)
432}
433
434/// Parse and validate a YAML string containing task configuration.
435///
436/// Returns `Err(errors)` if either the YAML is malformed or semantic
437/// validation fails.
438pub fn parse_str(content: &str) -> Result<TasksFile, Vec<ConfigError>> {
439    // Run serde_saphyr on a dedicated thread with a larger stack to avoid
440    // stack-overflow on platforms (notably Windows) where the main thread
441    // stack may be small and serde-generated deserializers are deeply
442    // recursive.
443    let owned = content.to_string();
444    let handle = std::thread::Builder::new()
445        .name("task_config_parser".into())
446        .stack_size(8 * 1024 * 1024)
447        .spawn(move || {
448            match serde_saphyr::from_str::<TasksFile>(&owned) {
449                Ok(parsed) => {
450                    let errors = validate(&parsed);
451                    if errors.is_empty() {
452                        Ok(parsed)
453                    } else {
454                        Err(errors)
455                    }
456                }
457                Err(e) => Err(vec![ConfigError::new("", e.to_string())]),
458            }
459        });
460
461    let handle = match handle {
462        Ok(h) => h,
463        Err(e) => return Err(vec![ConfigError::new("", format!("failed to spawn parser thread: {}", e))]),
464    };
465
466    match handle.join() {
467        Ok(res) => res,
468        Err(_e) => Err(vec![ConfigError::new("", "parser thread panicked".to_string())]),
469    }
470}
471
472// ---------------------------------------------------------------------------
473// Validation
474// ---------------------------------------------------------------------------
475
476fn validate(file: &TasksFile) -> Vec<ConfigError> {
477    let mut errors = Vec::new();
478
479    if file.version != 1 {
480        errors.push(ConfigError::new(
481            "version",
482            format!("expected 1, got {}", file.version),
483        ));
484    }
485
486    for (task_id, task_def) in &file.tasks {
487        validate_task(file, task_id, task_def, &mut errors);
488    }
489
490    detect_cycles(file, &mut errors);
491
492    errors
493}
494
495fn validate_task(
496    file: &TasksFile,
497    task_id: &str,
498    task_def: &TaskDef,
499    errors: &mut Vec<ConfigError>,
500) {
501    match task_def {
502        TaskDef::Shell(shell) => validate_shell(task_id, shell, errors),
503        TaskDef::Sequence(seq) => validate_composite(file, task_id, &seq.steps, errors),
504        TaskDef::Parallel(par) => validate_composite(file, task_id, &par.steps, errors),
505    }
506}
507
508fn validate_shell(task_id: &str, shell: &ShellTaskDef, errors: &mut Vec<ConfigError>) {
509    let inputs = shell.inputs.as_ref();
510    if let Some(inputs) = inputs {
511        for (input_name, input_def) in inputs {
512            validate_input(task_id, input_name, input_def, errors);
513        }
514    }
515    // Always check interpolations — even with no inputs, {foo} in the
516    // command is an error (no declared input to fill it).
517    let empty = HashMap::new();
518    validate_interpolations(task_id, &shell.command, inputs.unwrap_or(&empty), errors);
519}
520
521fn validate_composite(
522    file: &TasksFile,
523    task_id: &str,
524    steps: &[StepRef],
525    errors: &mut Vec<ConfigError>,
526) {
527    for (i, step) in steps.iter().enumerate() {
528        let path = || format!("tasks.{task_id}.steps[{i}]");
529
530        match step {
531            StepRef::Simple(ref_id) => {
532                if !file.tasks.contains_key(ref_id) {
533                    errors.push(ConfigError::new(
534                        path(),
535                        format!("references unknown task '{ref_id}'"),
536                    ));
537                }
538            }
539            StepRef::WithOverrides(map) => {
540                if map.len() != 1 {
541                    errors.push(ConfigError::new(
542                        path(),
543                        format!(
544                            "step with overrides must have exactly one task name, found {}",
545                            map.len()
546                        ),
547                    ));
548                    continue;
549                }
550
551                let (ref_id, overrides) = map.iter().next().unwrap();
552
553                if !file.tasks.contains_key(ref_id) {
554                    errors.push(ConfigError::new(
555                        path(),
556                        format!("references unknown task '{ref_id}'"),
557                    ));
558                    continue;
559                }
560
561                // Validate overrides against declared inputs on the target task.
562                if let Some(TaskDef::Shell(shell)) = file.tasks.get(ref_id) {
563                    let declared: HashSet<&str> = shell
564                        .inputs
565                        .as_ref()
566                        .map(|m| m.keys().map(String::as_str).collect())
567                        .unwrap_or_default();
568
569                    for key in overrides.keys() {
570                        if !declared.contains(key.as_str()) {
571                            errors.push(ConfigError::new(
572                                format!("{}.{ref_id}.{key}", path()),
573                                format!("task '{ref_id}' has no input '{key}'"),
574                            ));
575                        }
576                    }
577                }
578            }
579        }
580    }
581}
582
583fn validate_input(
584    task_id: &str,
585    input_name: &str,
586    input_def: &InputDef,
587    errors: &mut Vec<ConfigError>,
588) {
589    let prefix = format!("tasks.{task_id}.inputs.{input_name}");
590
591    match input_def.input_type {
592        InputType::Select => {
593            let opts = input_def.options.as_deref().unwrap_or(&[]);
594            if opts.is_empty() {
595                errors.push(ConfigError::new(
596                    format!("{prefix}.options"),
597                    "select input must define a non-empty 'options' list",
598                ));
599            }
600            if let Some(default) = &input_def.default
601                && let Some(s) = default.as_str()
602                    && !opts.is_empty() && !opts.iter().any(|o| o == s) {
603                        errors.push(ConfigError::new(
604                            format!("{prefix}.default"),
605                            format!("default '{s}' is not listed in options"),
606                        ));
607                    }
608        }
609
610        InputType::Text => {
611            if let Some(v) = &input_def.validate {
612                if let (Some(min), Some(max)) = (v.min_length, v.max_length)
613                    && min > max {
614                        errors.push(ConfigError::new(
615                            format!("{prefix}.validate"),
616                            format!("min_length ({min}) must not exceed max_length ({max})"),
617                        ));
618                    }
619                if let Some(pattern) = &v.pattern
620                    && let Err(e) = Regex::new(pattern) {
621                        errors.push(ConfigError::new(
622                            format!("{prefix}.validate.pattern"),
623                            format!("invalid regular expression: {e}"),
624                        ));
625                    }
626            }
627        }
628
629        InputType::Number => {
630            if let Some(v) = &input_def.validate
631                && let (Some(min), Some(max)) = (v.min, v.max)
632                    && min > max {
633                        errors.push(ConfigError::new(
634                            format!("{prefix}.validate"),
635                            format!("min ({min}) must not exceed max ({max})"),
636                        ));
637                    }
638        }
639
640        InputType::Boolean => {}
641    }
642}
643
644fn validate_interpolations(
645    task_id: &str,
646    command: &str,
647    inputs: &HashMap<String, InputDef>,
648    errors: &mut Vec<ConfigError>,
649) {
650    for cap in INTERPOLATION_RE.captures_iter(command) {
651        let name = &cap[1];
652        if !inputs.contains_key(name) {
653            errors.push(ConfigError::new(
654                format!("tasks.{task_id}.command"),
655                format!("references undeclared input '{{{name}}}'; add it to 'inputs'"),
656            ));
657        }
658    }
659}
660
661// ---------------------------------------------------------------------------
662// Cycle detection (Kahn's algorithm)
663// ---------------------------------------------------------------------------
664
665fn detect_cycles(file: &TasksFile, errors: &mut Vec<ConfigError>) {
666    // Build adjacency list and in-degrees for existing tasks.
667    let mut adj: HashMap<String, Vec<String>> = HashMap::new();
668    let mut indeg: HashMap<String, usize> = HashMap::new();
669
670    // Initialize nodes
671    for task_id in file.tasks.keys() {
672        indeg.insert(task_id.clone(), 0);
673        adj.insert(task_id.clone(), Vec::new());
674    }
675
676    // Populate adjacency and indegree (only consider dependencies that exist)
677    for (task_id, def) in &file.tasks {
678        for d in task_step_ids(Some(def)) {
679            if file.tasks.contains_key(&d) {
680                adj.get_mut(task_id).unwrap().push(d.clone());
681                *indeg.get_mut(&d).unwrap() += 1;
682            }
683        }
684    }
685
686    // Kahn's algorithm to detect nodes in cycles without recursion
687    let mut queue: VecDeque<String> = indeg
688        .iter()
689        .filter_map(|(k, &v)| if v == 0 { Some(k.clone()) } else { None })
690        .collect();
691    let mut removed = 0usize;
692
693    while let Some(node) = queue.pop_front() {
694        removed += 1;
695        if let Some(neis) = adj.get(&node) {
696            for n in neis {
697                if let Some(c) = indeg.get_mut(n) {
698                    *c -= 1;
699                    if *c == 0 {
700                        queue.push_back(n.clone());
701                    }
702                }
703            }
704        }
705    }
706
707    if removed != file.tasks.len() {
708        // Remaining nodes are part of cycles
709        let mut cycle_nodes: Vec<String> = indeg
710            .into_iter()
711            .filter_map(|(k, v)| if v > 0 { Some(k) } else { None })
712            .collect();
713        cycle_nodes.sort();
714        let msg = format!("cyclic dependency involving: {}", cycle_nodes.join(" → "));
715        let path = format!("tasks.{}", cycle_nodes.first().unwrap_or(&"<unknown>".to_string()));
716        errors.push(ConfigError::new(path, msg));
717    }
718}
719
720/// Collect the task IDs directly referenced by a task's steps.
721fn task_step_ids(def: Option<&TaskDef>) -> Vec<String> {
722    match def {
723        None | Some(TaskDef::Shell(_)) => vec![],
724        Some(TaskDef::Sequence(c)) | Some(TaskDef::Parallel(c)) => {
725            c.steps.iter().filter_map(|s| s.task_id().map(str::to_owned)).collect()
726        }
727    }
728}
729
730// ---------------------------------------------------------------------------
731// Tests
732// ---------------------------------------------------------------------------
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737
738    // -----------------------------------------------------------------------
739    // Helpers
740    // -----------------------------------------------------------------------
741
742    fn ok(yaml: &str) -> TasksFile {
743        parse_str(yaml).unwrap_or_else(|errs| {
744            panic!(
745                "expected Ok but got errors:\n{}",
746                errs.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("\n")
747            )
748        })
749    }
750
751    fn err(yaml: &str) -> Vec<ConfigError> {
752        parse_str(yaml).unwrap_err()
753    }
754
755    fn has_error(errors: &[ConfigError], substr: &str) -> bool {
756        errors.iter().any(|e| {
757            e.message.contains(substr) || e.path.contains(substr)
758        })
759    }
760
761    // -----------------------------------------------------------------------
762    // 1. Minimal valid shell task
763    // -----------------------------------------------------------------------
764
765    #[test]
766    fn minimal_shell_task() {
767        let f = ok(r#"
768version: 1
769tasks:
770  build:
771    type: shell
772    command: "cargo build"
773"#);
774        assert_eq!(f.version, 1);
775        assert!(matches!(f.tasks.get("build"), Some(TaskDef::Shell(_))));
776    }
777
778    // -----------------------------------------------------------------------
779    // 2. Full shell task with inputs and UI
780    // -----------------------------------------------------------------------
781
782    #[test]
783    fn full_shell_task() {
784        let f = ok(r#"
785version: 1
786tasks:
787  build:
788    type: shell
789    queue: build
790    cancel: queue
791    command: "cargo build --{mode}"
792    inputs:
793      mode:
794        type: select
795        options: [debug, release]
796        default: debug
797    ui:
798      title: Build
799      description: Compile the workspace
800      category: build
801"#);
802        let TaskDef::Shell(shell) = f.tasks.get("build").unwrap() else {
803            panic!("expected shell");
804        };
805        assert_eq!(shell.command, "cargo build --{mode}");
806        assert_eq!(shell.queue.as_deref(), Some("build"));
807        let inputs = shell.inputs.as_ref().unwrap();
808        let mode = inputs.get("mode").unwrap();
809        assert_eq!(mode.input_type, InputType::Select);
810        assert_eq!(mode.options.as_ref().unwrap(), &["debug", "release"]);
811    }
812
813    // -----------------------------------------------------------------------
814    // 3. Sequence task
815    // -----------------------------------------------------------------------
816
817    #[test]
818    fn sequence_task() {
819        let f = ok(r#"
820version: 1
821tasks:
822  build:
823    type: shell
824    command: cargo build
825  test:
826    type: shell
827    command: cargo test
828  ci:
829    type: sequence
830    steps:
831      - build
832      - test
833"#);
834        let TaskDef::Sequence(seq) = f.tasks.get("ci").unwrap() else {
835            panic!("expected sequence");
836        };
837        assert_eq!(seq.steps.len(), 2);
838        assert!(!seq.continue_on_error);
839        assert_eq!(seq.steps[0].task_id(), Some("build"));
840        assert_eq!(seq.steps[1].task_id(), Some("test"));
841    }
842
843    // -----------------------------------------------------------------------
844    // 4. Parallel task with continue_on_error
845    // -----------------------------------------------------------------------
846
847    #[test]
848    fn parallel_task_with_continue_on_error() {
849        let f = ok(r#"
850version: 1
851tasks:
852  lint:
853    type: shell
854    command: cargo clippy
855  fmt:
856    type: shell
857    command: cargo fmt --check
858  check:
859    type: parallel
860    continue_on_error: true
861    steps:
862      - lint
863      - fmt
864"#);
865        let TaskDef::Parallel(par) = f.tasks.get("check").unwrap() else {
866            panic!("expected parallel");
867        };
868        assert!(par.continue_on_error);
869        assert_eq!(par.steps.len(), 2);
870    }
871
872    // -----------------------------------------------------------------------
873    // 5. Step with input overrides
874    // -----------------------------------------------------------------------
875
876    #[test]
877    fn step_with_input_overrides() {
878        let f = ok(r#"
879version: 1
880tasks:
881  build:
882    type: shell
883    command: "cargo build --{mode}"
884    inputs:
885      mode:
886        type: select
887        options: [debug, release]
888        default: debug
889  deploy:
890    type: sequence
891    steps:
892      - build:
893          mode: release
894      - build
895"#);
896        let TaskDef::Sequence(seq) = f.tasks.get("deploy").unwrap() else {
897            panic!()
898        };
899        let StepRef::WithOverrides(map) = &seq.steps[0] else {
900            panic!("expected step with overrides");
901        };
902        let overrides = map.get("build").unwrap();
903        assert!(overrides.contains_key("mode"));
904        assert_eq!(seq.steps[1].task_id(), Some("build"));
905    }
906
907    // -----------------------------------------------------------------------
908    // 6. Text input with validation
909    // -----------------------------------------------------------------------
910
911    #[test]
912    fn text_input_with_validation() {
913        let f = ok(r#"
914version: 1
915tasks:
916  test:
917    type: shell
918    command: "cargo test {filter}"
919    inputs:
920      filter:
921        type: text
922        default: ""
923        placeholder: "Test name"
924        validate:
925          min_length: 0
926          max_length: 50
927          pattern: "^[a-zA-Z0-9_:]*$"
928"#);
929        let TaskDef::Shell(shell) = f.tasks.get("test").unwrap() else {
930            panic!()
931        };
932        let filter = shell.inputs.as_ref().unwrap().get("filter").unwrap();
933        assert_eq!(filter.input_type, InputType::Text);
934        let v = filter.validate.as_ref().unwrap();
935        assert_eq!(v.max_length, Some(50));
936        assert!(v.pattern.is_some());
937    }
938
939    // -----------------------------------------------------------------------
940    // 7. Number input with min/max
941    // -----------------------------------------------------------------------
942
943    #[test]
944    fn number_input_with_validation() {
945        let f = ok(r#"
946version: 1
947tasks:
948  scale:
949    type: shell
950    command: "scale --factor {factor}"
951    inputs:
952      factor:
953        type: number
954        default: 1
955        validate:
956          min: 1
957          max: 10
958"#);
959        let TaskDef::Shell(shell) = f.tasks.get("scale").unwrap() else {
960            panic!()
961        };
962        let n = shell.inputs.as_ref().unwrap().get("factor").unwrap();
963        assert_eq!(n.input_type, InputType::Number);
964        let v = n.validate.as_ref().unwrap();
965        assert_eq!(v.min, Some(1.0));
966        assert_eq!(v.max, Some(10.0));
967    }
968
969    // -----------------------------------------------------------------------
970    // 8. Full example from spec
971    // -----------------------------------------------------------------------
972
973    #[test]
974    fn full_spec_example() {
975        let f = ok(r#"
976version: 1
977
978tasks:
979  build:
980    type: shell
981    queue: build
982    command: "cargo build --{mode}"
983    inputs:
984      mode:
985        type: select
986        options: [debug, release]
987        default: debug
988
989  test:
990    type: shell
991    queue: build
992    command: "cargo test {filter}"
993    inputs:
994      filter:
995        type: text
996        default: ""
997
998  check:
999    type: parallel
1000    steps:
1001      - build
1002      - test
1003    continue_on_error: true
1004
1005  deploy:
1006    type: sequence
1007    steps:
1008      - build:
1009          mode: release
1010      - test
1011"#);
1012        assert!(f.tasks.contains_key("build"));
1013        assert!(f.tasks.contains_key("test"));
1014        assert!(f.tasks.contains_key("check"));
1015        assert!(f.tasks.contains_key("deploy"));
1016    }
1017
1018    // -----------------------------------------------------------------------
1019    // ERROR: wrong version
1020    // -----------------------------------------------------------------------
1021
1022    #[test]
1023    fn wrong_version_is_error() {
1024        let errs = err("version: 2\ntasks: {}");
1025        assert!(has_error(&errs, "expected 1"));
1026    }
1027
1028    // -----------------------------------------------------------------------
1029    // ERROR: unknown field in shell task (deny_unknown_fields)
1030    // -----------------------------------------------------------------------
1031
1032    #[test]
1033    fn unknown_field_in_shell_is_error() {
1034        let errs = err(r#"
1035version: 1
1036tasks:
1037  build:
1038    type: shell
1039    command: cargo build
1040    totally_unknown_field: oops
1041"#);
1042        assert!(!errs.is_empty(), "expected at least one error");
1043    }
1044
1045    // -----------------------------------------------------------------------
1046    // ERROR: missing required `command` in shell task
1047    // -----------------------------------------------------------------------
1048
1049    #[test]
1050    fn missing_command_is_error() {
1051        let errs = err(r#"
1052version: 1
1053tasks:
1054  build:
1055    type: shell
1056"#);
1057        assert!(!errs.is_empty());
1058    }
1059
1060    // -----------------------------------------------------------------------
1061    // ERROR: missing `steps` in sequence task
1062    // -----------------------------------------------------------------------
1063
1064    #[test]
1065    fn missing_steps_is_error() {
1066        let errs = err(r#"
1067version: 1
1068tasks:
1069  ci:
1070    type: sequence
1071"#);
1072        assert!(!errs.is_empty());
1073    }
1074
1075    // -----------------------------------------------------------------------
1076    // ERROR: step references non-existent task
1077    // -----------------------------------------------------------------------
1078
1079    #[test]
1080    fn unknown_step_reference_is_error() {
1081        let errs = err(r#"
1082version: 1
1083tasks:
1084  ci:
1085    type: sequence
1086    steps:
1087      - build
1088      - nonexistent
1089"#);
1090        assert!(has_error(&errs, "nonexistent") || has_error(&errs, "unknown"));
1091    }
1092
1093    // -----------------------------------------------------------------------
1094    // ERROR: direct cycle A → A
1095    // -----------------------------------------------------------------------
1096
1097    #[test]
1098    fn direct_self_cycle_is_error() {
1099        let errs = err(r#"
1100version: 1
1101tasks:
1102  a:
1103    type: sequence
1104    steps:
1105      - a
1106"#);
1107        assert!(has_error(&errs, "cyclic") || has_error(&errs, "cycle"));
1108    }
1109
1110    // -----------------------------------------------------------------------
1111    // ERROR: two-node cycle A → B → A
1112    // -----------------------------------------------------------------------
1113
1114    #[test]
1115    fn two_node_cycle_is_error() {
1116        let errs = err(r#"
1117version: 1
1118tasks:
1119  a:
1120    type: sequence
1121    steps:
1122      - b
1123  b:
1124    type: sequence
1125    steps:
1126      - a
1127"#);
1128        assert!(has_error(&errs, "cyclic") || has_error(&errs, "cycle"));
1129    }
1130
1131    // -----------------------------------------------------------------------
1132    // ERROR: indirect cycle A → B → C → A
1133    // -----------------------------------------------------------------------
1134
1135    #[test]
1136    fn indirect_cycle_is_error() {
1137        let errs = err(r#"
1138version: 1
1139tasks:
1140  a:
1141    type: sequence
1142    steps: [b]
1143  b:
1144    type: sequence
1145    steps: [c]
1146  c:
1147    type: sequence
1148    steps: [a]
1149"#);
1150        assert!(has_error(&errs, "cyclic") || has_error(&errs, "cycle"));
1151    }
1152
1153    // -----------------------------------------------------------------------
1154    // OK: linear chain with no cycle
1155    // -----------------------------------------------------------------------
1156
1157    #[test]
1158    fn linear_chain_has_no_cycle() {
1159        ok(r#"
1160version: 1
1161tasks:
1162  a:
1163    type: shell
1164    command: echo a
1165  b:
1166    type: sequence
1167    steps: [a]
1168  c:
1169    type: sequence
1170    steps: [b]
1171"#);
1172    }
1173
1174    // -----------------------------------------------------------------------
1175    // ERROR: select input without options
1176    // -----------------------------------------------------------------------
1177
1178    #[test]
1179    fn select_without_options_is_error() {
1180        let errs = err(r#"
1181version: 1
1182tasks:
1183  build:
1184    type: shell
1185    command: "cargo build --{mode}"
1186    inputs:
1187      mode:
1188        type: select
1189"#);
1190        assert!(has_error(&errs, "options") || has_error(&errs, "select"));
1191    }
1192
1193    // -----------------------------------------------------------------------
1194    // ERROR: select default not in options
1195    // -----------------------------------------------------------------------
1196
1197    #[test]
1198    fn select_default_not_in_options_is_error() {
1199        let errs = err(r#"
1200version: 1
1201tasks:
1202  build:
1203    type: shell
1204    command: "cargo build --{mode}"
1205    inputs:
1206      mode:
1207        type: select
1208        options: [debug, release]
1209        default: optimised
1210"#);
1211        assert!(has_error(&errs, "optimised") || has_error(&errs, "default"));
1212    }
1213
1214    // -----------------------------------------------------------------------
1215    // ERROR: command interpolates undeclared input
1216    // -----------------------------------------------------------------------
1217
1218    #[test]
1219    fn undeclared_interpolation_is_error() {
1220        let errs = err(r#"
1221version: 1
1222tasks:
1223  test:
1224    type: shell
1225    command: "cargo test {ghost}"
1226"#);
1227        assert!(has_error(&errs, "ghost") || has_error(&errs, "undeclared"));
1228    }
1229
1230    // -----------------------------------------------------------------------
1231    // OK: declared interpolation succeeds
1232    // -----------------------------------------------------------------------
1233
1234    #[test]
1235    fn declared_interpolation_is_ok() {
1236        ok(r#"
1237version: 1
1238tasks:
1239  test:
1240    type: shell
1241    command: "cargo test {filter}"
1242    inputs:
1243      filter:
1244        type: text
1245        default: ""
1246"#);
1247    }
1248
1249    // -----------------------------------------------------------------------
1250    // ERROR: invalid regex in text validate.pattern
1251    // -----------------------------------------------------------------------
1252
1253    #[test]
1254    fn invalid_regex_pattern_is_error() {
1255        let errs = err(r#"
1256version: 1
1257tasks:
1258  test:
1259    type: shell
1260    command: "cargo test {filter}"
1261    inputs:
1262      filter:
1263        type: text
1264        validate:
1265          pattern: "[invalid("
1266"#);
1267        assert!(has_error(&errs, "regular expression") || has_error(&errs, "pattern"));
1268    }
1269
1270    // -----------------------------------------------------------------------
1271    // ERROR: step override references undeclared input
1272    // -----------------------------------------------------------------------
1273
1274    #[test]
1275    fn step_override_undeclared_input_is_error() {
1276        let errs = err(r#"
1277version: 1
1278tasks:
1279  build:
1280    type: shell
1281    command: cargo build
1282  deploy:
1283    type: sequence
1284    steps:
1285      - build:
1286          ghost_input: something
1287"#);
1288        assert!(has_error(&errs, "ghost_input") || has_error(&errs, "no input"));
1289    }
1290
1291    // -----------------------------------------------------------------------
1292    // ERROR: min_length > max_length
1293    // -----------------------------------------------------------------------
1294
1295    #[test]
1296    fn inverted_text_length_bounds_is_error() {
1297        let errs = err(r#"
1298version: 1
1299tasks:
1300  t:
1301    type: shell
1302    command: "run {x}"
1303    inputs:
1304      x:
1305        type: text
1306        validate:
1307          min_length: 10
1308          max_length: 5
1309"#);
1310        assert!(has_error(&errs, "min_length") || has_error(&errs, "max_length"));
1311    }
1312
1313    // -----------------------------------------------------------------------
1314    // ERROR: numeric min > max
1315    // -----------------------------------------------------------------------
1316
1317    #[test]
1318    fn inverted_number_bounds_is_error() {
1319        let errs = err(r#"
1320version: 1
1321tasks:
1322  t:
1323    type: shell
1324    command: "run {factor}"
1325    inputs:
1326      factor:
1327        type: number
1328        validate:
1329          min: 100
1330          max: 1
1331"#);
1332        assert!(has_error(&errs, "min") || has_error(&errs, "max"));
1333    }
1334
1335    // -----------------------------------------------------------------------
1336    // OK: boolean input
1337    // -----------------------------------------------------------------------
1338
1339    #[test]
1340    fn boolean_input() {
1341        ok(r#"
1342version: 1
1343tasks:
1344  test:
1345    type: shell
1346    command: "cargo test {verbose}"
1347    inputs:
1348      verbose:
1349        type: boolean
1350        default: false
1351"#);
1352    }
1353
1354    // -----------------------------------------------------------------------
1355    // OK: effective_queue falls back to task_id
1356    // -----------------------------------------------------------------------
1357
1358    #[test]
1359    fn effective_queue_fallback() {
1360        let f = ok(r#"
1361version: 1
1362tasks:
1363  build:
1364    type: shell
1365    command: cargo build
1366"#);
1367        let TaskDef::Shell(shell) = f.tasks.get("build").unwrap() else {
1368            panic!()
1369        };
1370        assert_eq!(shell.effective_queue("build"), "build");
1371    }
1372
1373    // -----------------------------------------------------------------------
1374    // OK: explicit queue overrides task_id
1375    // -----------------------------------------------------------------------
1376
1377    #[test]
1378    fn effective_queue_explicit() {
1379        let f = ok(r#"
1380version: 1
1381tasks:
1382  build:
1383    type: shell
1384    queue: compilation
1385    command: cargo build
1386"#);
1387        let TaskDef::Shell(shell) = f.tasks.get("build").unwrap() else {
1388            panic!()
1389        };
1390        assert_eq!(shell.effective_queue("build"), "compilation");
1391    }
1392}