Skip to main content

release_tool/
cli.rs

1use crate::command::{CommandRunner, SystemCommandRunner};
2use crate::config::Config;
3use crate::doctor::{CapabilityStatus, run_doctor};
4use crate::domain::{ArtifactIdentity, ReleaseIntent, ReleasePlan};
5use crate::git::GitRepository;
6use crate::lifecycle::{PublishRequest, publish_release_with_progress, resolve_plan};
7use crate::progress::StderrProgressReporter;
8use anyhow::Result;
9use chrono::Local;
10use clap::{Args, Parser, Subcommand};
11use std::io::{self, Write};
12use std::path::PathBuf;
13use std::process::ExitCode;
14use std::sync::Arc;
15
16#[derive(Debug, Parser)]
17#[command(version, about)]
18struct Cli {
19    #[arg(long, default_value = "release.toml", global = true)]
20    config: PathBuf,
21
22    #[command(subcommand)]
23    command: Command,
24}
25
26#[derive(Debug, Subcommand)]
27enum Command {
28    Doctor,
29    Plan(ReleaseArguments),
30    Publish(PublishArguments),
31}
32
33#[derive(Clone, Debug, Args)]
34struct ReleaseArguments {
35    #[arg(long)]
36    release: bool,
37
38    #[arg(long = "target")]
39    targets: Vec<String>,
40
41    #[arg(long, conflicts_with = "targets")]
42    all: bool,
43}
44
45#[derive(Clone, Debug, Args)]
46struct PublishArguments {
47    #[command(flatten)]
48    release: ReleaseArguments,
49
50    #[arg(long)]
51    yes: bool,
52
53    /// Skip the project preflight hook; build and artifact verification still run.
54    #[arg(long)]
55    skip_tests: bool,
56}
57
58/// Runs the release-tool CLI using the current process arguments and I/O.
59///
60/// Cargo-based adopters call this from a one-line runner binary, so Cargo owns
61/// source acquisition, locking, compilation, and caching without a separate
62/// release-tool installation step.
63pub fn entrypoint() -> ExitCode {
64    match run() {
65        Ok(()) => ExitCode::SUCCESS,
66        Err(error) => {
67            eprintln!("error: {error:#}");
68            ExitCode::FAILURE
69        }
70    }
71}
72
73fn run() -> Result<()> {
74    let cli = Cli::parse();
75    let mut config = Config::load(&cli.config)?;
76    let root = cli
77        .config
78        .parent()
79        .filter(|path| !path.as_os_str().is_empty())
80        .unwrap_or_else(|| std::path::Path::new("."));
81    match cli.command {
82        Command::Doctor => {
83            let report = run_doctor(&config, root, Arc::new(SystemCommandRunner))?;
84            for capability in report.capabilities {
85                let status = match capability.status {
86                    CapabilityStatus::Verified => "VERIFIED",
87                    CapabilityStatus::Unverifiable => "UNVERIFIABLE",
88                };
89                println!("{status} {}", capability.description);
90            }
91            Ok(())
92        }
93        Command::Plan(arguments) => {
94            let runner = Arc::new(SystemCommandRunner);
95            let repository = GitRepository::new(root, runner.clone());
96            let snapshot = repository.snapshot(&config.repository.branch)?;
97            let intent = if arguments.release {
98                ReleaseIntent::New
99            } else {
100                ReleaseIntent::Existing
101            };
102            let plan = resolve_plan(
103                &config,
104                &snapshot,
105                intent,
106                Local::now().date_naive(),
107                &arguments.targets,
108                arguments.all,
109                runner,
110            )?;
111            print_plan(&plan);
112            Ok(())
113        }
114        Command::Publish(arguments) => {
115            if arguments.skip_tests {
116                config.hooks.preflight.clear();
117                eprintln!("[release-tool] Skipping project preflight (--skip-tests)");
118            }
119            let intent = if arguments.release.release {
120                ReleaseIntent::New
121            } else {
122                ReleaseIntent::Existing
123            };
124            let runner: Arc<dyn CommandRunner> = Arc::new(SystemCommandRunner);
125            let assume_yes = arguments.yes;
126            let mut confirm = |plan: &ReleasePlan| -> Result<bool> {
127                if assume_yes {
128                    return Ok(true);
129                }
130                print!(
131                    "Publish {} at commit {}? [y/N] ",
132                    plan.release.tag, plan.release.commit
133                );
134                io::stdout().flush()?;
135                let mut answer = String::new();
136                io::stdin().read_line(&mut answer)?;
137                Ok(matches!(answer.trim(), "y" | "Y" | "yes" | "YES"))
138            };
139            let outcomes = publish_release_with_progress(
140                &config,
141                root,
142                &PublishRequest {
143                    intent,
144                    today: Local::now().date_naive(),
145                    targets: arguments.release.targets,
146                    all_targets: arguments.release.all,
147                },
148                runner,
149                &mut confirm,
150                &StderrProgressReporter,
151            )?;
152            for outcome in outcomes {
153                if outcome.skipped {
154                    println!(
155                        "release-tool {}: verified existing {} at {}; skipped (commit {})",
156                        outcome.tool_version, outcome.target, outcome.tag, outcome.commit
157                    );
158                } else {
159                    println!(
160                        "release-tool {}: published {} at {} ({})",
161                        outcome.tool_version, outcome.target, outcome.tag, outcome.commit
162                    );
163                }
164            }
165            Ok(())
166        }
167    }
168}
169
170fn print_plan(plan: &ReleasePlan) {
171    println!("release-tool: {}", plan.tool_version);
172    println!("repository: {}", plan.release.repository);
173    println!("commit: {}", plan.release.commit);
174    println!("tag: {}", plan.release.tag);
175    println!("sealed: {}", plan.release.tag_already_sealed);
176    for target in &plan.targets {
177        println!("target: {}", target.name);
178        println!("  publisher: {}", target.publisher);
179        for artifact in &target.artifacts {
180            match artifact {
181                ArtifactIdentity::GithubReleaseAsset { name } => {
182                    println!("  artifact: github-release:{name}");
183                }
184                ArtifactIdentity::MavenPackage {
185                    group_id,
186                    artifact_id,
187                    version,
188                    extension,
189                } => println!("  artifact: maven:{group_id}:{artifact_id}:{version}:{extension}"),
190                ArtifactIdentity::OciImage {
191                    repository,
192                    tag,
193                    platform,
194                } => println!("  artifact: oci:{repository}:{tag} ({platform})"),
195            }
196        }
197    }
198}