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}