wrkflw 0.8.0

A GitHub Actions workflow validator and executor
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
use bollard::Docker;
use clap::{Parser, Subcommand, ValueEnum};
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;

mod prefilter;
mod run_workflow_cmd;
mod watch_cmd;

#[derive(Debug, Clone, ValueEnum)]
pub(crate) enum RuntimeChoice {
    /// Use Docker containers for isolation
    Docker,
    /// Use Podman containers for isolation
    Podman,
    /// Use process emulation mode (no containers, UNSAFE)
    Emulation,
    /// Use secure emulation mode with sandboxing (recommended for untrusted code)
    SecureEmulation,
}

impl From<RuntimeChoice> for wrkflw_executor::RuntimeType {
    fn from(choice: RuntimeChoice) -> Self {
        match choice {
            RuntimeChoice::Docker => wrkflw_executor::RuntimeType::Docker,
            RuntimeChoice::Podman => wrkflw_executor::RuntimeType::Podman,
            RuntimeChoice::Emulation => wrkflw_executor::RuntimeType::Emulation,
            RuntimeChoice::SecureEmulation => wrkflw_executor::RuntimeType::SecureEmulation,
        }
    }
}

#[derive(Debug, Parser)]
#[command(
    name = "wrkflw",
    about = "GitHub & GitLab CI/CD validator and executor",
    version,
    long_about = "A CI/CD validator and executor that runs workflows locally.\n\nExamples:\n  wrkflw validate                             # Validate all workflows in .github/workflows\n  wrkflw run .github/workflows/build.yml      # Run a specific workflow\n  wrkflw run .gitlab-ci.yml                   # Run a GitLab CI pipeline\n  wrkflw --verbose run .github/workflows/build.yml  # Run with more output\n  wrkflw --debug run .github/workflows/build.yml    # Run with detailed debug information\n  wrkflw run --runtime emulation .github/workflows/build.yml  # Use emulation mode instead of containers\n  wrkflw run --runtime podman .github/workflows/build.yml     # Use Podman instead of Docker\n  wrkflw run --preserve-containers-on-failure .github/workflows/build.yml  # Keep failed containers for debugging"
)]
struct Wrkflw {
    #[command(subcommand)]
    command: Option<Commands>,

    /// Run in verbose mode with detailed output
    #[arg(short, long, global = true)]
    verbose: bool,

    /// Run in debug mode with extensive execution details
    #[arg(short, long, global = true)]
    debug: bool,
}

#[derive(Debug, Subcommand)]
enum Commands {
    /// Validate workflow or pipeline files
    Validate {
        /// Path(s) to workflow/pipeline file(s) or directory(ies) (defaults to .github/workflows if none provided)
        #[arg(value_name = "path", num_args = 0..)]
        paths: Vec<PathBuf>,

        /// Explicitly validate as GitLab CI/CD pipeline
        #[arg(long)]
        gitlab: bool,

        /// Set exit code to 1 on validation failure
        #[arg(long = "exit-code", default_value_t = true)]
        exit_code: bool,

        /// Don't set exit code to 1 on validation failure (overrides --exit-code)
        #[arg(long = "no-exit-code", conflicts_with = "exit_code")]
        no_exit_code: bool,
    },

    /// Execute workflow or pipeline files locally
    Run {
        /// Path to workflow/pipeline file to execute
        path: PathBuf,

        /// Container runtime to use (docker, podman, emulation, secure-emulation)
        #[arg(short, long, value_enum, default_value = "docker")]
        runtime: RuntimeChoice,

        /// Show 'Would execute GitHub action' messages in emulation mode
        #[arg(long, default_value_t = false)]
        show_action_messages: bool,

        /// Preserve Docker containers on failure for debugging (Docker mode only)
        #[arg(long)]
        preserve_containers_on_failure: bool,

        /// Explicitly run as GitLab CI/CD pipeline
        #[arg(long)]
        gitlab: bool,

        /// Run only a specific job by name
        #[arg(long)]
        job: Option<String>,

        /// Simulate a specific event type for trigger filtering (e.g., push, pull_request)
        #[arg(long)]
        event: Option<String>,

        /// Use git diff to determine changed files for trigger filtering
        #[arg(long)]
        diff: bool,

        /// Manually specify changed files (comma-separated) for trigger filtering
        #[arg(long, value_delimiter = ',')]
        changed_files: Option<Vec<String>>,

        /// Base ref for diff comparison.
        ///
        /// Omit to auto-detect: tries `origin/HEAD`, then `main`/`master`,
        /// then `HEAD~1`. Pass `HEAD` to compare working tree against the
        /// last commit (uncommitted changes only).
        #[arg(long)]
        diff_base: Option<String>,

        /// Head ref for diff comparison (default: working tree)
        #[arg(long)]
        diff_head: Option<String>,

        /// Target/base branch for pull_request events (e.g. main).
        /// GitHub Actions evaluates `branches:` filters on `pull_request`
        /// against the base branch — set this to simulate a PR locally.
        #[arg(long)]
        base_branch: Option<String>,

        /// Activity type for events that support it (e.g. `opened`,
        /// `synchronize` for pull_request). Required when simulating an
        /// event whose workflows use `types:` filters — without it, every
        /// such workflow is reported as skipped for "no activity type".
        #[arg(long)]
        activity_type: Option<String>,

        /// Reject degraded filter contexts (missing base branch on
        /// `pull_request`, `--event` without changed-file input, etc.)
        /// with a hard error instead of a log warning.
        ///
        /// Defaults to `true` so the CLI fails loudly on a
        /// silently-under-filtered run — the opposite of the
        /// warn-and-proceed behavior that produced "why did my
        /// workflow not fire?" tickets. Pass `--no-strict-filter` to
        /// opt back into the legacy warning behavior for scripts that
        /// have already adapted to it.
        #[arg(long = "strict-filter", default_value_t = true)]
        strict_filter: bool,

        /// Opposite of `--strict-filter`; re-enables the legacy
        /// warn-and-proceed behavior for degraded contexts. Kept as
        /// a separate flag instead of `--no-strict-filter` so clap's
        /// `conflicts_with` makes the intent explicit at the call
        /// site.
        #[arg(long = "no-strict-filter", conflicts_with = "strict_filter")]
        no_strict_filter: bool,
    },

    /// Watch for file changes and re-run affected workflows.
    ///
    /// On Ctrl+C the watcher drains the current cycle gracefully:
    /// workflows already executing finish, the trigger-filter state
    /// is flushed, and the signal is passed through to the cleanup
    /// handler that reaps Docker containers and tempdirs. A hard
    /// exit only happens if the graceful drain is still running
    /// after ~10s — long enough for normal teardown, short enough
    /// that a hung subprocess cannot wedge the session.
    Watch {
        /// Path to workflow file or directory (defaults to .github/workflows)
        path: Option<PathBuf>,

        /// Container runtime to use (docker, podman, emulation, secure-emulation)
        #[arg(short, long, value_enum, default_value = "docker")]
        runtime: RuntimeChoice,

        /// Debounce interval in milliseconds
        #[arg(long, default_value = "500")]
        debounce: u64,

        /// Event type to simulate (default: push)
        #[arg(long, default_value = "push")]
        event: String,

        /// Show 'Would execute GitHub action' messages in emulation mode
        #[arg(long, default_value_t = false)]
        show_action_messages: bool,

        /// Preserve Docker containers on failure for debugging (Docker mode only)
        #[arg(long)]
        preserve_containers_on_failure: bool,

        /// Maximum number of workflows that may execute concurrently per cycle
        #[arg(long, default_value_t = wrkflw_watcher::DEFAULT_MAX_CONCURRENT_EXECUTIONS)]
        max_concurrency: usize,

        /// Target/base branch for pull_request events (e.g. main).
        /// Required if you watch with `--event pull_request` and any workflow
        /// uses `branches:` to constrain the target branch.
        #[arg(long)]
        base_branch: Option<String>,

        /// Activity type for events that support it (e.g. `opened`,
        /// `synchronize` for pull_request). Required when watching an
        /// event whose workflows use `types:` filters — without it, every
        /// such workflow is silently rejected for "no activity type".
        #[arg(long)]
        activity_type: Option<String>,

        /// Upper bound on the debouncer's pending-event set. Events
        /// past this count during a churn burst are dropped and
        /// surfaced as a per-cycle warning so the user sees that
        /// something was missed. Omit the flag to use the debouncer's
        /// built-in default, which is sized for typical workloads.
        ///
        /// The flag is `Option<usize>` rather than `usize` with a
        /// sentinel `0 = default` value because `--max-pending-events 0`
        /// reads as "unbounded" to most users — the convention
        /// violation was flagged in review. `0` is now explicitly
        /// rejected at startup (warning + fall through to default)
        /// since a zero cap would drop every event and render the
        /// watcher useless.
        #[arg(long)]
        max_pending_events: Option<usize>,

        /// Extra directory names to ignore in addition to the built-in
        /// list (`.git`, `target`, `node_modules`, `.build`, `build`,
        /// `dist`, `__pycache__`, `.tox`, `.mypy_cache`, `.pytest_cache`,
        /// `.venv`, `venv`). Matched by directory-component name, not
        /// glob or path — a user file literally named `.terraform` is
        /// never silenced; only events whose parent path contains a
        /// `.terraform/` component are dropped. Pass multiple times
        /// or as a comma-separated list: `--ignore-dir .terraform
        /// --ignore-dir coverage` or `--ignore-dir .terraform,coverage`.
        #[arg(long = "ignore-dir", value_delimiter = ',')]
        ignore_dirs: Vec<String>,

        /// Reject degraded filter contexts (missing base branch on
        /// `pull_request`, unknown events, etc.) with a hard error
        /// instead of a log warning. Defaults to `true` so watch
        /// mode fails loudly on misconfiguration rather than running
        /// a session-long "0 triggered" stream.
        #[arg(long = "strict-filter", default_value_t = true)]
        strict_filter: bool,

        /// Opposite of `--strict-filter`; re-enables the legacy
        /// warn-and-proceed behavior for degraded contexts.
        #[arg(long = "no-strict-filter", conflicts_with = "strict_filter")]
        no_strict_filter: bool,
    },

    /// Open TUI interface to manage workflows
    #[cfg(feature = "tui")]
    Tui {
        /// Path to workflow file or directory (defaults to .github/workflows)
        path: Option<PathBuf>,

        /// Container runtime to use (docker, podman, emulation, secure-emulation)
        #[arg(short, long, value_enum, default_value = "docker")]
        runtime: RuntimeChoice,

        /// Show 'Would execute GitHub action' messages in emulation mode
        #[arg(long, default_value_t = false)]
        show_action_messages: bool,

        /// Preserve Docker containers on failure for debugging (Docker mode only)
        #[arg(long)]
        preserve_containers_on_failure: bool,
    },

    /// Trigger a GitHub workflow remotely
    Trigger {
        /// Name of the workflow file (without .yml extension)
        workflow: String,

        /// Branch to run the workflow on
        #[arg(short, long)]
        branch: Option<String>,

        /// Key-value inputs for the workflow in format key=value
        #[arg(short, long, value_parser = parse_key_val)]
        input: Option<Vec<(String, String)>>,
    },

    /// Trigger a GitLab pipeline remotely
    TriggerGitlab {
        /// Branch to run the pipeline on
        #[arg(short, long)]
        branch: Option<String>,

        /// Key-value variables for the pipeline in format key=value
        #[arg(short = 'V', long, value_parser = parse_key_val)]
        variable: Option<Vec<(String, String)>>,
    },

    /// List available workflows and pipelines
    List {
        /// Show jobs within each workflow/pipeline
        #[arg(long)]
        jobs: bool,
    },
}

// Parser function for key-value pairs
fn parse_key_val(s: &str) -> Result<(String, String), String> {
    let pos = s
        .find('=')
        .ok_or_else(|| format!("invalid KEY=value: no `=` found in `{}`", s))?;

    Ok((s[..pos].to_string(), s[pos + 1..].to_string()))
}

// Make this function public for testing? Or move to a utils/cleanup mod?
// Or call wrkflw_executor::cleanup and wrkflw_runtime::cleanup directly?
// Let's try calling them directly for now.
async fn cleanup_on_exit() {
    // Clean up Docker resources if available, but don't let it block indefinitely
    match tokio::time::timeout(std::time::Duration::from_secs(3), async {
        match Docker::connect_with_local_defaults() {
            Ok(docker) => {
                // Assuming cleanup_resources exists in executor crate
                wrkflw_executor::cleanup_resources(&docker).await;
            }
            Err(_) => {
                // Docker not available
                wrkflw_logging::info("Docker not available, skipping Docker cleanup");
            }
        }
    })
    .await
    {
        Ok(_) => wrkflw_logging::debug("Docker cleanup completed successfully"),
        Err(_) => wrkflw_logging::warning(
            "Docker cleanup timed out after 3 seconds, continuing with shutdown",
        ),
    }

    // Always clean up emulation resources
    match tokio::time::timeout(
        std::time::Duration::from_secs(2),
        // Assuming cleanup_resources exists in wrkflw_runtime::emulation module
        wrkflw_runtime::emulation::cleanup_resources(),
    )
    .await
    {
        Ok(_) => wrkflw_logging::debug("Emulation cleanup completed successfully"),
        Err(_) => wrkflw_logging::warning("Emulation cleanup timed out, continuing with shutdown"),
    }

    wrkflw_logging::info("Resource cleanup completed");
}

async fn handle_signals() {
    // Set up a hard exit timer in case cleanup takes too long
    // This ensures the app always exits even if Docker operations are stuck
    let hard_exit_time = std::time::Duration::from_secs(10);

    // Wait for Ctrl+C
    match tokio::signal::ctrl_c().await {
        Ok(_) => {
            println!("Received Ctrl+C, shutting down and cleaning up...");
        }
        Err(e) => {
            // Log the error but continue with cleanup
            eprintln!("Warning: Failed to properly listen for ctrl+c event: {}", e);
            println!("Shutting down and cleaning up...");
        }
    }

    // Set up a watchdog thread that will force exit if cleanup takes too long
    // This is important because Docker operations can sometimes hang indefinitely
    let _ = std::thread::spawn(move || {
        std::thread::sleep(hard_exit_time);
        eprintln!(
            "Cleanup taking too long (over {} seconds), forcing exit...",
            hard_exit_time.as_secs()
        );
        wrkflw_logging::error("Forced exit due to cleanup timeout");
        std::process::exit(1);
    });

    // Clean up containers
    cleanup_on_exit().await;

    // Exit with success status - the force exit thread will be terminated automatically
    std::process::exit(0);
}

/// Determines if a file is a GitLab CI/CD pipeline based on its name and content
pub(crate) fn is_gitlab_pipeline(path: &Path) -> bool {
    // First check the file name
    if let Some(file_name) = path.file_name() {
        if let Some(file_name_str) = file_name.to_str() {
            if file_name_str == ".gitlab-ci.yml" || file_name_str.ends_with("gitlab-ci.yml") {
                return true;
            }
        }
    }

    // Check if file is in .gitlab/ci directory
    if let Some(parent) = path.parent() {
        if let Some(parent_str) = parent.to_str() {
            if parent_str.ends_with(".gitlab/ci")
                && path
                    .extension()
                    .is_some_and(|ext| ext == "yml" || ext == "yaml")
            {
                return true;
            }
        }
    }

    // If file exists, check the content
    if path.exists() {
        if let Ok(content) = std::fs::read_to_string(path) {
            // GitLab CI/CD pipelines typically have stages, before_script, after_script at the top level
            if content.contains("stages:")
                || content.contains("before_script:")
                || content.contains("after_script:")
            {
                // Check for GitHub Actions specific keys that would indicate it's not GitLab
                if !content.contains("on:")
                    && !content.contains("runs-on:")
                    && !content.contains("uses:")
                {
                    return true;
                }
            }
        }
    }

    false
}

#[tokio::main]
async fn main() {
    // Gracefully handle Broken pipe (EPIPE) when output is piped (e.g., to `head`)
    let default_panic_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        let mut is_broken_pipe = false;
        if let Some(s) = info.payload().downcast_ref::<&str>() {
            if s.contains("Broken pipe") {
                is_broken_pipe = true;
            }
        }
        if let Some(s) = info.payload().downcast_ref::<String>() {
            if s.contains("Broken pipe") {
                is_broken_pipe = true;
            }
        }
        if is_broken_pipe {
            // Treat as a successful, short-circuited exit
            std::process::exit(0);
        }
        // Fallback to the default hook for all other panics
        default_panic_hook(info);
    }));

    let cli = Wrkflw::parse();
    let verbose = cli.verbose;
    let debug = cli.debug;

    // Set log level based on command line flags
    if debug {
        wrkflw_logging::set_log_level(wrkflw_logging::LogLevel::Debug);
        wrkflw_logging::debug("Debug mode enabled - showing detailed logs");
    } else if verbose {
        wrkflw_logging::set_log_level(wrkflw_logging::LogLevel::Info);
        wrkflw_logging::info("Verbose mode enabled");
    } else {
        wrkflw_logging::set_log_level(wrkflw_logging::LogLevel::Warning);
    }

    // Setup a Ctrl+C handler that runs in the background
    tokio::spawn(handle_signals());

    match &cli.command {
        Some(Commands::Validate {
            paths,
            gitlab,
            exit_code,
            no_exit_code,
        }) => {
            // Determine the paths to validate (default to .github/workflows when none provided)
            let validate_paths: Vec<PathBuf> = if paths.is_empty() {
                vec![PathBuf::from(".github/workflows")]
            } else {
                paths.clone()
            };

            // Determine if we're validating a GitLab pipeline based on the --gitlab flag or file detection
            let force_gitlab = *gitlab;
            let mut validation_failed = false;

            for validate_path in validate_paths {
                // Check if the path exists; if not, mark failure but continue
                if !validate_path.exists() {
                    eprintln!("Error: Path does not exist: {}", validate_path.display());
                    validation_failed = true;
                    continue;
                }

                if validate_path.is_dir() {
                    // Validate all workflow files in the directory
                    let rd = match std::fs::read_dir(&validate_path) {
                        Ok(rd) => rd,
                        Err(e) => {
                            eprintln!(
                                "Failed to read directory {}: {}",
                                validate_path.display(),
                                e
                            );
                            validation_failed = true;
                            continue;
                        }
                    };
                    let entries = rd
                        .filter_map(|entry| entry.ok())
                        .filter(|entry| {
                            entry.path().is_file()
                                && entry
                                    .path()
                                    .extension()
                                    .is_some_and(|ext| ext == "yml" || ext == "yaml")
                        })
                        .collect::<Vec<_>>();

                    println!(
                        "Validating {} workflow file(s) in {}...",
                        entries.len(),
                        validate_path.display()
                    );

                    for entry in entries {
                        let path = entry.path();
                        let is_gitlab = force_gitlab || is_gitlab_pipeline(&path);

                        let file_failed = if is_gitlab {
                            validate_gitlab_pipeline(&path, verbose)
                        } else {
                            validate_github_workflow(&path, verbose)
                        };

                        if file_failed {
                            validation_failed = true;
                        }
                    }
                } else {
                    // Validate a single workflow file
                    let is_gitlab = force_gitlab || is_gitlab_pipeline(&validate_path);

                    let file_failed = if is_gitlab {
                        validate_gitlab_pipeline(&validate_path, verbose)
                    } else {
                        validate_github_workflow(&validate_path, verbose)
                    };

                    if file_failed {
                        validation_failed = true;
                    }
                }
            }

            // Set exit code if validation failed and exit_code flag is true (and no_exit_code is false)
            if validation_failed && *exit_code && !*no_exit_code {
                std::process::exit(1);
            }
        }
        Some(Commands::Run {
            path,
            runtime,
            show_action_messages,
            preserve_containers_on_failure,
            gitlab,
            job,
            event,
            diff,
            changed_files,
            diff_base,
            diff_head,
            base_branch,
            activity_type,
            strict_filter,
            no_strict_filter,
        }) => {
            run_workflow_cmd::run(run_workflow_cmd::RunCtx {
                path: path.clone(),
                runtime: runtime.clone(),
                show_action_messages: *show_action_messages,
                preserve_containers_on_failure: *preserve_containers_on_failure,
                gitlab: *gitlab,
                job: job.clone(),
                event: event.clone(),
                diff: *diff,
                changed_files: changed_files.clone(),
                diff_base: diff_base.clone(),
                diff_head: diff_head.clone(),
                base_branch: base_branch.clone(),
                activity_type: activity_type.clone(),
                strict_filter: *strict_filter,
                no_strict_filter: *no_strict_filter,
                verbose,
            })
            .await;
        }
        Some(Commands::Watch {
            path,
            runtime,
            debounce,
            event,
            show_action_messages,
            preserve_containers_on_failure,
            max_concurrency,
            base_branch,
            activity_type,
            max_pending_events,
            ignore_dirs,
            strict_filter,
            no_strict_filter,
        }) => {
            watch_cmd::run(watch_cmd::WatchCtx {
                path: path.clone(),
                runtime: runtime.clone(),
                debounce: *debounce,
                event: event.clone(),
                show_action_messages: *show_action_messages,
                preserve_containers_on_failure: *preserve_containers_on_failure,
                max_concurrency: *max_concurrency,
                base_branch: base_branch.clone(),
                activity_type: activity_type.clone(),
                max_pending_events: *max_pending_events,
                ignore_dirs: ignore_dirs.clone(),
                strict_filter: *strict_filter,
                no_strict_filter: *no_strict_filter,
                verbose,
            })
            .await;
        }
        Some(Commands::TriggerGitlab { branch, variable }) => {
            // Convert optional Vec<(String, String)> to Option<HashMap<String, String>>
            let variables = variable
                .as_ref()
                .map(|v| v.iter().cloned().collect::<HashMap<String, String>>());

            // Trigger the pipeline
            if let Err(e) = wrkflw_gitlab::trigger_pipeline(branch.as_deref(), variables).await {
                eprintln!("Error triggering GitLab pipeline: {}", e);
                std::process::exit(1);
            }
        }
        #[cfg(feature = "tui")]
        Some(Commands::Tui {
            path,
            runtime,
            show_action_messages,
            preserve_containers_on_failure,
        }) => {
            // Set runtime type based on the runtime choice
            let runtime_type = runtime.clone().into();

            // Call the TUI implementation from the ui crate
            if let Err(e) = wrkflw_ui::run_wrkflw_tui(
                path.as_ref(),
                runtime_type,
                verbose,
                *preserve_containers_on_failure,
                *show_action_messages,
            )
            .await
            {
                eprintln!("Error running TUI: {}", e);
                std::process::exit(1);
            }
        }
        Some(Commands::Trigger {
            workflow,
            branch,
            input,
        }) => {
            // Convert optional Vec<(String, String)> to Option<HashMap<String, String>>
            let inputs = input
                .as_ref()
                .map(|i| i.iter().cloned().collect::<HashMap<String, String>>());

            // Trigger the workflow
            if let Err(e) =
                wrkflw_github::trigger_workflow(workflow, branch.as_deref(), inputs).await
            {
                eprintln!("Error triggering GitHub workflow: {}", e);
                std::process::exit(1);
            }
        }
        Some(Commands::List { jobs }) => {
            list_workflows_and_pipelines(verbose, *jobs);
        }
        None => {
            #[cfg(feature = "tui")]
            {
                // Launch TUI by default when no command is provided
                let runtime_type = wrkflw_executor::RuntimeType::Docker;

                // Call the TUI implementation from the ui crate with default path
                if let Err(e) =
                    wrkflw_ui::run_wrkflw_tui(None, runtime_type, verbose, false, false).await
                {
                    eprintln!("Error running TUI: {}", e);
                    std::process::exit(1);
                }
            }
            #[cfg(not(feature = "tui"))]
            {
                use clap::CommandFactory;
                Wrkflw::command().print_help().unwrap();
                println!();
            }
        }
    }
}

/// Validate a GitHub workflow file
/// Returns true if validation failed, false if it passed
fn validate_github_workflow(path: &Path, verbose: bool) -> bool {
    use wrkflw_ui::cli_style;
    print!("Validating GitHub workflow file: {}... ", path.display());

    match wrkflw_evaluator::evaluate_workflow_file(path, verbose) {
        Ok(result) => {
            if result.is_valid {
                println!("{}", cli_style::success("Valid"));
                if verbose {
                    println!("{}", cli_style::dim("  All validation checks passed"));
                }
            } else {
                println!("{}", cli_style::error("Invalid"));
                for (i, issue) in result.issues.iter().enumerate() {
                    println!("{}", cli_style::indent(&format!("{}. {}", i + 1, issue)));
                }
            }
            !result.is_valid
        }
        Err(e) => {
            println!("{}", cli_style::error("Error"));
            eprintln!("  {}", e);
            true
        }
    }
}

/// Validate a GitLab CI/CD pipeline file
/// Returns true if validation failed, false if it passed
fn validate_gitlab_pipeline(path: &Path, verbose: bool) -> bool {
    use wrkflw_ui::cli_style;
    print!("Validating GitLab CI pipeline file: {}... ", path.display());

    match wrkflw_parser::gitlab::parse_pipeline(path) {
        Ok(pipeline) => {
            println!("{}", cli_style::success("Valid syntax"));

            let validation_result = wrkflw_validators::validate_gitlab_pipeline(&pipeline);

            if !validation_result.is_valid {
                println!("{}", cli_style::warning("Validation issues:"));
                for issue in validation_result.issues {
                    println!("{}", cli_style::indent(&format!("- {}", issue)));
                }
                true
            } else {
                if verbose {
                    println!("{}", cli_style::success("All validation checks passed"));
                }
                false // Validation passed
            }
        }
        Err(e) => {
            println!("{}", cli_style::error("Invalid"));
            eprintln!("Validation failed: {}", e);
            true
        }
    }
}

/// List available workflows and pipelines in the repository
fn list_workflows_and_pipelines(verbose: bool, show_jobs: bool) {
    use colored::Colorize;
    use wrkflw_ui::cli_style;

    // Check for GitHub workflows
    let github_path = PathBuf::from(".github/workflows");
    if github_path.exists() && github_path.is_dir() {
        println!("{}", "GitHub Workflows".bold().cyan());

        match std::fs::read_dir(&github_path) {
            Ok(rd) => {
                let entries: Vec<_> = rd
                    .filter_map(|entry| entry.ok())
                    .filter(|entry| {
                        entry.path().is_file()
                            && entry
                                .path()
                                .extension()
                                .is_some_and(|ext| ext == "yml" || ext == "yaml")
                    })
                    .collect();

                if entries.is_empty() {
                    println!(
                        "{}",
                        cli_style::dim("  No workflow files found in .github/workflows")
                    );
                } else {
                    for (i, entry) in entries.iter().enumerate() {
                        let is_last = i == entries.len() - 1;
                        let connector = if is_last {
                            "\u{2514}\u{2500}\u{2500}"
                        } else {
                            "\u{251C}\u{2500}\u{2500}"
                        };
                        println!("{} {}", connector.dimmed(), entry.path().display());
                        if show_jobs {
                            let prefix = if is_last { "    " } else { "\u{2502}   " };
                            match wrkflw_parser::workflow::parse_workflow(&entry.path()) {
                                Ok(workflow) => {
                                    let mut job_names: Vec<&String> =
                                        workflow.jobs.keys().collect();
                                    job_names.sort();
                                    println!(
                                        "{}{}",
                                        prefix.dimmed(),
                                        format!(
                                            "Jobs: {}",
                                            job_names
                                                .iter()
                                                .map(|s| s.as_str())
                                                .collect::<Vec<_>>()
                                                .join(", ")
                                        )
                                        .dimmed()
                                    );
                                }
                                Err(e) => {
                                    eprintln!("{}Could not parse workflow: {}", prefix, e);
                                }
                            }
                        }
                    }
                }
            }
            Err(e) => {
                eprintln!(
                    "{}",
                    cli_style::error(&format!(
                        "Failed to read directory {}: {}",
                        github_path.display(),
                        e
                    ))
                );
            }
        }
    } else {
        println!(
            "{}",
            cli_style::dim("GitHub Workflows: No .github/workflows directory found")
        );
    }

    // Check for GitLab CI pipeline
    let gitlab_path = PathBuf::from(".gitlab-ci.yml");
    if gitlab_path.exists() && gitlab_path.is_file() {
        println!("\n{}", "GitLab CI Pipeline".bold().cyan());
        println!(
            "{} {}",
            "\u{2514}\u{2500}\u{2500}".dimmed(),
            gitlab_path.display()
        );
        if show_jobs {
            match wrkflw_parser::gitlab::parse_pipeline(Path::new(".gitlab-ci.yml")) {
                Ok(pipeline) => {
                    let mut job_names: Vec<&String> = pipeline.jobs.keys().collect();
                    job_names.sort();
                    println!(
                        "    {}",
                        format!(
                            "Jobs: {}",
                            job_names
                                .iter()
                                .map(|s| s.as_str())
                                .collect::<Vec<_>>()
                                .join(", ")
                        )
                        .dimmed()
                    );
                }
                Err(e) => {
                    eprintln!("    Could not parse pipeline: {}", e);
                }
            }
        }
    } else {
        println!(
            "{}",
            cli_style::dim("GitLab CI Pipeline: No .gitlab-ci.yml file found")
        );
    }

    // Check for other GitLab CI pipeline files
    if verbose {
        println!(
            "\n{}",
            cli_style::info("Searching for other GitLab CI pipeline files...")
        );

        let entries = walkdir::WalkDir::new(".")
            .follow_links(true)
            .into_iter()
            .filter_map(|entry| entry.ok())
            .filter(|entry| {
                entry.path().is_file()
                    && entry
                        .file_name()
                        .to_string_lossy()
                        .ends_with("gitlab-ci.yml")
                    && entry.path() != gitlab_path
            })
            .collect::<Vec<_>>();

        if !entries.is_empty() {
            println!("{}", "Additional GitLab CI Pipeline files:".bold());
            for entry in entries {
                println!(
                    "{} {}",
                    "\u{2514}\u{2500}\u{2500}".dimmed(),
                    entry.path().display()
                );
            }
        }
    }
}