ggen_cli_lib/cmds/ci/
release.rs

1//! Release workflow management and execution.
2//!
3//! This module provides functionality to run release workflows locally using act,
4//! with support for retry logic, metrics collection, timeout control, and dry runs.
5//! It integrates with GitHub Actions workflows and supports parallel execution.
6//!
7//! # Examples
8//!
9//! ```bash
10//! ggen ci release run --workflow-only --json
11//! ggen ci release retry --max-retries 5 --verbose
12//! ggen ci release metrics --homebrew-only
13//! ggen ci release timeout --timeout 3600
14//! ggen ci release dry-run --json
15//! ```
16//!
17//! # Errors
18//!
19//! Returns errors if act execution fails, workflows don't exist, or if
20//! system requirements are not met.
21
22use clap::{Args, Subcommand};
23use ggen_utils::error::Result;
24// CLI output only - no library logging
25
26#[cfg_attr(test, mockall::automock)]
27pub trait ReleaseWorkflowRunner {
28    fn run(&self, args: &ReleaseRunArgs) -> Result<ReleaseResult>;
29    fn run_with_retry(&self, args: &ReleaseRetryArgs) -> Result<ReleaseResult>;
30    fn run_with_metrics(&self, args: &ReleaseMetricsArgs) -> Result<ReleaseResult>;
31    fn run_with_timeout(&self, args: &ReleaseTimeoutArgs) -> Result<ReleaseResult>;
32    fn dry_run(&self, args: &ReleaseDryRunArgs) -> Result<ReleaseResult>;
33}
34
35#[derive(Debug, Clone)]
36pub struct ReleaseResult {
37    pub stdout: String,
38    pub stderr: String,
39    pub success: bool,
40}
41
42#[derive(Args, Debug)]
43pub struct ReleaseArgs {
44    #[command(subcommand)]
45    pub action: ReleaseAction,
46}
47
48#[derive(Subcommand, Debug)]
49pub enum ReleaseAction {
50    /// Run release workflows locally with act
51    Run(RunArgs),
52
53    /// Run release workflows with retry logic
54    Retry(RetryArgs),
55
56    /// Run release workflows with metrics collection
57    Metrics(MetricsArgs),
58
59    /// Run release workflows with custom timeout
60    Timeout(TimeoutArgs),
61
62    /// Show what would be executed without running
63    DryRun(DryRunArgs),
64}
65
66// Type aliases for trait methods
67pub type ReleaseRunArgs = RunArgs;
68pub type ReleaseRetryArgs = RetryArgs;
69pub type ReleaseMetricsArgs = MetricsArgs;
70pub type ReleaseTimeoutArgs = TimeoutArgs;
71pub type ReleaseDryRunArgs = DryRunArgs;
72
73#[derive(Args, Debug, PartialEq)]
74pub struct RunArgs {
75    /// Run only the release workflow
76    #[arg(long)]
77    pub workflow_only: bool,
78
79    /// Run only the homebrew-release workflow
80    #[arg(long)]
81    pub homebrew_only: bool,
82
83    /// Output in JSON format
84    #[arg(long)]
85    pub json: bool,
86
87    /// Enable verbose output
88    #[arg(long)]
89    pub verbose: bool,
90
91    /// Enable debug output
92    #[arg(long)]
93    pub debug: bool,
94}
95
96#[derive(Args, Debug, PartialEq)]
97pub struct RetryArgs {
98    /// Run only the release workflow
99    #[arg(long)]
100    pub workflow_only: bool,
101
102    /// Run only the homebrew-release workflow
103    #[arg(long)]
104    pub homebrew_only: bool,
105
106    /// Maximum retry attempts [default: 3]
107    #[arg(long, default_value = "3")]
108    pub max_retries: u8,
109
110    /// Output in JSON format
111    #[arg(long)]
112    pub json: bool,
113
114    /// Enable verbose output
115    #[arg(long)]
116    pub verbose: bool,
117}
118
119#[derive(Args, Debug, PartialEq)]
120pub struct MetricsArgs {
121    /// Run only the release workflow
122    #[arg(long)]
123    pub workflow_only: bool,
124
125    /// Run only the homebrew-release workflow
126    #[arg(long)]
127    pub homebrew_only: bool,
128
129    /// Output in JSON format
130    #[arg(long)]
131    pub json: bool,
132
133    /// Enable verbose output
134    #[arg(long)]
135    pub verbose: bool,
136}
137
138#[derive(Args, Debug, PartialEq)]
139pub struct TimeoutArgs {
140    /// Workflow timeout in seconds [default: 1800]
141    #[arg(long, default_value = "1800")]
142    pub timeout: u32,
143
144    /// Run only the release workflow
145    #[arg(long)]
146    pub workflow_only: bool,
147
148    /// Run only the homebrew-release workflow
149    #[arg(long)]
150    pub homebrew_only: bool,
151
152    /// Output in JSON format
153    #[arg(long)]
154    pub json: bool,
155
156    /// Enable verbose output
157    #[arg(long)]
158    pub verbose: bool,
159}
160
161#[derive(Args, Debug, PartialEq)]
162pub struct DryRunArgs {
163    /// Run only the release workflow
164    #[arg(long)]
165    pub workflow_only: bool,
166
167    /// Run only the homebrew-release workflow
168    #[arg(long)]
169    pub homebrew_only: bool,
170
171    /// Output in JSON format
172    #[arg(long)]
173    pub json: bool,
174
175    /// Enable verbose output
176    #[arg(long)]
177    pub verbose: bool,
178}
179
180pub async fn run(args: &ReleaseArgs) -> Result<()> {
181    let runner = CargoMakeReleaseRunner;
182    run_with_deps(args, &runner).await
183}
184
185pub async fn run_with_deps(args: &ReleaseArgs, runner: &dyn ReleaseWorkflowRunner) -> Result<()> {
186    match &args.action {
187        ReleaseAction::Run(run_args) => run_release_workflows_with_deps(run_args, runner).await,
188        ReleaseAction::Retry(retry_args) => {
189            run_release_with_retry_with_deps(retry_args, runner).await
190        }
191        ReleaseAction::Metrics(metrics_args) => {
192            run_release_with_metrics_with_deps(metrics_args, runner).await
193        }
194        ReleaseAction::Timeout(timeout_args) => {
195            run_release_with_timeout_with_deps(timeout_args, runner).await
196        }
197        ReleaseAction::DryRun(dry_run_args) => {
198            run_release_dry_run_with_deps(dry_run_args, runner).await
199        }
200    }
201}
202
203async fn run_release_workflows_with_deps(
204    args: &RunArgs, runner: &dyn ReleaseWorkflowRunner,
205) -> Result<()> {
206    println!("🚀 Running release workflows locally with act");
207
208    let result = runner.run(args)?;
209
210    if !result.success {
211        return Err(ggen_utils::error::Error::new_fmt(format_args!(
212            "Release workflows failed: {}",
213            result.stderr
214        )));
215    }
216
217    if !args.json {
218        println!("✅ Release workflows completed successfully");
219    }
220    println!("{}", result.stdout);
221    Ok(())
222}
223
224#[allow(dead_code)]
225async fn run_release_workflows(args: &RunArgs) -> Result<()> {
226    let runner = CargoMakeReleaseRunner;
227    run_release_workflows_with_deps(args, &runner).await
228}
229
230async fn run_release_with_retry_with_deps(
231    args: &RetryArgs, runner: &dyn ReleaseWorkflowRunner,
232) -> Result<()> {
233    println!("🔄 Running release workflows with retry logic");
234
235    let result = runner.run_with_retry(args)?;
236
237    if !result.success {
238        return Err(ggen_utils::error::Error::new_fmt(format_args!(
239            "Release workflows with retry failed: {}",
240            result.stderr
241        )));
242    }
243
244    if !args.json {
245        println!("✅ Release workflows with retry completed successfully");
246    }
247    println!("{}", result.stdout);
248    Ok(())
249}
250
251#[allow(dead_code)]
252async fn run_release_with_retry(args: &RetryArgs) -> Result<()> {
253    let runner = CargoMakeReleaseRunner;
254    run_release_with_retry_with_deps(args, &runner).await
255}
256
257async fn run_release_with_metrics_with_deps(
258    args: &MetricsArgs, runner: &dyn ReleaseWorkflowRunner,
259) -> Result<()> {
260    println!("📊 Running release workflows with metrics collection");
261
262    let result = runner.run_with_metrics(args)?;
263
264    if !result.success {
265        return Err(ggen_utils::error::Error::new_fmt(format_args!(
266            "Release workflows with metrics failed: {}",
267            result.stderr
268        )));
269    }
270
271    if !args.json {
272        println!("✅ Release workflows with metrics completed successfully");
273    }
274    println!("{}", result.stdout);
275    Ok(())
276}
277
278#[allow(dead_code)]
279async fn run_release_with_metrics(args: &MetricsArgs) -> Result<()> {
280    let runner = CargoMakeReleaseRunner;
281    run_release_with_metrics_with_deps(args, &runner).await
282}
283
284async fn run_release_with_timeout_with_deps(
285    args: &TimeoutArgs, runner: &dyn ReleaseWorkflowRunner,
286) -> Result<()> {
287    println!("⏱️ Running release workflows with custom timeout");
288
289    let result = runner.run_with_timeout(args)?;
290
291    if !result.success {
292        return Err(ggen_utils::error::Error::new_fmt(format_args!(
293            "Release workflows with timeout failed: {}",
294            result.stderr
295        )));
296    }
297
298    if !args.json {
299        println!("✅ Release workflows with timeout completed successfully");
300    }
301    println!("{}", result.stdout);
302    Ok(())
303}
304
305#[allow(dead_code)]
306async fn run_release_with_timeout(args: &TimeoutArgs) -> Result<()> {
307    let runner = CargoMakeReleaseRunner;
308    run_release_with_timeout_with_deps(args, &runner).await
309}
310
311async fn run_release_dry_run_with_deps(
312    args: &DryRunArgs, runner: &dyn ReleaseWorkflowRunner,
313) -> Result<()> {
314    println!("🔍 Showing what would be executed (dry run)");
315
316    let result = runner.dry_run(args)?;
317
318    if !result.success {
319        return Err(ggen_utils::error::Error::new_fmt(format_args!(
320            "Release workflows dry run failed: {}",
321            result.stderr
322        )));
323    }
324
325    println!("{}", result.stdout);
326    Ok(())
327}
328
329#[allow(dead_code)]
330async fn run_release_dry_run(args: &DryRunArgs) -> Result<()> {
331    let runner = CargoMakeReleaseRunner;
332    run_release_dry_run_with_deps(args, &runner).await
333}
334
335// Concrete implementations for production use
336pub struct CargoMakeReleaseRunner;
337
338impl ReleaseWorkflowRunner for CargoMakeReleaseRunner {
339    fn run(&self, args: &ReleaseRunArgs) -> Result<ReleaseResult> {
340        let mut cmd = std::process::Command::new("cargo");
341        cmd.args(["make", "act-release"]);
342
343        if args.workflow_only {
344            cmd.arg("--workflow-only");
345        }
346
347        if args.homebrew_only {
348            cmd.arg("--homebrew-only");
349        }
350
351        if args.json {
352            cmd.arg("--json");
353        }
354
355        if args.verbose {
356            cmd.arg("--verbose");
357        }
358
359        if args.debug {
360            cmd.env("DEBUG", "true");
361        }
362
363        let output = cmd.output()?;
364        Ok(ReleaseResult {
365            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
366            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
367            success: output.status.success(),
368        })
369    }
370
371    fn run_with_retry(&self, args: &ReleaseRetryArgs) -> Result<ReleaseResult> {
372        let mut cmd = std::process::Command::new("cargo");
373        cmd.args(["make", "act-release-retry"]);
374        cmd.arg("--max-retries").arg(args.max_retries.to_string());
375
376        if args.workflow_only {
377            cmd.arg("--workflow-only");
378        }
379
380        if args.homebrew_only {
381            cmd.arg("--homebrew-only");
382        }
383
384        if args.json {
385            cmd.arg("--json");
386        }
387
388        if args.verbose {
389            cmd.arg("--verbose");
390        }
391
392        let output = cmd.output()?;
393        Ok(ReleaseResult {
394            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
395            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
396            success: output.status.success(),
397        })
398    }
399
400    fn run_with_metrics(&self, args: &ReleaseMetricsArgs) -> Result<ReleaseResult> {
401        let mut cmd = std::process::Command::new("cargo");
402        cmd.args(["make", "act-release-metrics"]);
403
404        if args.workflow_only {
405            cmd.arg("--workflow-only");
406        }
407
408        if args.homebrew_only {
409            cmd.arg("--homebrew-only");
410        }
411
412        if args.json {
413            cmd.arg("--json");
414        }
415
416        if args.verbose {
417            cmd.arg("--verbose");
418        }
419
420        let output = cmd.output()?;
421        Ok(ReleaseResult {
422            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
423            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
424            success: output.status.success(),
425        })
426    }
427
428    fn run_with_timeout(&self, args: &ReleaseTimeoutArgs) -> Result<ReleaseResult> {
429        let mut cmd = std::process::Command::new("cargo");
430        cmd.args(["make", "act-release-timeout"]);
431        cmd.arg("--timeout").arg(args.timeout.to_string());
432
433        if args.workflow_only {
434            cmd.arg("--workflow-only");
435        }
436
437        if args.homebrew_only {
438            cmd.arg("--homebrew-only");
439        }
440
441        if args.json {
442            cmd.arg("--json");
443        }
444
445        if args.verbose {
446            cmd.arg("--verbose");
447        }
448
449        let output = cmd.output()?;
450        Ok(ReleaseResult {
451            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
452            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
453            success: output.status.success(),
454        })
455    }
456
457    fn dry_run(&self, args: &ReleaseDryRunArgs) -> Result<ReleaseResult> {
458        let mut cmd = std::process::Command::new("cargo");
459        cmd.args(["make", "act-release-dry-run"]);
460
461        if args.workflow_only {
462            cmd.arg("--workflow-only");
463        }
464
465        if args.homebrew_only {
466            cmd.arg("--homebrew-only");
467        }
468
469        if args.json {
470            cmd.arg("--json");
471        }
472
473        if args.verbose {
474            cmd.arg("--verbose");
475        }
476
477        let output = cmd.output()?;
478        Ok(ReleaseResult {
479            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
480            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
481            success: output.status.success(),
482        })
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use mockall::predicate::*;
490
491    #[tokio::test]
492    async fn test_run_calls_runner() {
493        let mut mock = MockReleaseWorkflowRunner::new();
494        mock.expect_run()
495            .with(eq(RunArgs {
496                workflow_only: false,
497                homebrew_only: false,
498                json: false,
499                verbose: false,
500                debug: false,
501            }))
502            .times(1)
503            .returning(|_| {
504                Ok(ReleaseResult {
505                    stdout: "Release complete".to_string(),
506                    stderr: "".to_string(),
507                    success: true,
508                })
509            });
510
511        let args = RunArgs {
512            workflow_only: false,
513            homebrew_only: false,
514            json: false,
515            verbose: false,
516            debug: false,
517        };
518        let result = run_release_workflows_with_deps(&args, &mock).await;
519        assert!(result.is_ok());
520    }
521
522    #[tokio::test]
523    async fn test_run_handles_failure() {
524        let mut mock = MockReleaseWorkflowRunner::new();
525        mock.expect_run()
526            .with(eq(RunArgs {
527                workflow_only: false,
528                homebrew_only: false,
529                json: false,
530                verbose: false,
531                debug: false,
532            }))
533            .times(1)
534            .returning(|_| {
535                Ok(ReleaseResult {
536                    stdout: "".to_string(),
537                    stderr: "Release failed".to_string(),
538                    success: false,
539                })
540            });
541
542        let args = RunArgs {
543            workflow_only: false,
544            homebrew_only: false,
545            json: false,
546            verbose: false,
547            debug: false,
548        };
549        let result = run_release_workflows_with_deps(&args, &mock).await;
550        assert!(result.is_err());
551        assert!(result
552            .unwrap_err()
553            .to_string()
554            .contains("Release workflows failed"));
555    }
556
557    #[tokio::test]
558    async fn test_retry_calls_runner() {
559        let mut mock = MockReleaseWorkflowRunner::new();
560        mock.expect_run_with_retry()
561            .with(eq(RetryArgs {
562                workflow_only: false,
563                homebrew_only: false,
564                max_retries: 3,
565                json: false,
566                verbose: false,
567            }))
568            .times(1)
569            .returning(|_| {
570                Ok(ReleaseResult {
571                    stdout: "Retry complete".to_string(),
572                    stderr: "".to_string(),
573                    success: true,
574                })
575            });
576
577        let args = RetryArgs {
578            workflow_only: false,
579            homebrew_only: false,
580            max_retries: 3,
581            json: false,
582            verbose: false,
583        };
584        let result = run_release_with_retry_with_deps(&args, &mock).await;
585        assert!(result.is_ok());
586    }
587
588    #[tokio::test]
589    async fn test_metrics_calls_runner() {
590        let mut mock = MockReleaseWorkflowRunner::new();
591        mock.expect_run_with_metrics()
592            .with(eq(MetricsArgs {
593                workflow_only: false,
594                homebrew_only: false,
595                json: false,
596                verbose: false,
597            }))
598            .times(1)
599            .returning(|_| {
600                Ok(ReleaseResult {
601                    stdout: "Metrics complete".to_string(),
602                    stderr: "".to_string(),
603                    success: true,
604                })
605            });
606
607        let args = MetricsArgs {
608            workflow_only: false,
609            homebrew_only: false,
610            json: false,
611            verbose: false,
612        };
613        let result = run_release_with_metrics_with_deps(&args, &mock).await;
614        assert!(result.is_ok());
615    }
616
617    #[tokio::test]
618    async fn test_timeout_calls_runner() {
619        let mut mock = MockReleaseWorkflowRunner::new();
620        mock.expect_run_with_timeout()
621            .with(eq(TimeoutArgs {
622                timeout: 1800,
623                workflow_only: false,
624                homebrew_only: false,
625                json: false,
626                verbose: false,
627            }))
628            .times(1)
629            .returning(|_| {
630                Ok(ReleaseResult {
631                    stdout: "Timeout complete".to_string(),
632                    stderr: "".to_string(),
633                    success: true,
634                })
635            });
636
637        let args = TimeoutArgs {
638            timeout: 1800,
639            workflow_only: false,
640            homebrew_only: false,
641            json: false,
642            verbose: false,
643        };
644        let result = run_release_with_timeout_with_deps(&args, &mock).await;
645        assert!(result.is_ok());
646    }
647
648    #[tokio::test]
649    async fn test_dry_run_calls_runner() {
650        let mut mock = MockReleaseWorkflowRunner::new();
651        mock.expect_dry_run()
652            .with(eq(DryRunArgs {
653                workflow_only: false,
654                homebrew_only: false,
655                json: false,
656                verbose: false,
657            }))
658            .times(1)
659            .returning(|_| {
660                Ok(ReleaseResult {
661                    stdout: "Dry run complete".to_string(),
662                    stderr: "".to_string(),
663                    success: true,
664                })
665            });
666
667        let args = DryRunArgs {
668            workflow_only: false,
669            homebrew_only: false,
670            json: false,
671            verbose: false,
672        };
673        let result = run_release_dry_run_with_deps(&args, &mock).await;
674        assert!(result.is_ok());
675    }
676
677    #[tokio::test]
678    async fn test_run_with_deps_dispatches_correctly() {
679        let mut mock = MockReleaseWorkflowRunner::new();
680        mock.expect_run()
681            .with(eq(RunArgs {
682                workflow_only: false,
683                homebrew_only: false,
684                json: false,
685                verbose: false,
686                debug: false,
687            }))
688            .times(1)
689            .returning(|_| {
690                Ok(ReleaseResult {
691                    stdout: "Release complete".to_string(),
692                    stderr: "".to_string(),
693                    success: true,
694                })
695            });
696
697        let args = ReleaseArgs {
698            action: ReleaseAction::Run(RunArgs {
699                workflow_only: false,
700                homebrew_only: false,
701                json: false,
702                verbose: false,
703                debug: false,
704            }),
705        };
706
707        let result = run_with_deps(&args, &mock).await;
708        assert!(result.is_ok());
709    }
710
711    #[test]
712    fn test_run_args_defaults() {
713        let args = RunArgs {
714            workflow_only: false,
715            homebrew_only: false,
716            json: false,
717            verbose: false,
718            debug: false,
719        };
720        assert!(!args.workflow_only);
721        assert!(!args.homebrew_only);
722        assert!(!args.json);
723        assert!(!args.verbose);
724        assert!(!args.debug);
725    }
726
727    #[test]
728    fn test_retry_args_defaults() {
729        let args = RetryArgs {
730            workflow_only: false,
731            homebrew_only: false,
732            max_retries: 3,
733            json: false,
734            verbose: false,
735        };
736        assert!(!args.workflow_only);
737        assert!(!args.homebrew_only);
738        assert_eq!(args.max_retries, 3);
739        assert!(!args.json);
740        assert!(!args.verbose);
741    }
742
743    #[test]
744    fn test_metrics_args_defaults() {
745        let args = MetricsArgs {
746            workflow_only: false,
747            homebrew_only: false,
748            json: false,
749            verbose: false,
750        };
751        assert!(!args.workflow_only);
752        assert!(!args.homebrew_only);
753        assert!(!args.json);
754        assert!(!args.verbose);
755    }
756
757    #[test]
758    fn test_timeout_args_defaults() {
759        let args = TimeoutArgs {
760            timeout: 1800,
761            workflow_only: false,
762            homebrew_only: false,
763            json: false,
764            verbose: false,
765        };
766        assert_eq!(args.timeout, 1800);
767        assert!(!args.workflow_only);
768        assert!(!args.homebrew_only);
769        assert!(!args.json);
770        assert!(!args.verbose);
771    }
772
773    #[test]
774    fn test_dry_run_args_defaults() {
775        let args = DryRunArgs {
776            workflow_only: false,
777            homebrew_only: false,
778            json: false,
779            verbose: false,
780        };
781        assert!(!args.workflow_only);
782        assert!(!args.homebrew_only);
783        assert!(!args.json);
784        assert!(!args.verbose);
785    }
786}