1pub mod backend;
6pub mod executor;
7pub mod graph;
8pub mod index;
9pub mod io;
10
11pub use backend::{
13 BackendFactory, HostBackend, TaskBackend, create_backend, create_backend_with_factory,
14 should_use_dagger,
15};
16pub use executor::*;
17pub use graph::*;
18pub use index::{IndexedTask, TaskIndex, TaskPath};
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::path::Path;
24
25fn default_hermetic() -> bool {
26 true
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
31pub struct Shell {
32 pub command: Option<String>,
34 pub flag: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
40pub struct Mapping {
41 pub from: String,
43 pub to: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
49#[serde(untagged)]
50pub enum Input {
51 Path(String),
53 Project(ProjectReference),
55 Task(TaskOutput),
57}
58
59impl Input {
60 pub fn as_path(&self) -> Option<&String> {
61 match self {
62 Input::Path(path) => Some(path),
63 Input::Project(_) | Input::Task(_) => None,
64 }
65 }
66
67 pub fn as_project(&self) -> Option<&ProjectReference> {
68 match self {
69 Input::Project(reference) => Some(reference),
70 Input::Path(_) | Input::Task(_) => None,
71 }
72 }
73
74 pub fn as_task_output(&self) -> Option<&TaskOutput> {
75 match self {
76 Input::Task(output) => Some(output),
77 Input::Path(_) | Input::Project(_) => None,
78 }
79 }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
84pub struct ProjectReference {
85 pub project: String,
87 pub task: String,
89 pub map: Vec<Mapping>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
95pub struct TaskOutput {
96 pub task: String,
98 #[serde(default)]
101 pub map: Option<Vec<Mapping>>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
106pub struct Task {
107 #[serde(default)]
109 pub shell: Option<Shell>,
110
111 pub command: String,
113
114 #[serde(default)]
116 pub args: Vec<String>,
117
118 #[serde(default)]
120 pub env: HashMap<String, serde_json::Value>,
121
122 #[serde(default)]
124 pub dagger: Option<DaggerTaskConfig>,
125
126 #[serde(default = "default_hermetic")]
129 pub hermetic: bool,
130
131 #[serde(default, rename = "dependsOn")]
133 pub depends_on: Vec<String>,
134
135 #[serde(default)]
137 pub inputs: Vec<Input>,
138
139 #[serde(default)]
141 pub outputs: Vec<String>,
142
143 #[serde(default, rename = "inputsFrom")]
145 pub inputs_from: Option<Vec<TaskOutput>>,
146
147 #[serde(default)]
149 pub workspaces: Vec<String>,
150
151 #[serde(default)]
153 pub description: Option<String>,
154
155 #[serde(default)]
157 pub params: Option<TaskParams>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
162pub struct DaggerTaskConfig {
163 #[serde(default)]
166 pub image: Option<String>,
167
168 #[serde(default)]
171 pub from: Option<String>,
172
173 #[serde(default)]
176 pub secrets: Option<Vec<DaggerSecret>>,
177
178 #[serde(default)]
180 pub cache: Option<Vec<DaggerCacheMount>>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
185pub struct DaggerSecret {
186 pub name: String,
188
189 #[serde(default)]
191 pub path: Option<String>,
192
193 #[serde(default, rename = "envVar")]
195 pub env_var: Option<String>,
196
197 pub resolver: crate::secrets::Secret,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
203pub struct DaggerCacheMount {
204 pub path: String,
206
207 pub name: String,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
213pub struct TaskParams {
214 #[serde(default)]
217 pub positional: Vec<ParamDef>,
218
219 #[serde(flatten, default)]
222 pub named: HashMap<String, ParamDef>,
223}
224
225#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
227#[serde(rename_all = "lowercase")]
228pub enum ParamType {
229 #[default]
230 String,
231 Bool,
232 Int,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
237pub struct ParamDef {
238 #[serde(default)]
240 pub description: Option<String>,
241
242 #[serde(default)]
244 pub required: bool,
245
246 #[serde(default)]
248 pub default: Option<String>,
249
250 #[serde(default, rename = "type")]
252 pub param_type: ParamType,
253
254 #[serde(default)]
256 pub short: Option<String>,
257}
258
259#[derive(Debug, Clone, Default)]
261pub struct ResolvedArgs {
262 pub positional: Vec<String>,
264 pub named: HashMap<String, String>,
266}
267
268impl ResolvedArgs {
269 pub fn new() -> Self {
271 Self::default()
272 }
273
274 pub fn interpolate(&self, template: &str) -> String {
277 let mut result = template.to_string();
278
279 for (i, value) in self.positional.iter().enumerate() {
281 let placeholder = format!("{{{{{}}}}}", i);
282 result = result.replace(&placeholder, value);
283 }
284
285 for (name, value) in &self.named {
287 let placeholder = format!("{{{{{}}}}}", name);
288 result = result.replace(&placeholder, value);
289 }
290
291 result
292 }
293
294 pub fn interpolate_args(&self, args: &[String]) -> Vec<String> {
296 args.iter().map(|arg| self.interpolate(arg)).collect()
297 }
298}
299
300impl Default for Task {
301 fn default() -> Self {
302 Self {
303 shell: None,
304 command: String::new(),
305 args: vec![],
306 env: HashMap::new(),
307 dagger: None,
308 hermetic: true, depends_on: vec![],
310 inputs: vec![],
311 outputs: vec![],
312 inputs_from: None,
313 workspaces: vec![],
314 description: None,
315 params: None,
316 }
317 }
318}
319
320impl Task {
321 pub fn description(&self) -> &str {
323 self.description
324 .as_deref()
325 .unwrap_or("No description provided")
326 }
327
328 pub fn iter_path_inputs(&self) -> impl Iterator<Item = &String> {
330 self.inputs.iter().filter_map(Input::as_path)
331 }
332
333 pub fn iter_project_refs(&self) -> impl Iterator<Item = &ProjectReference> {
335 self.inputs.iter().filter_map(Input::as_project)
336 }
337
338 pub fn iter_task_outputs(&self) -> impl Iterator<Item = &TaskOutput> {
340 self.inputs.iter().filter_map(Input::as_task_output)
341 }
342
343 pub fn collect_path_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
345 self.iter_path_inputs()
346 .map(|path| apply_prefix(prefix, path))
347 .collect()
348 }
349
350 pub fn collect_project_destinations_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
352 self.iter_project_refs()
353 .flat_map(|reference| reference.map.iter().map(|m| apply_prefix(prefix, &m.to)))
354 .collect()
355 }
356
357 pub fn collect_all_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
359 let mut inputs = self.collect_path_inputs_with_prefix(prefix);
360 inputs.extend(self.collect_project_destinations_with_prefix(prefix));
361 inputs
362 }
363}
364
365fn apply_prefix(prefix: Option<&Path>, value: &str) -> String {
366 if let Some(prefix) = prefix {
367 prefix.join(value).to_string_lossy().to_string()
368 } else {
369 value.to_string()
370 }
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
375#[serde(untagged)]
376pub enum TaskGroup {
377 Sequential(Vec<TaskDefinition>),
379
380 Parallel(HashMap<String, TaskDefinition>),
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
386#[serde(untagged)]
387pub enum TaskDefinition {
388 Single(Box<Task>),
390
391 Group(TaskGroup),
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
397pub struct Tasks {
398 #[serde(flatten)]
400 pub tasks: HashMap<String, TaskDefinition>,
401}
402
403impl Tasks {
404 pub fn new() -> Self {
406 Self::default()
407 }
408
409 pub fn get(&self, name: &str) -> Option<&TaskDefinition> {
411 self.tasks.get(name)
412 }
413
414 pub fn list_tasks(&self) -> Vec<&str> {
416 self.tasks.keys().map(|s| s.as_str()).collect()
417 }
418
419 pub fn contains(&self, name: &str) -> bool {
421 self.tasks.contains_key(name)
422 }
423}
424
425impl TaskDefinition {
426 pub fn is_single(&self) -> bool {
428 matches!(self, TaskDefinition::Single(_))
429 }
430
431 pub fn is_group(&self) -> bool {
433 matches!(self, TaskDefinition::Group(_))
434 }
435
436 pub fn as_single(&self) -> Option<&Task> {
438 match self {
439 TaskDefinition::Single(task) => Some(task.as_ref()),
440 _ => None,
441 }
442 }
443
444 pub fn as_group(&self) -> Option<&TaskGroup> {
446 match self {
447 TaskDefinition::Group(group) => Some(group),
448 _ => None,
449 }
450 }
451}
452
453impl TaskGroup {
454 pub fn is_sequential(&self) -> bool {
456 matches!(self, TaskGroup::Sequential(_))
457 }
458
459 pub fn is_parallel(&self) -> bool {
461 matches!(self, TaskGroup::Parallel(_))
462 }
463
464 pub fn len(&self) -> usize {
466 match self {
467 TaskGroup::Sequential(tasks) => tasks.len(),
468 TaskGroup::Parallel(tasks) => tasks.len(),
469 }
470 }
471
472 pub fn is_empty(&self) -> bool {
474 self.len() == 0
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn test_task_default_values() {
484 let task = Task {
485 command: "echo".to_string(),
486 ..Default::default()
487 };
488
489 assert!(task.shell.is_none());
490 assert_eq!(task.command, "echo");
491 assert_eq!(task.description(), "No description provided");
492 assert!(task.args.is_empty());
493 assert!(task.hermetic); }
495
496 #[test]
497 fn test_task_deserialization() {
498 let json = r#"{
499 "command": "echo",
500 "args": ["Hello", "World"]
501 }"#;
502
503 let task: Task = serde_json::from_str(json).unwrap();
504 assert_eq!(task.command, "echo");
505 assert_eq!(task.args, vec!["Hello", "World"]);
506 assert!(task.shell.is_none()); }
508
509 #[test]
510 fn test_task_group_sequential() {
511 let task1 = Task {
512 command: "echo".to_string(),
513 args: vec!["first".to_string()],
514 description: Some("First task".to_string()),
515 ..Default::default()
516 };
517
518 let task2 = Task {
519 command: "echo".to_string(),
520 args: vec!["second".to_string()],
521 description: Some("Second task".to_string()),
522 ..Default::default()
523 };
524
525 let group = TaskGroup::Sequential(vec![
526 TaskDefinition::Single(Box::new(task1)),
527 TaskDefinition::Single(Box::new(task2)),
528 ]);
529
530 assert!(group.is_sequential());
531 assert!(!group.is_parallel());
532 assert_eq!(group.len(), 2);
533 }
534
535 #[test]
536 fn test_task_group_parallel() {
537 let task1 = Task {
538 command: "echo".to_string(),
539 args: vec!["task1".to_string()],
540 description: Some("Task 1".to_string()),
541 ..Default::default()
542 };
543
544 let task2 = Task {
545 command: "echo".to_string(),
546 args: vec!["task2".to_string()],
547 description: Some("Task 2".to_string()),
548 ..Default::default()
549 };
550
551 let mut parallel_tasks = HashMap::new();
552 parallel_tasks.insert("task1".to_string(), TaskDefinition::Single(Box::new(task1)));
553 parallel_tasks.insert("task2".to_string(), TaskDefinition::Single(Box::new(task2)));
554
555 let group = TaskGroup::Parallel(parallel_tasks);
556
557 assert!(!group.is_sequential());
558 assert!(group.is_parallel());
559 assert_eq!(group.len(), 2);
560 }
561
562 #[test]
563 fn test_tasks_collection() {
564 let mut tasks = Tasks::new();
565 assert!(tasks.list_tasks().is_empty());
566
567 let task = Task {
568 command: "echo".to_string(),
569 args: vec!["hello".to_string()],
570 description: Some("Hello task".to_string()),
571 ..Default::default()
572 };
573
574 tasks
575 .tasks
576 .insert("greet".to_string(), TaskDefinition::Single(Box::new(task)));
577
578 assert!(tasks.contains("greet"));
579 assert!(!tasks.contains("nonexistent"));
580 assert_eq!(tasks.list_tasks(), vec!["greet"]);
581
582 let retrieved = tasks.get("greet").unwrap();
583 assert!(retrieved.is_single());
584 }
585
586 #[test]
587 fn test_task_definition_helpers() {
588 let task = Task {
589 command: "test".to_string(),
590 description: Some("Test task".to_string()),
591 ..Default::default()
592 };
593
594 let single = TaskDefinition::Single(Box::new(task.clone()));
595 assert!(single.is_single());
596 assert!(!single.is_group());
597 assert_eq!(single.as_single().unwrap().command, "test");
598 assert!(single.as_group().is_none());
599
600 let group = TaskDefinition::Group(TaskGroup::Sequential(vec![]));
601 assert!(!group.is_single());
602 assert!(group.is_group());
603 assert!(group.as_single().is_none());
604 assert!(group.as_group().is_some());
605 }
606
607 #[test]
608 fn test_input_deserialization_variants() {
609 let path_json = r#""src/**/*.rs""#;
610 let path_input: Input = serde_json::from_str(path_json).unwrap();
611 assert_eq!(path_input, Input::Path("src/**/*.rs".to_string()));
612
613 let project_json = r#"{
614 "project": "../projB",
615 "task": "build",
616 "map": [{"from": "dist/app.txt", "to": "vendor/app.txt"}]
617 }"#;
618 let project_input: Input = serde_json::from_str(project_json).unwrap();
619 match project_input {
620 Input::Project(reference) => {
621 assert_eq!(reference.project, "../projB");
622 assert_eq!(reference.task, "build");
623 assert_eq!(reference.map.len(), 1);
624 assert_eq!(reference.map[0].from, "dist/app.txt");
625 assert_eq!(reference.map[0].to, "vendor/app.txt");
626 }
627 other => panic!("Expected project reference, got {:?}", other),
628 }
629
630 let task_json = r#"{"task": "build.deps"}"#;
632 let task_input: Input = serde_json::from_str(task_json).unwrap();
633 match task_input {
634 Input::Task(output) => {
635 assert_eq!(output.task, "build.deps");
636 assert!(output.map.is_none());
637 }
638 other => panic!("Expected task output reference, got {:?}", other),
639 }
640 }
641
642 #[test]
643 fn test_task_input_helpers_collect() {
644 use std::collections::HashSet;
645 use std::path::Path;
646
647 let task = Task {
648 inputs: vec![
649 Input::Path("src".into()),
650 Input::Project(ProjectReference {
651 project: "../projB".into(),
652 task: "build".into(),
653 map: vec![Mapping {
654 from: "dist/app.txt".into(),
655 to: "vendor/app.txt".into(),
656 }],
657 }),
658 ],
659 ..Default::default()
660 };
661
662 let path_inputs: Vec<String> = task.iter_path_inputs().cloned().collect();
663 assert_eq!(path_inputs, vec!["src".to_string()]);
664
665 let project_refs: Vec<&ProjectReference> = task.iter_project_refs().collect();
666 assert_eq!(project_refs.len(), 1);
667 assert_eq!(project_refs[0].project, "../projB");
668
669 let prefix = Path::new("prefix");
670 let collected = task.collect_all_inputs_with_prefix(Some(prefix));
671 let collected: HashSet<_> = collected
672 .into_iter()
673 .map(std::path::PathBuf::from)
674 .collect();
675 let expected: HashSet<_> = ["src", "vendor/app.txt"]
676 .into_iter()
677 .map(|p| prefix.join(p))
678 .collect();
679 assert_eq!(collected, expected);
680 }
681
682 #[test]
683 fn test_resolved_args_interpolate_positional() {
684 let args = ResolvedArgs {
685 positional: vec!["video123".into(), "1080p".into()],
686 named: HashMap::new(),
687 };
688 assert_eq!(args.interpolate("{{0}}"), "video123");
689 assert_eq!(args.interpolate("{{1}}"), "1080p");
690 assert_eq!(args.interpolate("--id={{0}}"), "--id=video123");
691 assert_eq!(args.interpolate("{{0}}-{{1}}"), "video123-1080p");
692 }
693
694 #[test]
695 fn test_resolved_args_interpolate_named() {
696 let mut named = HashMap::new();
697 named.insert("url".into(), "https://example.com".into());
698 named.insert("quality".into(), "720p".into());
699 let args = ResolvedArgs {
700 positional: vec![],
701 named,
702 };
703 assert_eq!(args.interpolate("{{url}}"), "https://example.com");
704 assert_eq!(args.interpolate("--quality={{quality}}"), "--quality=720p");
705 }
706
707 #[test]
708 fn test_resolved_args_interpolate_mixed() {
709 let mut named = HashMap::new();
710 named.insert("format".into(), "mp4".into());
711 let args = ResolvedArgs {
712 positional: vec!["VIDEO_ID".into()],
713 named,
714 };
715 assert_eq!(
716 args.interpolate("download {{0}} --format={{format}}"),
717 "download VIDEO_ID --format=mp4"
718 );
719 }
720
721 #[test]
722 fn test_resolved_args_no_placeholder_unchanged() {
723 let args = ResolvedArgs::new();
724 assert_eq!(
725 args.interpolate("no placeholders here"),
726 "no placeholders here"
727 );
728 assert_eq!(args.interpolate(""), "");
729 }
730
731 #[test]
732 fn test_resolved_args_interpolate_args_list() {
733 let args = ResolvedArgs {
734 positional: vec!["id123".into()],
735 named: HashMap::new(),
736 };
737 let input = vec!["--id".into(), "{{0}}".into(), "--verbose".into()];
738 let result = args.interpolate_args(&input);
739 assert_eq!(result, vec!["--id", "id123", "--verbose"]);
740 }
741
742 #[test]
743 fn test_task_params_deserialization_with_flatten() {
744 let json = r#"{
746 "positional": [{"description": "Video ID", "required": true}],
747 "quality": {"description": "Quality", "default": "1080p", "short": "q"},
748 "verbose": {"description": "Verbose output", "type": "bool"}
749 }"#;
750 let params: TaskParams = serde_json::from_str(json).unwrap();
751
752 assert_eq!(params.positional.len(), 1);
753 assert_eq!(
754 params.positional[0].description,
755 Some("Video ID".to_string())
756 );
757 assert!(params.positional[0].required);
758
759 assert_eq!(params.named.len(), 2);
760 assert!(params.named.contains_key("quality"));
761 assert!(params.named.contains_key("verbose"));
762
763 let quality = ¶ms.named["quality"];
764 assert_eq!(quality.default, Some("1080p".to_string()));
765 assert_eq!(quality.short, Some("q".to_string()));
766
767 let verbose = ¶ms.named["verbose"];
768 assert_eq!(verbose.param_type, ParamType::Bool);
769 }
770
771 #[test]
772 fn test_task_params_empty() {
773 let json = r#"{}"#;
774 let params: TaskParams = serde_json::from_str(json).unwrap();
775 assert!(params.positional.is_empty());
776 assert!(params.named.is_empty());
777 }
778
779 #[test]
780 fn test_param_def_defaults() {
781 let def = ParamDef::default();
782 assert!(def.description.is_none());
783 assert!(!def.required);
784 assert!(def.default.is_none());
785 assert_eq!(def.param_type, ParamType::String);
786 assert!(def.short.is_none());
787 }
788}