1use std::collections::{HashMap, HashSet, VecDeque};
47use std::path::Path;
48
49use once_cell::sync::Lazy;
50use regex::Regex;
51use serde::Deserialize;
52
53static INTERPOLATION_RE: Lazy<Regex> =
59 Lazy::new(|| Regex::new(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}").unwrap());
60
61#[derive(Debug, Clone, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct TasksFile {
69 pub version: u32,
71 pub tasks: HashMap<String, TaskDef>,
73}
74
75impl TasksFile {
76 pub fn get(&self, task_id: &str) -> Option<&TaskDef> {
78 self.tasks.get(task_id)
79 }
80
81 pub fn iter(&self) -> impl Iterator<Item = (&str, &TaskDef)> {
83 self.tasks.iter().map(|(k, v)| (k.as_str(), v))
84 }
85}
86
87#[derive(Debug, Clone, Deserialize)]
93#[serde(tag = "type", rename_all = "snake_case")]
94pub enum TaskDef {
95 Shell(ShellTaskDef),
97 Sequence(CompositeTaskDef),
99 Parallel(CompositeTaskDef),
101}
102
103impl TaskDef {
104 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#[derive(Debug, Clone, Deserialize)]
119#[serde(deny_unknown_fields)]
120pub struct ShellTaskDef {
121 pub command: String,
123 #[serde(default)]
126 pub queue: Option<String>,
127 #[serde(default)]
129 pub cancel: Option<CancelPolicy>,
130 #[serde(default)]
132 pub inputs: Option<HashMap<String, InputDef>>,
133 #[serde(default)]
135 pub ui: Option<UiMeta>,
136}
137
138impl ShellTaskDef {
139 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum CancelPolicy {
151 Queue,
153 None,
155}
156
157#[derive(Debug, Clone, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct CompositeTaskDef {
165 pub steps: Vec<StepRef>,
167 #[serde(default)]
170 pub continue_on_error: bool,
171 #[serde(default)]
173 pub ui: Option<UiMeta>,
174}
175
176#[derive(Debug, Clone, Deserialize)]
191#[serde(untagged)]
192pub enum StepRef {
193 Simple(String),
195 WithOverrides(HashMap<String, HashMap<String, InputValue>>),
197}
198
199impl StepRef {
200 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 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#[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 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 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#[derive(Debug, Clone, Deserialize)]
282#[serde(deny_unknown_fields)]
283pub struct InputDef {
284 #[serde(rename = "type")]
286 pub input_type: InputType,
287 #[serde(default)]
289 pub default: Option<InputValue>,
290 #[serde(default)]
292 pub placeholder: Option<String>,
293 #[serde(default)]
295 pub options: Option<Vec<String>>,
296 #[serde(default)]
298 pub validate: Option<ValidationRules>,
299}
300
301#[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#[derive(Debug, Clone, Deserialize)]
316#[serde(deny_unknown_fields)]
317pub struct ValidationRules {
318 #[serde(default)]
320 pub min_length: Option<usize>,
321 #[serde(default)]
323 pub max_length: Option<usize>,
324 #[serde(default)]
326 pub pattern: Option<String>,
327 #[serde(default, deserialize_with = "de_opt_number")]
330 pub min: Option<f64>,
331 #[serde(default, deserialize_with = "de_opt_number")]
334 pub max: Option<f64>,
335}
336
337fn 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#[derive(Debug, Clone, Deserialize)]
366#[serde(deny_unknown_fields)]
367pub struct UiMeta {
368 #[serde(default)]
370 pub title: Option<String>,
371 #[serde(default)]
373 pub description: Option<String>,
374 #[serde(default)]
376 pub category: Option<String>,
377}
378
379#[derive(Debug, Clone)]
385pub struct ConfigError {
386 pub path: String,
389 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
409pub 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
434pub fn parse_str(content: &str) -> Result<TasksFile, Vec<ConfigError>> {
439 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
472fn 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 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 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
661fn detect_cycles(file: &TasksFile, errors: &mut Vec<ConfigError>) {
666 let mut adj: HashMap<String, Vec<String>> = HashMap::new();
668 let mut indeg: HashMap<String, usize> = HashMap::new();
669
670 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 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 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 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
720fn 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#[cfg(test)]
735mod tests {
736 use super::*;
737
738 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
1023 fn wrong_version_is_error() {
1024 let errs = err("version: 2\ntasks: {}");
1025 assert!(has_error(&errs, "expected 1"));
1026 }
1027
1028 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}