1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5#[serde(rename_all = "camelCase")]
6pub struct Options {
7 #[serde(default = "default_cwd")]
8 pub cwd: String,
9 #[serde(default = "default_stop_on_error")]
10 pub stop_on_error: bool,
11 #[serde(default = "default_timeout_ms")]
12 pub timeout_ms: u64,
13 #[serde(default)]
14 pub env: HashMap<String, String>,
15 #[serde(default)]
16 pub cache: bool,
17 #[serde(default)]
18 pub cache_dir: Option<String>,
19 #[serde(default)]
20 pub incremental: bool,
21}
22
23fn default_cwd() -> String {
24 ".".to_string()
25}
26
27fn default_stop_on_error() -> bool {
28 true
29}
30
31fn default_timeout_ms() -> u64 {
32 30000
33}
34
35impl Default for Options {
36 fn default() -> Self {
37 Self {
38 cwd: default_cwd(),
39 stop_on_error: default_stop_on_error(),
40 timeout_ms: default_timeout_ms(),
41 env: HashMap::new(),
42 cache: false,
43 cache_dir: None,
44 incremental: false,
45 }
46 }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct RetryConfig {
52 #[serde(default = "default_retry_count")]
53 pub count: usize,
54 #[serde(default = "default_retry_delay")]
55 pub delay_ms: u64,
56 #[serde(default)]
57 pub backoff: bool,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(rename_all = "camelCase", tag = "type")]
62pub enum Step {
63 Bash {
64 #[serde(default)]
65 id: String,
66 #[serde(default = "default_depends_on")]
67 depends_on: Vec<String>,
68 cmd: String,
69 #[serde(default)]
70 timeout_ms: Option<u64>,
71 #[serde(default)]
72 retry: Option<RetryConfig>,
73 },
74 Read {
75 #[serde(default)]
76 id: String,
77 #[serde(default = "default_depends_on")]
78 depends_on: Vec<String>,
79 path: String,
80 #[serde(default)]
81 max_bytes: Option<usize>,
82 #[serde(default)]
83 encoding: Option<String>,
84 #[serde(default, alias = "filter_imports")]
85 filter_imports: Option<String>,
86 #[serde(default, alias = "filter_exports")]
87 filter_exports: Option<String>,
88 #[serde(default)]
89 since: Option<String>,
90 },
91 Write {
92 #[serde(default)]
93 id: String,
94 #[serde(default = "default_depends_on")]
95 depends_on: Vec<String>,
96 path: String,
97 content: String,
98 #[serde(default = "default_true")]
99 create_dirs: bool,
100 },
101 Patch {
102 #[serde(default)]
103 id: String,
104 #[serde(default = "default_depends_on")]
105 depends_on: Vec<String>,
106 path: String,
107 edits: Vec<PatchEdit>,
108 },
109 Mv {
110 #[serde(default)]
111 id: String,
112 #[serde(default = "default_depends_on")]
113 depends_on: Vec<String>,
114 from: String,
115 to: String,
116 },
117 Cp {
118 #[serde(default)]
119 id: String,
120 #[serde(default = "default_depends_on")]
121 depends_on: Vec<String>,
122 from: String,
123 to: String,
124 #[serde(default)]
125 recursive: bool,
126 },
127 Rm {
128 #[serde(default)]
129 id: String,
130 #[serde(default = "default_depends_on")]
131 depends_on: Vec<String>,
132 path: String,
133 #[serde(default)]
134 recursive: bool,
135 },
136 Mkdir {
137 #[serde(default)]
138 id: String,
139 #[serde(default = "default_depends_on")]
140 depends_on: Vec<String>,
141 path: String,
142 },
143 Grep {
144 #[serde(default)]
145 id: String,
146 #[serde(default = "default_depends_on")]
147 depends_on: Vec<String>,
148 pattern: String,
149 path: String,
150 #[serde(default)]
151 ext: Vec<String>,
152 #[serde(default = "default_regex")]
153 regex: bool,
154 #[serde(default)]
155 context_lines: Option<usize>,
156 },
157 Replace {
158 #[serde(default)]
159 id: String,
160 #[serde(default = "default_depends_on")]
161 depends_on: Vec<String>,
162 pattern: String,
163 replacement: String,
164 path: String,
165 #[serde(default)]
166 ext: Vec<String>,
167 #[serde(default = "default_regex")]
168 regex: bool,
169 #[serde(default = "default_true")]
170 case_sensitive: bool,
171 #[serde(default)]
172 glob: Option<String>,
173 #[serde(default)]
174 whole_word: bool,
175 },
176 Scan {
177 #[serde(default)]
178 id: String,
179 #[serde(default = "default_depends_on")]
180 depends_on: Vec<String>,
181 path: String,
182 #[serde(default = "default_depth")]
183 depth: usize,
184 #[serde(default)]
185 include: Vec<String>,
186 #[serde(default = "default_scan_output")]
187 output: ScanOutput,
188 },
189 Summarize {
190 #[serde(default)]
191 id: String,
192 #[serde(default = "default_depends_on")]
193 depends_on: Vec<String>,
194 path: String,
195 #[serde(default)]
196 focus: String,
197 },
198 Extract {
199 #[serde(default)]
200 id: String,
201 #[serde(default = "default_depends_on")]
202 depends_on: Vec<String>,
203 path: String,
204 #[serde(default)]
205 pick: Vec<String>,
206 },
207 Diff {
208 #[serde(default)]
209 id: String,
210 #[serde(default = "default_depends_on")]
211 depends_on: Vec<String>,
212 a: String,
213 b: String,
214 #[serde(default = "default_diff_format")]
215 format: DiffFormat,
216 },
217 Lint {
218 #[serde(default)]
219 id: String,
220 #[serde(default = "default_depends_on")]
221 depends_on: Vec<String>,
222 path: String,
223 #[serde(default = "default_lint_tool")]
224 tool: LintTool,
225 },
226 Template {
227 #[serde(default)]
228 id: String,
229 #[serde(default = "default_depends_on")]
230 depends_on: Vec<String>,
231 #[serde(default)]
232 name: String,
233 #[serde(default)]
234 builtin: String,
235 #[serde(default)]
236 source: String,
237 output: String,
238 #[serde(default)]
239 vars: HashMap<String, String>,
240 },
241 Snapshot {
242 #[serde(default)]
243 id: String,
244 #[serde(default = "default_depends_on")]
245 depends_on: Vec<String>,
246 path: String,
247 snapshot_id: String,
248 },
249 Restore {
250 #[serde(default)]
251 id: String,
252 #[serde(default = "default_depends_on")]
253 depends_on: Vec<String>,
254 snapshot_id: String,
255 },
256 Git {
257 #[serde(default)]
258 id: String,
259 #[serde(default = "default_depends_on")]
260 depends_on: Vec<String>,
261 op: GitOp,
262 #[serde(default)]
263 args: Vec<String>,
264 },
265 Http {
266 #[serde(default)]
267 id: String,
268 #[serde(default = "default_depends_on")]
269 depends_on: Vec<String>,
270 method: String,
271 url: String,
272 #[serde(default)]
273 headers: HashMap<String, String>,
274 #[serde(default = "default_expect_status")]
275 expect_status: u16,
276 #[serde(default)]
277 body: Option<String>,
278 },
279 Import {
280 #[serde(default)]
281 id: String,
282 #[serde(default = "default_depends_on")]
283 depends_on: Vec<String>,
284 path: String,
285 #[serde(default)]
286 add: Vec<String>,
287 #[serde(default)]
288 remove: Vec<String>,
289 #[serde(default)]
290 organize: bool,
291 },
292 Refactor {
293 #[serde(default)]
294 id: String,
295 #[serde(default = "default_depends_on")]
296 depends_on: Vec<String>,
297 symbol: String,
298 rename_to: String,
299 path: String,
300 #[serde(default)]
301 ext: Vec<String>,
302 #[serde(default)]
303 dry_run: bool,
304 #[serde(default = "default_true")]
305 whole_word: bool,
306 #[serde(default)]
307 preview: bool,
308 },
309 Deps {
310 #[serde(default)]
311 id: String,
312 #[serde(default = "default_depends_on")]
313 depends_on: Vec<String>,
314 path: String,
315 #[serde(default = "default_depth")]
316 depth: usize,
317 #[serde(default)]
318 include: Vec<String>,
319 #[serde(default)]
320 focus: Option<String>,
321 },
322 Checkpoint {
323 #[serde(default)]
324 id: String,
325 #[serde(default = "default_depends_on")]
326 depends_on: Vec<String>,
327 checkpoint_id: String,
328 #[serde(default)]
329 restore: bool,
330 },
331 Boilerplate {
332 #[serde(default)]
333 id: String,
334 #[serde(default = "default_depends_on")]
335 depends_on: Vec<String>,
336 path: String,
337 #[serde(default)]
338 add_header: Option<String>,
339 #[serde(default)]
340 add_license: Option<String>,
341 #[serde(default)]
342 add_shebang: Option<String>,
343 #[serde(default)]
344 auto_imports: bool,
345 },
346 DeadCode {
347 #[serde(default)]
348 id: String,
349 #[serde(default = "default_depends_on")]
350 depends_on: Vec<String>,
351 path: String,
352 #[serde(default)]
353 include: Vec<String>,
354 },
355 If {
356 #[serde(default)]
357 id: String,
358 #[serde(default = "default_depends_on")]
359 depends_on: Vec<String>,
360 condition: Condition,
361 then: Vec<Step>,
362 #[serde(default, rename = "else")]
363 else_: Vec<Step>,
364 },
365 Each {
366 #[serde(default)]
367 id: String,
368 #[serde(default = "default_depends_on")]
369 depends_on: Vec<String>,
370 over: EachOver,
371 #[serde(default = "default_each_as", rename = "as")]
372 as_: String,
373 #[serde(default = "default_each_parallel")]
374 parallel: bool,
375 step: Box<Step>,
376 },
377 Parallel {
378 #[serde(default)]
379 id: String,
380 #[serde(default = "default_depends_on")]
381 depends_on: Vec<String>,
382 steps: Vec<Step>,
383 },
384}
385
386impl Step {
387 pub fn get_id(&self) -> &str {
388 match self {
389 Step::Bash { id, .. } => id,
390 Step::Read { id, .. } => id,
391 Step::Write { id, .. } => id,
392 Step::Patch { id, .. } => id,
393 Step::Mv { id, .. } => id,
394 Step::Cp { id, .. } => id,
395 Step::Rm { id, .. } => id,
396 Step::Mkdir { id, .. } => id,
397 Step::Grep { id, .. } => id,
398 Step::Replace { id, .. } => id,
399 Step::Scan { id, .. } => id,
400 Step::Summarize { id, .. } => id,
401 Step::Extract { id, .. } => id,
402 Step::Diff { id, .. } => id,
403 Step::Lint { id, .. } => id,
404 Step::Template { id, .. } => id,
405 Step::Snapshot { id, .. } => id,
406 Step::Restore { id, .. } => id,
407 Step::Git { id, .. } => id,
408 Step::Http { id, .. } => id,
409 Step::Import { id, .. } => id,
410 Step::Refactor { id, .. } => id,
411 Step::Deps { id, .. } => id,
412 Step::Checkpoint { id, .. } => id,
413 Step::Boilerplate { id, .. } => id,
414 Step::DeadCode { id, .. } => id,
415 Step::If { id, .. } => id,
416 Step::Each { id, .. } => id,
417 Step::Parallel { id, .. } => id,
418 }
419 }
420
421 pub fn get_depends_on(&self) -> &[String] {
422 match self {
423 Step::Bash { depends_on, .. } => depends_on,
424 Step::Read { depends_on, .. } => depends_on,
425 Step::Write { depends_on, .. } => depends_on,
426 Step::Patch { depends_on, .. } => depends_on,
427 Step::Mv { depends_on, .. } => depends_on,
428 Step::Cp { depends_on, .. } => depends_on,
429 Step::Rm { depends_on, .. } => depends_on,
430 Step::Mkdir { depends_on, .. } => depends_on,
431 Step::Grep { depends_on, .. } => depends_on,
432 Step::Replace { depends_on, .. } => depends_on,
433 Step::Scan { depends_on, .. } => depends_on,
434 Step::Summarize { depends_on, .. } => depends_on,
435 Step::Extract { depends_on, .. } => depends_on,
436 Step::Diff { depends_on, .. } => depends_on,
437 Step::Lint { depends_on, .. } => depends_on,
438 Step::Template { depends_on, .. } => depends_on,
439 Step::Snapshot { depends_on, .. } => depends_on,
440 Step::Restore { depends_on, .. } => depends_on,
441 Step::Git { depends_on, .. } => depends_on,
442 Step::Http { depends_on, .. } => depends_on,
443 Step::Import { depends_on, .. } => depends_on,
444 Step::Refactor { depends_on, .. } => depends_on,
445 Step::Deps { depends_on, .. } => depends_on,
446 Step::Checkpoint { depends_on, .. } => depends_on,
447 Step::Boilerplate { depends_on, .. } => depends_on,
448 Step::DeadCode { depends_on, .. } => depends_on,
449 Step::If { depends_on, .. } => depends_on,
450 Step::Each { depends_on, .. } => depends_on,
451 Step::Parallel { depends_on, .. } => depends_on,
452 }
453 }
454}
455
456fn default_regex() -> bool {
457 false
458}
459
460fn default_true() -> bool {
461 true
462}
463
464fn default_retry_count() -> usize {
465 3
466}
467
468fn default_retry_delay() -> u64 {
469 1000
470}
471
472fn default_depth() -> usize {
473 3
474}
475
476fn default_scan_output() -> ScanOutput {
477 ScanOutput::Summary
478}
479
480fn default_diff_format() -> DiffFormat {
481 DiffFormat::Stat
482}
483
484fn default_lint_tool() -> LintTool {
485 LintTool::Auto
486}
487
488fn default_expect_status() -> u16 {
489 200
490}
491
492fn default_each_parallel() -> bool {
493 true
494}
495
496fn default_each_as() -> String {
497 "item".to_string()
498}
499
500fn default_depends_on() -> Vec<String> {
501 Vec::new()
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
505#[serde(rename_all = "camelCase")]
506pub enum ScanOutput {
507 Summary,
508 Full,
509 Imports,
510 Exports,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize)]
514#[serde(rename_all = "camelCase")]
515pub enum DiffFormat {
516 Unified,
517 Json,
518 Stat,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize)]
522#[serde(rename_all = "camelCase")]
523pub enum LintTool {
524 Auto,
525 Eslint,
526 Biome,
527 Clippy,
528 Ruff,
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize)]
532#[serde(rename_all = "camelCase")]
533pub enum GitOp {
534 Status,
535 Diff,
536 Log,
537 Add,
538 Commit,
539 Branch,
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize)]
543#[serde(rename_all = "camelCase")]
544pub struct PatchEdit {
545 pub find: String,
546 pub replace: String,
547}
548
549#[derive(Debug, Clone, Serialize, Deserialize)]
550#[serde(untagged)]
551pub enum EachOver {
552 List(Vec<String>),
553 Ref(EachRef),
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize)]
557#[serde(rename_all = "camelCase")]
558pub struct EachRef {
559 pub ref_: usize,
560 #[serde(default = "default_pick")]
561 pub pick: String,
562}
563
564fn default_pick() -> String {
565 "*".to_string()
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize)]
569#[serde(rename_all = "camelCase", tag = "type")]
570pub enum Condition {
571 Exists {
572 path: String,
573 },
574 Contains {
575 path: String,
576 pattern: String,
577 #[serde(default)]
578 regex: bool,
579 },
580 GrepHasResults {
581 #[serde(rename = "ref")]
582 ref_: usize,
583 },
584 StepOk {
585 #[serde(rename = "ref")]
586 ref_: usize,
587 },
588 StepFailed {
589 #[serde(rename = "ref")]
590 ref_: usize,
591 },
592 FileChanged {
593 path: String,
594 since: String,
595 },
596 Not {
597 condition: Box<Condition>,
598 },
599 And {
600 conditions: Vec<Condition>,
601 },
602 Or {
603 conditions: Vec<Condition>,
604 },
605}
606
607#[derive(Debug, Clone, Serialize, Deserialize)]
608#[serde(rename_all = "camelCase")]
609pub struct Payload {
610 #[serde(default)]
611 pub name: Option<String>,
612 #[serde(default)]
613 pub description: Option<String>,
614 #[serde(default)]
615 pub version: Option<String>,
616 #[serde(default)]
617 pub author: Option<String>,
618 #[serde(default)]
619 pub options: Options,
620 #[serde(default)]
621 pub props: HashMap<String, serde_json::Value>,
622 #[serde(default)]
623 pub compose: Vec<ComposeTask>,
624 pub steps: Vec<Step>,
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize)]
628#[serde(rename_all = "camelCase")]
629pub struct ComposeTask {
630 pub task: String,
631 #[serde(default)]
632 pub props: HashMap<String, serde_json::Value>,
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize)]
636#[serde(rename_all = "camelCase")]
637pub struct GrepMatch {
638 pub path: String,
639 pub line: usize,
640 pub text: String,
641}
642
643#[derive(Debug, Clone, Serialize, Deserialize)]
644#[serde(rename_all = "camelCase")]
645pub struct FileContent {
646 pub path: String,
647 pub content: String,
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize)]
651#[serde(rename_all = "camelCase")]
652pub struct StepResult {
653 pub index: usize,
654 #[serde(flatten)]
655 pub step_type: StepTypeResult,
656 pub status: String,
657 pub duration_ms: u64,
658 #[serde(skip_serializing_if = "Option::is_none")]
659 pub stopped_pipeline: Option<bool>,
660}
661
662#[derive(Debug, Clone, Serialize, Deserialize)]
663#[serde(rename_all = "camelCase", tag = "type")]
664pub enum StepTypeResult {
665 Bash {
666 cmd: String,
667 stdout: String,
668 stderr: String,
669 exit_code: i32,
670 },
671 Read {
672 path: String,
673 files: Vec<FileContent>,
674 files_filtered: usize,
675 filter_reason: Option<String>,
676 },
677 Write {
678 path: String,
679 diff: Option<String>,
680 },
681 Patch {
682 path: String,
683 edits_applied: usize,
684 diff: Option<String>,
685 },
686 Mv {
687 from: String,
688 to: String,
689 },
690 Cp {
691 from: String,
692 to: String,
693 },
694 Rm {
695 path: String,
696 },
697 Mkdir {
698 path: String,
699 },
700 Grep {
701 pattern: String,
702 matches: Vec<GrepMatch>,
703 },
704 Replace {
705 pattern: String,
706 replacement: String,
707 files_scanned: usize,
708 files_modified: usize,
709 total_replacements: usize,
710 },
711 Scan {
712 path: String,
713 stack: Vec<String>,
714 entry_points: Vec<String>,
715 file_count: usize,
716 tree: HashMap<String, Vec<String>>,
717 exports: HashMap<String, Vec<String>>,
718 imports_graph: HashMap<String, Vec<String>>,
719 },
720 Summarize {
721 path: String,
722 summary: FileSummary,
723 },
724 Extract {
725 path: String,
726 data: serde_json::Value,
727 },
728 Diff {
729 a: String,
730 b: String,
731 added: usize,
732 removed: usize,
733 changed_sections: Vec<String>,
734 is_identical: bool,
735 unified_diff: Option<String>,
736 },
737 Lint {
738 errors_count: usize,
739 warnings_count: usize,
740 errors: Vec<LintError>,
741 },
742 Template {
743 output: String,
744 rendered: bool,
745 },
746 Snapshot {
747 path: String,
748 id: String,
749 archived: bool,
750 },
751 Restore {
752 id: String,
753 restored: bool,
754 },
755 Git {
756 op: String,
757 output: serde_json::Value,
758 },
759 Http {
760 method: String,
761 url: String,
762 status: u16,
763 body: Option<String>,
764 },
765 Import {
766 path: String,
767 added: Vec<String>,
768 removed: Vec<String>,
769 organized: bool,
770 },
771 Refactor {
772 symbol: String,
773 rename_to: String,
774 files_scanned: usize,
775 files_modified: usize,
776 total_replacements: usize,
777 dry_run: bool,
778 changes: Vec<RefactorChange>,
779 },
780 Deps {
781 path: String,
782 graph: HashMap<String, Vec<String>>,
783 dependents: HashMap<String, Vec<String>>,
784 file_count: usize,
785 cycles: Vec<Vec<String>>,
786 },
787 Checkpoint {
788 checkpoint_id: String,
789 action: String,
790 steps_saved: usize,
791 },
792 Boilerplate {
793 path: String,
794 header_added: bool,
795 license_added: bool,
796 shebang_added: bool,
797 imports_added: Vec<String>,
798 },
799 DeadCode {
800 path: String,
801 unused_functions: Vec<DeadCodeIssue>,
802 unused_variables: Vec<DeadCodeIssue>,
803 unused_imports: Vec<DeadCodeIssue>,
804 unreachable_code: Vec<DeadCodeIssue>,
805 },
806 If {
807 condition_met: bool,
808 branch: String,
809 results: Vec<StepResult>,
810 },
811 Each {
812 items: Vec<String>,
813 results: Vec<StepResult>,
814 },
815 Parallel {
816 results: Vec<StepResult>,
817 },
818}
819
820#[derive(Debug, Clone, Serialize, Deserialize)]
821#[serde(rename_all = "camelCase")]
822pub struct FileSummary {
823 pub imports: Vec<String>,
824 pub exports: Vec<String>,
825 pub functions: Vec<String>,
826 pub types_used: Vec<String>,
827 pub line_count: usize,
828 pub last_modified: String,
829}
830
831#[derive(Debug, Clone, Serialize, Deserialize)]
832#[serde(rename_all = "camelCase")]
833pub struct LintError {
834 pub file: String,
835 pub line: usize,
836 pub rule: String,
837 pub message: String,
838 pub severity: String,
839}
840
841#[derive(Debug, Clone, Serialize, Deserialize)]
842#[serde(rename_all = "camelCase")]
843pub struct RefactorChange {
844 pub path: String,
845 pub line: usize,
846 pub old_text: String,
847 pub new_text: String,
848}
849
850#[derive(Debug, Clone, Serialize, Deserialize)]
851#[serde(rename_all = "camelCase")]
852pub struct DeadCodeIssue {
853 pub file: String,
854 pub line: usize,
855 pub symbol: String,
856 pub kind: String,
857 pub message: String,
858}
859
860impl StepTypeResult {
861 #[allow(dead_code)]
862 pub fn step_type_name(&self) -> &'static str {
863 match self {
864 Self::Bash { .. } => "bash",
865 Self::Read { .. } => "read",
866 Self::Write { .. } => "write",
867 Self::Patch { .. } => "patch",
868 Self::Mv { .. } => "mv",
869 Self::Cp { .. } => "cp",
870 Self::Rm { .. } => "rm",
871 Self::Mkdir { .. } => "mkdir",
872 Self::Grep { .. } => "grep",
873 Self::Replace { .. } => "replace",
874 Self::Scan { .. } => "scan",
875 Self::Summarize { .. } => "summarize",
876 Self::Extract { .. } => "extract",
877 Self::Diff { .. } => "diff",
878 Self::Lint { .. } => "lint",
879 Self::Template { .. } => "template",
880 Self::Snapshot { .. } => "snapshot",
881 Self::Restore { .. } => "restore",
882 Self::Git { .. } => "git",
883 Self::Http { .. } => "http",
884 Self::Import { .. } => "import",
885 Self::Refactor { .. } => "refactor",
886 Self::Deps { .. } => "deps",
887 Self::Checkpoint { .. } => "checkpoint",
888 Self::Boilerplate { .. } => "boilerplate",
889 Self::DeadCode { .. } => "dead_code",
890 Self::If { .. } => "if",
891 Self::Each { .. } => "each",
892 Self::Parallel { .. } => "parallel",
893 }
894 }
895}
896
897#[derive(Debug, Clone, Serialize, Deserialize)]
898#[serde(rename_all = "camelCase")]
899pub struct Output {
900 pub status: String,
901 pub steps_total: usize,
902 pub steps_ok: usize,
903 pub steps_failed: usize,
904 pub duration_ms: u64,
905 pub results: Vec<StepResult>,
906}
907
908#[cfg(test)]
909mod tests {
910 use super::*;
911 use serde_json;
912
913 #[test]
914 fn test_parse_bash_step() {
915 let json = r#"{
916 "type": "bash",
917 "cmd": "echo hello",
918 "timeout_ms": 5000
919 }"#;
920
921 let step: Step = serde_json::from_str(json).unwrap();
922 match step {
923 Step::Bash {
924 cmd, timeout_ms, ..
925 } => {
926 assert_eq!(cmd, "echo hello");
927 assert_eq!(timeout_ms, Some(5000));
928 }
929 _ => panic!("Expected Bash step"),
930 }
931 }
932
933 #[test]
934 fn test_parse_read_step() {
935 let json = r#"{
936 "type": "read",
937 "path": "src/main.rs",
938 "max_bytes": 1048576
939 }"#;
940
941 let step: Step = serde_json::from_str(json).unwrap();
942 match step {
943 Step::Read {
944 path, max_bytes, ..
945 } => {
946 assert_eq!(path, "src/main.rs");
947 assert_eq!(max_bytes, Some(1048576));
948 }
949 _ => panic!("Expected Read step"),
950 }
951 }
952
953 #[test]
954 fn test_parse_write_step() {
955 let json = r#"{
956 "type": "write",
957 "path": "output.txt",
958 "content": "Hello World",
959 "create_dirs": true
960 }"#;
961
962 let step: Step = serde_json::from_str(json).unwrap();
963 match step {
964 Step::Write {
965 path,
966 content,
967 create_dirs,
968 ..
969 } => {
970 assert_eq!(path, "output.txt");
971 assert_eq!(content, "Hello World");
972 assert!(create_dirs);
973 }
974 _ => panic!("Expected Write step"),
975 }
976 }
977
978 #[test]
979 fn test_parse_grep_step() {
980 let json = r#"{
981 "type": "grep",
982 "pattern": "TODO",
983 "path": "./src",
984 "ext": ["rs", "ts"],
985 "regex": true,
986 "context_lines": 2
987 }"#;
988
989 let step: Step = serde_json::from_str(json).unwrap();
990 match step {
991 Step::Grep {
992 pattern,
993 path,
994 ext,
995 regex,
996 ..
997 } => {
998 assert_eq!(pattern, "TODO");
999 assert_eq!(path, "./src");
1000 assert_eq!(ext, vec!["rs", "ts"]);
1001 assert!(regex);
1002 }
1003 _ => panic!("Expected Grep step"),
1004 }
1005 }
1006
1007 #[test]
1008 fn test_parse_replace_step_with_glob() {
1009 let json = r#"{
1010 "type": "replace",
1011 "pattern": "foo",
1012 "replacement": "bar",
1013 "path": "./src",
1014 "glob": "**/*.rs",
1015 "whole_word": true
1016 }"#;
1017
1018 let step: Step = serde_json::from_str(json).unwrap();
1019 match step {
1020 Step::Replace {
1021 pattern,
1022 replacement,
1023 glob,
1024 whole_word,
1025 ..
1026 } => {
1027 assert_eq!(pattern, "foo");
1028 assert_eq!(replacement, "bar");
1029 assert_eq!(glob, Some("**/*.rs".to_string()));
1030 assert!(whole_word);
1031 }
1032 _ => panic!("Expected Replace step"),
1033 }
1034 }
1035
1036 #[test]
1037 fn test_parse_if_step() {
1038 let json = r#"{
1039 "type": "if",
1040 "condition": { "type": "exists", "path": "./Cargo.toml" },
1041 "then": [
1042 { "type": "bash", "cmd": "echo exists" }
1043 ],
1044 "else": [
1045 { "type": "bash", "cmd": "echo not found" }
1046 ]
1047 }"#;
1048
1049 let step: Step = serde_json::from_str(json).unwrap();
1050 match step {
1051 Step::If {
1052 condition,
1053 then,
1054 else_,
1055 ..
1056 } => {
1057 match condition {
1058 Condition::Exists { path } => {
1059 assert_eq!(path, "./Cargo.toml");
1060 }
1061 _ => panic!("Expected Exists condition"),
1062 }
1063 assert_eq!(then.len(), 1);
1064 assert_eq!(else_.len(), 1);
1065 }
1066 _ => panic!("Expected If step"),
1067 }
1068 }
1069
1070 #[test]
1071 fn test_parse_each_step() {
1072 let json = r#"{
1073 "type": "each",
1074 "over": ["item1", "item2", "item3"],
1075 "as": "file",
1076 "parallel": true,
1077 "step": { "type": "bash", "cmd": "echo {{file}}" }
1078 }"#;
1079
1080 let step: Step = serde_json::from_str(json).unwrap();
1081 match step {
1082 Step::Each {
1083 over,
1084 as_,
1085 parallel,
1086 step,
1087 ..
1088 } => {
1089 match over {
1090 EachOver::List(items) => {
1091 assert_eq!(items.len(), 3);
1092 assert_eq!(items[0], "item1");
1093 }
1094 _ => panic!("Expected List over"),
1095 }
1096 assert_eq!(as_, "file");
1097 assert!(parallel);
1098 match *step {
1099 Step::Bash { cmd, .. } => assert_eq!(cmd, "echo {{file}}"),
1100 _ => panic!("Expected Bash step"),
1101 }
1102 }
1103 _ => panic!("Expected Each step"),
1104 }
1105 }
1106
1107 #[test]
1108 fn test_parse_parallel_step() {
1109 let json = r#"{
1110 "type": "parallel",
1111 "steps": [
1112 { "type": "bash", "cmd": "echo 1" },
1113 { "type": "bash", "cmd": "echo 2" }
1114 ]
1115 }"#;
1116
1117 let step: Step = serde_json::from_str(json).unwrap();
1118 match step {
1119 Step::Parallel { steps, .. } => {
1120 assert_eq!(steps.len(), 2);
1121 }
1122 _ => panic!("Expected Parallel step"),
1123 }
1124 }
1125
1126 #[test]
1127 fn test_parse_payload_with_options() {
1128 let json = r#"{
1129 "name": "test-task",
1130 "description": "A test task",
1131 "version": "1.0.0",
1132 "options": {
1133 "cwd": "./src",
1134 "stopOnError": false,
1135 "timeoutMs": 60000,
1136 "env": {
1137 "NODE_ENV": "production"
1138 }
1139 },
1140 "steps": [
1141 { "type": "bash", "cmd": "echo hello" }
1142 ]
1143 }"#;
1144
1145 let payload: Payload = serde_json::from_str(json).unwrap();
1146 assert_eq!(payload.name, Some("test-task".to_string()));
1147 assert_eq!(payload.description, Some("A test task".to_string()));
1148 assert_eq!(payload.version, Some("1.0.0".to_string()));
1149 assert_eq!(payload.options.cwd, "./src");
1150 assert!(!payload.options.stop_on_error);
1151 assert_eq!(payload.options.timeout_ms, 60000);
1152 assert_eq!(
1153 payload.options.env.get("NODE_ENV"),
1154 Some(&"production".to_string())
1155 );
1156 assert_eq!(payload.steps.len(), 1);
1157 }
1158
1159 #[test]
1160 fn test_parse_step_with_id_and_depends() {
1161 let json = r#"{
1162 "type": "bash",
1163 "id": "step2",
1164 "depends_on": ["step1"],
1165 "cmd": "echo hello"
1166 }"#;
1167
1168 let step: Step = serde_json::from_str(json).unwrap();
1169 match step {
1170 Step::Bash {
1171 id,
1172 depends_on,
1173 cmd,
1174 ..
1175 } => {
1176 assert_eq!(id, "step2");
1177 assert_eq!(depends_on, vec!["step1"]);
1178 assert_eq!(cmd, "echo hello");
1179 }
1180 _ => panic!("Expected Bash step"),
1181 }
1182 }
1183
1184 #[test]
1185 fn test_parse_retry_config() {
1186 let json = r#"{
1187 "type": "bash",
1188 "cmd": "flaky-command",
1189 "retry": {
1190 "count": 3,
1191 "delayMs": 2000,
1192 "backoff": true
1193 }
1194 }"#;
1195
1196 let step: Step = serde_json::from_str(json).unwrap();
1197 match step {
1198 Step::Bash { retry, .. } => {
1199 assert!(retry.is_some());
1200 let retry = retry.unwrap();
1201 assert_eq!(retry.count, 3);
1202 assert_eq!(retry.delay_ms, 2000);
1203 assert!(retry.backoff);
1204 }
1205 _ => panic!("Expected Bash step"),
1206 }
1207 }
1208
1209 #[test]
1210 fn test_parse_import_step() {
1211 let json = r#"{
1212 "type": "import",
1213 "path": "src/main.ts",
1214 "add": ["import { foo } from './foo';"],
1215 "remove": ["import { bar } from './bar';"],
1216 "organize": true
1217 }"#;
1218
1219 let step: Step = serde_json::from_str(json).unwrap();
1220 match step {
1221 Step::Import {
1222 path,
1223 add,
1224 remove,
1225 organize,
1226 ..
1227 } => {
1228 assert_eq!(path, "src/main.ts");
1229 assert_eq!(add.len(), 1);
1230 assert_eq!(add[0], "import { foo } from './foo';");
1231 assert_eq!(remove.len(), 1);
1232 assert!(organize);
1233 }
1234 _ => panic!("Expected Import step"),
1235 }
1236 }
1237
1238 #[test]
1239 fn test_parse_scan_step() {
1240 let json = r#"{
1241 "type": "scan",
1242 "path": "./src",
1243 "depth": 3,
1244 "include": ["rs", "toml"],
1245 "output": "full"
1246 }"#;
1247
1248 let step: Step = serde_json::from_str(json).unwrap();
1249 match step {
1250 Step::Scan {
1251 path,
1252 depth,
1253 include,
1254 output,
1255 ..
1256 } => {
1257 assert_eq!(path, "./src");
1258 assert_eq!(depth, 3);
1259 assert_eq!(include, vec!["rs", "toml"]);
1260 match output {
1261 ScanOutput::Full => {}
1262 _ => panic!("Expected Full output"),
1263 }
1264 }
1265 _ => panic!("Expected Scan step"),
1266 }
1267 }
1268
1269 #[test]
1270 fn test_parse_http_step() {
1271 let json = r#"{
1272 "type": "http",
1273 "method": "POST",
1274 "url": "https://api.example.com/data",
1275 "headers": {
1276 "Authorization": "Bearer token",
1277 "Content-Type": "application/json"
1278 },
1279 "expect_status": 201,
1280 "body": "{\"key\": \"value\"}"
1281 }"#;
1282
1283 let step: Step = serde_json::from_str(json).unwrap();
1284 match step {
1285 Step::Http {
1286 method,
1287 url,
1288 headers,
1289 expect_status,
1290 body,
1291 ..
1292 } => {
1293 assert_eq!(method, "POST");
1294 assert_eq!(url, "https://api.example.com/data");
1295 assert_eq!(
1296 headers.get("Authorization"),
1297 Some(&"Bearer token".to_string())
1298 );
1299 assert_eq!(expect_status, 201);
1300 assert_eq!(body, Some("{\"key\": \"value\"}".to_string()));
1301 }
1302 _ => panic!("Expected Http step"),
1303 }
1304 }
1305
1306 #[test]
1307 fn test_parse_complex_condition() {
1308 let json = r#"{
1309 "type": "if",
1310 "condition": {
1311 "type": "and",
1312 "conditions": [
1313 { "type": "exists", "path": "./Cargo.toml" },
1314 { "type": "not", "condition": { "type": "exists", "path": "./dist" } }
1315 ]
1316 },
1317 "then": [
1318 { "type": "bash", "cmd": "cargo build" }
1319 ],
1320 "else": []
1321 }"#;
1322
1323 let step: Step = serde_json::from_str(json).unwrap();
1324 match step {
1325 Step::If {
1326 condition, then, ..
1327 } => {
1328 match condition {
1329 Condition::And { conditions } => {
1330 assert_eq!(conditions.len(), 2);
1331 match &conditions[0] {
1332 Condition::Exists { path } => assert_eq!(path, "./Cargo.toml"),
1333 _ => panic!("Expected Exists"),
1334 }
1335 match &conditions[1] {
1336 Condition::Not { condition } => match &**condition {
1337 Condition::Exists { path } => assert_eq!(path, "./dist"),
1338 _ => panic!("Expected Exists inside Not"),
1339 },
1340 _ => panic!("Expected Not"),
1341 }
1342 }
1343 _ => panic!("Expected And condition"),
1344 }
1345 assert_eq!(then.len(), 1);
1346 }
1347 _ => panic!("Expected If step"),
1348 }
1349 }
1350
1351 #[test]
1352 fn test_step_get_id() {
1353 let step = Step::Bash {
1354 id: "my-step".to_string(),
1355 depends_on: vec![],
1356 cmd: "echo hello".to_string(),
1357 timeout_ms: None,
1358 retry: None,
1359 };
1360 assert_eq!(step.get_id(), "my-step");
1361 }
1362
1363 #[test]
1364 fn test_step_get_depends_on() {
1365 let step = Step::Bash {
1366 id: "step2".to_string(),
1367 depends_on: vec!["step1".to_string()],
1368 cmd: "echo hello".to_string(),
1369 timeout_ms: None,
1370 retry: None,
1371 };
1372 assert_eq!(step.get_depends_on(), &["step1".to_string()]);
1373 }
1374
1375 #[test]
1376 fn test_default_options() {
1377 let options = Options::default();
1378 assert_eq!(options.cwd, ".");
1379 assert!(options.stop_on_error);
1380 assert_eq!(options.timeout_ms, 30000);
1381 assert!(!options.cache);
1382 assert!(options.cache_dir.is_none());
1383 }
1384
1385 #[test]
1386 fn test_serialize_output() {
1387 let output = Output {
1388 status: "ok".to_string(),
1389 steps_total: 5,
1390 steps_ok: 5,
1391 steps_failed: 0,
1392 duration_ms: 1234,
1393 results: vec![],
1394 };
1395
1396 let json = serde_json::to_string(&output).unwrap();
1397 assert!(json.contains("\"status\":\"ok\""));
1398 assert!(json.contains("\"stepsTotal\":5"));
1399 assert!(json.contains("\"durationMs\":1234"));
1400 }
1401}