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] = &[
17    "init",
18    "run",
19    "history",
20    "tasks",
21    "validate",
22    "version",
23    "completion",
24];
25const VALID_NOTIFY_ON: &[&str] = &["never", "failure", "always"];
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28#[serde(default, deny_unknown_fields)]
29pub struct Config {
30    pub version: i32,
31    pub defaults: Defaults,
32    pub notifications: Notifications,
33    pub tasks: Option<HashMap<String, Task>>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37#[serde(default, deny_unknown_fields)]
38pub struct Defaults {
39    pub timeout: String,
40    pub retries: Option<i32>,
41    pub retry_backoff: String,
42    pub notify_on: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, Default)]
46#[serde(default, deny_unknown_fields)]
47pub struct Notifications {
48    pub desktop: Option<bool>,
49    pub webhook_url: String,
50    pub webhook_timeout: String,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54#[serde(default, deny_unknown_fields)]
55pub struct Task {
56    pub description: String,
57    pub exec: Vec<String>,
58    pub run: String,
59    pub tasks: Vec<String>,
60    pub parallel: bool,
61    pub dir: String,
62    pub env: HashMap<String, String>,
63    pub timeout: String,
64    pub retries: Option<i32>,
65    pub retry_backoff: String,
66    pub notify_on: String,
67}
68
69#[derive(Debug, Clone)]
70pub struct ResolvedTask {
71    pub name: String,
72    pub source: RunSource,
73    pub command_preview: String,
74    pub sub_tasks: Vec<String>,
75    pub parallel: bool,
76    pub use_shell: bool,
77    pub exec: Vec<String>,
78    pub shell: String,
79    pub dir: String,
80    pub env: HashMap<String, String>,
81    pub timeout: Duration,
82    pub retries: i32,
83    pub retry_backoff: Duration,
84    pub notify_on: String,
85}
86
87#[derive(Debug, Clone)]
88pub struct NotificationSettings {
89    pub desktop_enabled: bool,
90    pub webhook_url: String,
91    pub webhook_timeout: Duration,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct ValidationError {
96    pub field: String,
97    pub message: String,
98}
99
100#[derive(Debug, Clone, Default)]
101pub struct ValidationErrors {
102    pub issues: Vec<ValidationError>,
103}
104
105impl ValidationErrors {
106    pub fn new() -> Self {
107        Self::default()
108    }
109
110    pub fn add<F: Into<String>, M: Into<String>>(&mut self, field: F, message: M) {
111        self.issues.push(ValidationError {
112            field: field.into(),
113            message: message.into(),
114        });
115    }
116
117    pub fn has_issues(&self) -> bool {
118        !self.issues.is_empty()
119    }
120}
121
122impl fmt::Display for ValidationErrors {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        if let Some(first) = self.issues.first() {
125            write!(
126                f,
127                "configuration validation failed: {}: {}",
128                first.field, first.message
129            )
130        } else {
131            write!(f, "configuration validation failed")
132        }
133    }
134}
135
136impl std::error::Error for ValidationErrors {}
137
138pub fn load(path: &Path) -> Result<Config, String> {
139    let cfg = parse(path)?;
140    validate(&cfg).map_err(|e| e.to_string())?;
141    Ok(cfg)
142}
143
144pub fn parse(path: &Path) -> Result<Config, String> {
145    let text = fs::read_to_string(path).map_err(|e| format!("read config: {e}"))?;
146    let cfg: Config = serde_yaml::from_str(&text).map_err(|e| format!("parse config yaml: {e}"))?;
147    Ok(cfg)
148}
149
150pub fn validate(cfg: &Config) -> Result<(), ValidationErrors> {
151    let mut issues = ValidationErrors::new();
152
153    if cfg.version != CURRENT_VERSION {
154        issues.add("version", format!("must be {CURRENT_VERSION}"));
155    }
156
157    validate_defaults(&mut issues, &cfg.defaults);
158    validate_notifications(&mut issues, &cfg.notifications);
159
160    match &cfg.tasks {
161        None => issues.add("tasks", "is required"),
162        Some(tasks) => {
163            if tasks.is_empty() {
164                issues.add("tasks", "is required");
165            }
166            for (name, task) in tasks {
167                validate_task_name(&mut issues, name);
168                validate_task(&mut issues, name, task);
169            }
170            validate_task_dependencies(&mut issues, tasks);
171        }
172    }
173
174    if issues.has_issues() {
175        Err(issues)
176    } else {
177        Ok(())
178    }
179}
180
181impl Config {
182    pub fn resolve_task(&self, name: &str) -> Result<ResolvedTask, String> {
183        let tasks = self
184            .tasks
185            .as_ref()
186            .ok_or_else(|| "tasks: is required".to_string())?;
187
188        let task = tasks
189            .get(name)
190            .ok_or_else(|| format!("task {name:?} not found"))?;
191
192        let timeout = resolve_duration(&task.timeout, &self.defaults.timeout, Duration::ZERO)
193            .map_err(|e| format!("task {name:?} timeout: {e}"))?;
194        let retries = resolve_retries(task.retries, self.defaults.retries, 0);
195        let retry_backoff = resolve_duration(
196            &task.retry_backoff,
197            &self.defaults.retry_backoff,
198            Duration::from_secs(1),
199        )
200        .map_err(|e| format!("task {name:?} retry_backoff: {e}"))?;
201        let notify_on = resolve_notify_on(&task.notify_on, &self.defaults.notify_on, "failure");
202
203        let mut resolved = ResolvedTask {
204            name: name.to_string(),
205            source: RunSource::Task,
206            command_preview: String::new(),
207            sub_tasks: Vec::new(),
208            parallel: task.parallel,
209            use_shell: false,
210            exec: Vec::new(),
211            shell: String::new(),
212            dir: task.dir.clone(),
213            env: task.env.clone(),
214            timeout,
215            retries,
216            retry_backoff,
217            notify_on,
218        };
219
220        if !task.exec.is_empty() {
221            resolved.use_shell = false;
222            resolved.exec = task.exec.clone();
223            resolved.command_preview = join_command_preview(&task.exec);
224        } else if !task.tasks.is_empty() {
225            resolved.sub_tasks = task.tasks.clone();
226            resolved.command_preview = join_task_preview(&task.tasks, task.parallel);
227        } else {
228            resolved.use_shell = true;
229            resolved.shell = task.run.clone();
230            resolved.command_preview = task.run.clone();
231        }
232
233        Ok(resolved)
234    }
235
236    pub fn resolve_notification_settings(&self) -> Result<NotificationSettings, String> {
237        let desktop_enabled = self.notifications.desktop.unwrap_or(true);
238        let webhook_timeout = resolve_duration(
239            &self.notifications.webhook_timeout,
240            "",
241            Duration::from_secs(5),
242        )
243        .map_err(|e| format!("notifications.webhook_timeout: {e}"))?;
244
245        Ok(NotificationSettings {
246            desktop_enabled,
247            webhook_url: self.notifications.webhook_url.clone(),
248            webhook_timeout,
249        })
250    }
251}
252
253pub fn resolve_inline(
254    args: &[String],
255    name: &str,
256    timeout_flag: &str,
257    retries_flag: Option<i32>,
258    notify_on_flag: &str,
259    defaults: &Defaults,
260) -> Result<ResolvedTask, String> {
261    if args.is_empty() {
262        return Err("inline command is required after --".to_string());
263    }
264
265    let timeout = resolve_duration(timeout_flag, &defaults.timeout, Duration::ZERO)
266        .map_err(|e| format!("inline timeout: {e}"))?;
267
268    let retries = match retries_flag {
269        Some(v) => v,
270        None => resolve_retries(None, defaults.retries, 0),
271    };
272
273    if !(0..=10).contains(&retries) {
274        return Err("inline retries must be between 0 and 10".to_string());
275    }
276
277    let retry_backoff = resolve_duration("", &defaults.retry_backoff, Duration::from_secs(1))
278        .map_err(|e| format!("inline retry_backoff: {e}"))?;
279
280    let notify_on = resolve_notify_on(notify_on_flag, &defaults.notify_on, "failure");
281    let task_name = if name.trim().is_empty() {
282        "inline".to_string()
283    } else {
284        name.to_string()
285    };
286
287    Ok(ResolvedTask {
288        name: task_name,
289        source: RunSource::Inline,
290        command_preview: join_command_preview(args),
291        sub_tasks: Vec::new(),
292        parallel: false,
293        use_shell: false,
294        exec: args.to_vec(),
295        shell: String::new(),
296        dir: String::new(),
297        env: HashMap::new(),
298        timeout,
299        retries,
300        retry_backoff,
301        notify_on,
302    })
303}
304
305fn validate_defaults(issues: &mut ValidationErrors, d: &Defaults) {
306    if !d.timeout.is_empty() && parse_duration(&d.timeout).is_err() {
307        issues.add("defaults.timeout", "must be a valid duration");
308    }
309
310    if let Some(retries) = d.retries
311        && !(0..=10).contains(&retries)
312    {
313        issues.add("defaults.retries", "must be between 0 and 10");
314    }
315
316    if !d.retry_backoff.is_empty() && parse_duration(&d.retry_backoff).is_err() {
317        issues.add("defaults.retry_backoff", "must be a valid duration");
318    }
319
320    if !d.notify_on.is_empty() && !VALID_NOTIFY_ON.contains(&d.notify_on.as_str()) {
321        issues.add(
322            "defaults.notify_on",
323            "must be one of never, failure, always",
324        );
325    }
326}
327
328fn validate_notifications(issues: &mut ValidationErrors, n: &Notifications) {
329    if !n.webhook_url.is_empty() && reqwest::Url::parse(&n.webhook_url).is_err() {
330        issues.add("notifications.webhook_url", "must be a valid URL");
331    }
332
333    if !n.webhook_timeout.is_empty() && parse_duration(&n.webhook_timeout).is_err() {
334        issues.add("notifications.webhook_timeout", "must be a valid duration");
335    }
336}
337
338fn validate_task_name(issues: &mut ValidationErrors, name: &str) {
339    if !TASK_NAME_RE.is_match(name) {
340        issues.add(
341            format!("tasks.{name}"),
342            "name must match ^[a-z0-9][a-z0-9_-]{0,62}$",
343        );
344    }
345
346    if RESERVED_NAMES.contains(&name) {
347        issues.add(format!("tasks.{name}"), "name is reserved");
348    }
349}
350
351fn validate_task(issues: &mut ValidationErrors, name: &str, task: &Task) {
352    let field = format!("tasks.{name}");
353    let has_exec = !task.exec.is_empty();
354    let has_run = !task.run.is_empty();
355    let has_tasks = !task.tasks.is_empty();
356    let mode_count = [has_exec, has_run, has_tasks]
357        .into_iter()
358        .filter(|mode| *mode)
359        .count();
360
361    if mode_count != 1 {
362        issues.add(
363            field.clone(),
364            "must define exactly one of exec, run, or tasks",
365        );
366    }
367
368    if has_exec {
369        for (idx, tok) in task.exec.iter().enumerate() {
370            if tok.is_empty() {
371                issues.add(format!("{field}.exec[{idx}]"), "must not be empty");
372            }
373        }
374    }
375
376    if !task.timeout.is_empty() && parse_duration(&task.timeout).is_err() {
377        issues.add(format!("{field}.timeout"), "must be a valid duration");
378    }
379
380    if let Some(retries) = task.retries
381        && !(0..=10).contains(&retries)
382    {
383        issues.add(format!("{field}.retries"), "must be between 0 and 10");
384    }
385
386    if !task.retry_backoff.is_empty() && parse_duration(&task.retry_backoff).is_err() {
387        issues.add(format!("{field}.retry_backoff"), "must be a valid duration");
388    }
389
390    if !task.notify_on.is_empty() && !VALID_NOTIFY_ON.contains(&task.notify_on.as_str()) {
391        issues.add(
392            format!("{field}.notify_on"),
393            "must be one of never, failure, always",
394        );
395    }
396
397    if has_tasks {
398        if !task.dir.is_empty() {
399            issues.add(
400                format!("{field}.dir"),
401                "is not supported when using task composition",
402            );
403        }
404        if !task.env.is_empty() {
405            issues.add(
406                format!("{field}.env"),
407                "is not supported when using task composition",
408            );
409        }
410        if !task.timeout.is_empty() {
411            issues.add(
412                format!("{field}.timeout"),
413                "is not supported when using task composition",
414            );
415        }
416        if task.retries.is_some() {
417            issues.add(
418                format!("{field}.retries"),
419                "is not supported when using task composition",
420            );
421        }
422        if !task.retry_backoff.is_empty() {
423            issues.add(
424                format!("{field}.retry_backoff"),
425                "is not supported when using task composition",
426            );
427        }
428        for (idx, dep) in task.tasks.iter().enumerate() {
429            if dep.trim().is_empty() {
430                issues.add(format!("{field}.tasks[{idx}]"), "must not be empty");
431            }
432        }
433    }
434}
435
436fn parse_duration(text: &str) -> Result<Duration, humantime::DurationError> {
437    humantime::parse_duration(text)
438}
439
440fn validate_task_dependencies(issues: &mut ValidationErrors, tasks: &HashMap<String, Task>) {
441    for (name, task) in tasks {
442        if task.tasks.is_empty() {
443            continue;
444        }
445
446        let field = format!("tasks.{name}.tasks");
447        for (idx, dep) in task.tasks.iter().enumerate() {
448            if dep == name {
449                issues.add(
450                    format!("{field}[{idx}]"),
451                    "must not reference itself directly",
452                );
453                continue;
454            }
455
456            if !tasks.contains_key(dep) {
457                issues.add(
458                    format!("{field}[{idx}]"),
459                    format!("references unknown task {dep:?}"),
460                );
461            }
462        }
463    }
464}
465
466fn resolve_duration(
467    primary: &str,
468    fallback: &str,
469    default_value: Duration,
470) -> Result<Duration, String> {
471    let value = if !primary.is_empty() {
472        primary
473    } else if !fallback.is_empty() {
474        fallback
475    } else {
476        return Ok(default_value);
477    };
478
479    parse_duration(value).map_err(|_| "must be a valid duration".to_string())
480}
481
482fn resolve_retries(primary: Option<i32>, fallback: Option<i32>, default_value: i32) -> i32 {
483    primary.or(fallback).unwrap_or(default_value)
484}
485
486fn resolve_notify_on(primary: &str, fallback: &str, default_value: &str) -> String {
487    if !primary.is_empty() {
488        primary.to_string()
489    } else if !fallback.is_empty() {
490        fallback.to_string()
491    } else {
492        default_value.to_string()
493    }
494}
495
496fn join_command_preview(args: &[String]) -> String {
497    args.join(" ")
498}
499
500fn join_task_preview(tasks: &[String], parallel: bool) -> String {
501    let mode = if parallel { "parallel" } else { "sequential" };
502    format!("tasks ({mode}): {}", tasks.join(", "))
503}