1use crate::model::RunSource;
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fmt;
6use std::fs;
7use std::path::Path;
8use std::sync::LazyLock;
9use std::time::Duration;
10
11pub const CURRENT_VERSION: i32 = 1;
12
13static TASK_NAME_RE: LazyLock<Regex> =
14 LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9_-]{0,62}$").expect("valid regex"));
15
16const RESERVED_NAMES: &[&str] = &["init", "run", "history", "tasks", "version", "completion"];
17const VALID_NOTIFY_ON: &[&str] = &["never", "failure", "always"];
18
19#[derive(Debug, Clone, Serialize, Deserialize, Default)]
20#[serde(default, deny_unknown_fields)]
21pub struct Config {
22 pub version: i32,
23 pub defaults: Defaults,
24 pub notifications: Notifications,
25 pub tasks: Option<HashMap<String, Task>>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29#[serde(default, deny_unknown_fields)]
30pub struct Defaults {
31 pub timeout: String,
32 pub retries: Option<i32>,
33 pub retry_backoff: String,
34 pub notify_on: String,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38#[serde(default, deny_unknown_fields)]
39pub struct Notifications {
40 pub desktop: Option<bool>,
41 pub webhook_url: String,
42 pub webhook_timeout: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, Default)]
46#[serde(default, deny_unknown_fields)]
47pub struct Task {
48 pub description: String,
49 pub exec: Vec<String>,
50 pub run: String,
51 pub tasks: Vec<String>,
52 pub parallel: bool,
53 pub dir: String,
54 pub env: HashMap<String, String>,
55 pub timeout: String,
56 pub retries: Option<i32>,
57 pub retry_backoff: String,
58 pub notify_on: String,
59}
60
61#[derive(Debug, Clone)]
62pub struct ResolvedTask {
63 pub name: String,
64 pub source: RunSource,
65 pub command_preview: String,
66 pub sub_tasks: Vec<String>,
67 pub parallel: bool,
68 pub use_shell: bool,
69 pub exec: Vec<String>,
70 pub shell: String,
71 pub dir: String,
72 pub env: HashMap<String, String>,
73 pub timeout: Duration,
74 pub retries: i32,
75 pub retry_backoff: Duration,
76 pub notify_on: String,
77}
78
79#[derive(Debug, Clone)]
80pub struct NotificationSettings {
81 pub desktop_enabled: bool,
82 pub webhook_url: String,
83 pub webhook_timeout: Duration,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ValidationError {
88 pub field: String,
89 pub message: String,
90}
91
92#[derive(Debug, Clone, Default)]
93pub struct ValidationErrors {
94 pub issues: Vec<ValidationError>,
95}
96
97impl ValidationErrors {
98 pub fn new() -> Self {
99 Self::default()
100 }
101
102 pub fn add<F: Into<String>, M: Into<String>>(&mut self, field: F, message: M) {
103 self.issues.push(ValidationError {
104 field: field.into(),
105 message: message.into(),
106 });
107 }
108
109 pub fn has_issues(&self) -> bool {
110 !self.issues.is_empty()
111 }
112}
113
114impl fmt::Display for ValidationErrors {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 if let Some(first) = self.issues.first() {
117 write!(
118 f,
119 "configuration validation failed: {}: {}",
120 first.field, first.message
121 )
122 } else {
123 write!(f, "configuration validation failed")
124 }
125 }
126}
127
128impl std::error::Error for ValidationErrors {}
129
130pub fn load(path: &Path) -> Result<Config, String> {
131 let text = fs::read_to_string(path).map_err(|e| format!("read config: {e}"))?;
132 let cfg: Config = serde_yaml::from_str(&text).map_err(|e| format!("parse config yaml: {e}"))?;
133 validate(&cfg).map_err(|e| e.to_string())?;
134 Ok(cfg)
135}
136
137pub fn validate(cfg: &Config) -> Result<(), ValidationErrors> {
138 let mut issues = ValidationErrors::new();
139
140 if cfg.version != CURRENT_VERSION {
141 issues.add("version", format!("must be {CURRENT_VERSION}"));
142 }
143
144 validate_defaults(&mut issues, &cfg.defaults);
145 validate_notifications(&mut issues, &cfg.notifications);
146
147 match &cfg.tasks {
148 None => issues.add("tasks", "is required"),
149 Some(tasks) => {
150 if tasks.is_empty() {
151 issues.add("tasks", "is required");
152 }
153 for (name, task) in tasks {
154 validate_task_name(&mut issues, name);
155 validate_task(&mut issues, name, task);
156 }
157 validate_task_dependencies(&mut issues, tasks);
158 }
159 }
160
161 if issues.has_issues() {
162 Err(issues)
163 } else {
164 Ok(())
165 }
166}
167
168impl Config {
169 pub fn resolve_task(&self, name: &str) -> Result<ResolvedTask, String> {
170 let tasks = self
171 .tasks
172 .as_ref()
173 .ok_or_else(|| "tasks: is required".to_string())?;
174
175 let task = tasks
176 .get(name)
177 .ok_or_else(|| format!("task {name:?} not found"))?;
178
179 let timeout = resolve_duration(&task.timeout, &self.defaults.timeout, Duration::ZERO)
180 .map_err(|e| format!("task {name:?} timeout: {e}"))?;
181 let retries = resolve_retries(task.retries, self.defaults.retries, 0);
182 let retry_backoff = resolve_duration(
183 &task.retry_backoff,
184 &self.defaults.retry_backoff,
185 Duration::from_secs(1),
186 )
187 .map_err(|e| format!("task {name:?} retry_backoff: {e}"))?;
188 let notify_on = resolve_notify_on(&task.notify_on, &self.defaults.notify_on, "failure");
189
190 let mut resolved = ResolvedTask {
191 name: name.to_string(),
192 source: RunSource::Task,
193 command_preview: String::new(),
194 sub_tasks: Vec::new(),
195 parallel: task.parallel,
196 use_shell: false,
197 exec: Vec::new(),
198 shell: String::new(),
199 dir: task.dir.clone(),
200 env: task.env.clone(),
201 timeout,
202 retries,
203 retry_backoff,
204 notify_on,
205 };
206
207 if !task.exec.is_empty() {
208 resolved.use_shell = false;
209 resolved.exec = task.exec.clone();
210 resolved.command_preview = join_command_preview(&task.exec);
211 } else if !task.tasks.is_empty() {
212 resolved.sub_tasks = task.tasks.clone();
213 resolved.command_preview = join_task_preview(&task.tasks, task.parallel);
214 } else {
215 resolved.use_shell = true;
216 resolved.shell = task.run.clone();
217 resolved.command_preview = task.run.clone();
218 }
219
220 Ok(resolved)
221 }
222
223 pub fn resolve_notification_settings(&self) -> Result<NotificationSettings, String> {
224 let desktop_enabled = self.notifications.desktop.unwrap_or(true);
225 let webhook_timeout = resolve_duration(
226 &self.notifications.webhook_timeout,
227 "",
228 Duration::from_secs(5),
229 )
230 .map_err(|e| format!("notifications.webhook_timeout: {e}"))?;
231
232 Ok(NotificationSettings {
233 desktop_enabled,
234 webhook_url: self.notifications.webhook_url.clone(),
235 webhook_timeout,
236 })
237 }
238}
239
240pub fn resolve_inline(
241 args: &[String],
242 name: &str,
243 timeout_flag: &str,
244 retries_flag: Option<i32>,
245 notify_on_flag: &str,
246 defaults: &Defaults,
247) -> Result<ResolvedTask, String> {
248 if args.is_empty() {
249 return Err("inline command is required after --".to_string());
250 }
251
252 let timeout = resolve_duration(timeout_flag, &defaults.timeout, Duration::ZERO)
253 .map_err(|e| format!("inline timeout: {e}"))?;
254
255 let retries = match retries_flag {
256 Some(v) => v,
257 None => resolve_retries(None, defaults.retries, 0),
258 };
259
260 if !(0..=10).contains(&retries) {
261 return Err("inline retries must be between 0 and 10".to_string());
262 }
263
264 let retry_backoff = resolve_duration("", &defaults.retry_backoff, Duration::from_secs(1))
265 .map_err(|e| format!("inline retry_backoff: {e}"))?;
266
267 let notify_on = resolve_notify_on(notify_on_flag, &defaults.notify_on, "failure");
268 let task_name = if name.trim().is_empty() {
269 "inline".to_string()
270 } else {
271 name.to_string()
272 };
273
274 Ok(ResolvedTask {
275 name: task_name,
276 source: RunSource::Inline,
277 command_preview: join_command_preview(args),
278 sub_tasks: Vec::new(),
279 parallel: false,
280 use_shell: false,
281 exec: args.to_vec(),
282 shell: String::new(),
283 dir: String::new(),
284 env: HashMap::new(),
285 timeout,
286 retries,
287 retry_backoff,
288 notify_on,
289 })
290}
291
292fn validate_defaults(issues: &mut ValidationErrors, d: &Defaults) {
293 if !d.timeout.is_empty() && parse_duration(&d.timeout).is_err() {
294 issues.add("defaults.timeout", "must be a valid duration");
295 }
296
297 if let Some(retries) = d.retries
298 && !(0..=10).contains(&retries)
299 {
300 issues.add("defaults.retries", "must be between 0 and 10");
301 }
302
303 if !d.retry_backoff.is_empty() && parse_duration(&d.retry_backoff).is_err() {
304 issues.add("defaults.retry_backoff", "must be a valid duration");
305 }
306
307 if !d.notify_on.is_empty() && !VALID_NOTIFY_ON.contains(&d.notify_on.as_str()) {
308 issues.add(
309 "defaults.notify_on",
310 "must be one of never, failure, always",
311 );
312 }
313}
314
315fn validate_notifications(issues: &mut ValidationErrors, n: &Notifications) {
316 if !n.webhook_url.is_empty() && reqwest::Url::parse(&n.webhook_url).is_err() {
317 issues.add("notifications.webhook_url", "must be a valid URL");
318 }
319
320 if !n.webhook_timeout.is_empty() && parse_duration(&n.webhook_timeout).is_err() {
321 issues.add("notifications.webhook_timeout", "must be a valid duration");
322 }
323}
324
325fn validate_task_name(issues: &mut ValidationErrors, name: &str) {
326 if !TASK_NAME_RE.is_match(name) {
327 issues.add(
328 format!("tasks.{name}"),
329 "name must match ^[a-z0-9][a-z0-9_-]{0,62}$",
330 );
331 }
332
333 if RESERVED_NAMES.contains(&name) {
334 issues.add(format!("tasks.{name}"), "name is reserved");
335 }
336}
337
338fn validate_task(issues: &mut ValidationErrors, name: &str, task: &Task) {
339 let field = format!("tasks.{name}");
340 let has_exec = !task.exec.is_empty();
341 let has_run = !task.run.is_empty();
342 let has_tasks = !task.tasks.is_empty();
343 let mode_count = [has_exec, has_run, has_tasks]
344 .into_iter()
345 .filter(|mode| *mode)
346 .count();
347
348 if mode_count != 1 {
349 issues.add(
350 field.clone(),
351 "must define exactly one of exec, run, or tasks",
352 );
353 }
354
355 if has_exec {
356 for (idx, tok) in task.exec.iter().enumerate() {
357 if tok.is_empty() {
358 issues.add(format!("{field}.exec[{idx}]"), "must not be empty");
359 }
360 }
361 }
362
363 if !task.timeout.is_empty() && parse_duration(&task.timeout).is_err() {
364 issues.add(format!("{field}.timeout"), "must be a valid duration");
365 }
366
367 if let Some(retries) = task.retries
368 && !(0..=10).contains(&retries)
369 {
370 issues.add(format!("{field}.retries"), "must be between 0 and 10");
371 }
372
373 if !task.retry_backoff.is_empty() && parse_duration(&task.retry_backoff).is_err() {
374 issues.add(format!("{field}.retry_backoff"), "must be a valid duration");
375 }
376
377 if !task.notify_on.is_empty() && !VALID_NOTIFY_ON.contains(&task.notify_on.as_str()) {
378 issues.add(
379 format!("{field}.notify_on"),
380 "must be one of never, failure, always",
381 );
382 }
383
384 if has_tasks {
385 if !task.dir.is_empty() {
386 issues.add(
387 format!("{field}.dir"),
388 "is not supported when using task composition",
389 );
390 }
391 if !task.env.is_empty() {
392 issues.add(
393 format!("{field}.env"),
394 "is not supported when using task composition",
395 );
396 }
397 if !task.timeout.is_empty() {
398 issues.add(
399 format!("{field}.timeout"),
400 "is not supported when using task composition",
401 );
402 }
403 if task.retries.is_some() {
404 issues.add(
405 format!("{field}.retries"),
406 "is not supported when using task composition",
407 );
408 }
409 if !task.retry_backoff.is_empty() {
410 issues.add(
411 format!("{field}.retry_backoff"),
412 "is not supported when using task composition",
413 );
414 }
415 for (idx, dep) in task.tasks.iter().enumerate() {
416 if dep.trim().is_empty() {
417 issues.add(format!("{field}.tasks[{idx}]"), "must not be empty");
418 }
419 }
420 }
421}
422
423fn parse_duration(text: &str) -> Result<Duration, humantime::DurationError> {
424 humantime::parse_duration(text)
425}
426
427fn validate_task_dependencies(issues: &mut ValidationErrors, tasks: &HashMap<String, Task>) {
428 for (name, task) in tasks {
429 if task.tasks.is_empty() {
430 continue;
431 }
432
433 let field = format!("tasks.{name}.tasks");
434 for (idx, dep) in task.tasks.iter().enumerate() {
435 if dep == name {
436 issues.add(
437 format!("{field}[{idx}]"),
438 "must not reference itself directly",
439 );
440 continue;
441 }
442
443 if !tasks.contains_key(dep) {
444 issues.add(
445 format!("{field}[{idx}]"),
446 format!("references unknown task {dep:?}"),
447 );
448 }
449 }
450 }
451}
452
453fn resolve_duration(
454 primary: &str,
455 fallback: &str,
456 default_value: Duration,
457) -> Result<Duration, String> {
458 let value = if !primary.is_empty() {
459 primary
460 } else if !fallback.is_empty() {
461 fallback
462 } else {
463 return Ok(default_value);
464 };
465
466 parse_duration(value).map_err(|_| "must be a valid duration".to_string())
467}
468
469fn resolve_retries(primary: Option<i32>, fallback: Option<i32>, default_value: i32) -> i32 {
470 primary.or(fallback).unwrap_or(default_value)
471}
472
473fn resolve_notify_on(primary: &str, fallback: &str, default_value: &str) -> String {
474 if !primary.is_empty() {
475 primary.to_string()
476 } else if !fallback.is_empty() {
477 fallback.to_string()
478 } else {
479 default_value.to_string()
480 }
481}
482
483fn join_command_preview(args: &[String]) -> String {
484 args.join(" ")
485}
486
487fn join_task_preview(tasks: &[String], parallel: bool) -> String {
488 let mode = if parallel { "parallel" } else { "sequential" };
489 format!("tasks ({mode}): {}", tasks.join(", "))
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use tempfile::tempdir;
496
497 #[test]
498 fn validate_rejects_task_with_exec_and_run() {
499 let mut tasks = HashMap::new();
500 tasks.insert(
501 "build".to_string(),
502 Task {
503 exec: vec!["cargo".to_string(), "build".to_string()],
504 run: "cargo build".to_string(),
505 ..Task::default()
506 },
507 );
508
509 let cfg = Config {
510 version: 1,
511 tasks: Some(tasks),
512 ..Config::default()
513 };
514
515 assert!(validate(&cfg).is_err());
516 }
517
518 #[test]
519 fn resolve_task_applies_defaults() {
520 let mut tasks = HashMap::new();
521 tasks.insert(
522 "test".to_string(),
523 Task {
524 exec: vec!["cargo".to_string(), "test".to_string()],
525 ..Task::default()
526 },
527 );
528
529 let cfg = Config {
530 version: 1,
531 defaults: Defaults {
532 timeout: "3s".to_string(),
533 retries: Some(2),
534 retry_backoff: "2s".to_string(),
535 notify_on: "always".to_string(),
536 },
537 tasks: Some(tasks),
538 ..Config::default()
539 };
540
541 let resolved = cfg.resolve_task("test").expect("resolve task");
542 assert_eq!(resolved.timeout, Duration::from_secs(3));
543 assert_eq!(resolved.retries, 2);
544 assert_eq!(resolved.retry_backoff, Duration::from_secs(2));
545 assert_eq!(resolved.notify_on, "always");
546 }
547
548 #[test]
549 fn resolve_inline_uses_defaults_and_overrides() {
550 let defaults = Defaults {
551 timeout: "4s".to_string(),
552 retries: Some(3),
553 retry_backoff: "2s".to_string(),
554 notify_on: "always".to_string(),
555 };
556
557 let args = vec!["cargo".to_string(), "test".to_string()];
558 let resolved = resolve_inline(&args, "", "", None, "", &defaults).expect("resolve inline");
559 assert_eq!(resolved.name, "inline");
560 assert_eq!(resolved.timeout, Duration::from_secs(4));
561 assert_eq!(resolved.retries, 3);
562 assert_eq!(resolved.notify_on, "always");
563
564 let override_args = vec!["echo".to_string(), "ok".to_string()];
565 let overridden =
566 resolve_inline(&override_args, "quick", "1s", Some(1), "failure", &defaults)
567 .expect("resolve inline override");
568 assert_eq!(overridden.name, "quick");
569 assert_eq!(overridden.timeout, Duration::from_secs(1));
570 assert_eq!(overridden.retries, 1);
571 assert_eq!(overridden.notify_on, "failure");
572 }
573
574 #[test]
575 fn load_rejects_unknown_field() {
576 let dir = tempdir().expect("tempdir");
577 let path = dir.path().join("otto.yml");
578
579 fs::write(
580 &path,
581 r#"version: 1
582tasks:
583 test:
584 exec: ["echo", "ok"]
585 unexpected: true
586"#,
587 )
588 .expect("write config");
589
590 assert!(load(&path).is_err());
591 }
592
593 #[test]
594 fn resolve_notification_settings_defaults_and_override() {
595 let cfg = Config::default();
596 let settings = cfg
597 .resolve_notification_settings()
598 .expect("default settings");
599 assert!(settings.desktop_enabled);
600 assert_eq!(settings.webhook_timeout, Duration::from_secs(5));
601
602 let cfg = Config {
603 notifications: Notifications {
604 desktop: Some(false),
605 webhook_url: "https://example.com".to_string(),
606 webhook_timeout: "2s".to_string(),
607 },
608 ..Config::default()
609 };
610
611 let settings = cfg
612 .resolve_notification_settings()
613 .expect("override settings");
614 assert!(!settings.desktop_enabled);
615 assert_eq!(settings.webhook_timeout, Duration::from_secs(2));
616 }
617
618 #[test]
619 fn resolve_inline_rejects_invalid_retries() {
620 let args = vec!["echo".to_string(), "ok".to_string()];
621 let err = resolve_inline(&args, "", "", Some(11), "", &Defaults::default())
622 .expect_err("expected invalid retries");
623 assert!(err.contains("between 0 and 10"));
624 }
625
626 #[test]
627 fn resolve_task_supports_composed_tasks() {
628 let mut tasks = HashMap::new();
629 tasks.insert(
630 "ci".to_string(),
631 Task {
632 tasks: vec![
633 "lint".to_string(),
634 "build".to_string(),
635 "clippy".to_string(),
636 ],
637 parallel: true,
638 ..Task::default()
639 },
640 );
641 tasks.insert(
642 "lint".to_string(),
643 Task {
644 exec: vec![
645 "cargo".to_string(),
646 "fmt".to_string(),
647 "--check".to_string(),
648 ],
649 ..Task::default()
650 },
651 );
652 tasks.insert(
653 "build".to_string(),
654 Task {
655 exec: vec!["cargo".to_string(), "build".to_string()],
656 ..Task::default()
657 },
658 );
659 tasks.insert(
660 "clippy".to_string(),
661 Task {
662 exec: vec!["cargo".to_string(), "clippy".to_string()],
663 ..Task::default()
664 },
665 );
666
667 let cfg = Config {
668 version: 1,
669 tasks: Some(tasks),
670 ..Config::default()
671 };
672
673 let resolved = cfg.resolve_task("ci").expect("resolve composed task");
674 assert_eq!(resolved.sub_tasks.len(), 3);
675 assert!(resolved.parallel);
676 assert!(!resolved.command_preview.is_empty());
677 }
678
679 #[test]
680 fn validate_rejects_unknown_composed_task_reference() {
681 let mut tasks = HashMap::new();
682 tasks.insert(
683 "ci".to_string(),
684 Task {
685 tasks: vec!["lint".to_string(), "missing".to_string()],
686 ..Task::default()
687 },
688 );
689 tasks.insert(
690 "lint".to_string(),
691 Task {
692 exec: vec![
693 "cargo".to_string(),
694 "fmt".to_string(),
695 "--check".to_string(),
696 ],
697 ..Task::default()
698 },
699 );
700
701 let cfg = Config {
702 version: 1,
703 tasks: Some(tasks),
704 ..Config::default()
705 };
706
707 let err = validate(&cfg).expect_err("expected validation error");
708 assert!(err.to_string().contains("unknown task"));
709 }
710}