Skip to main content

rok_cli/
schema.rs

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}