Skip to main content

otto_cli/
config.rs

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}