release-tool 0.2.1

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
use crate::command::{CommandRunner, SystemCommandRunner};
use crate::config::Config;
use crate::doctor::{CapabilityStatus, run_doctor};
use crate::domain::{ArtifactIdentity, ReleaseIntent, ReleasePlan};
use crate::git::GitRepository;
use crate::lifecycle::{PublishRequest, publish_release_with_progress, resolve_plan};
use crate::progress::StderrProgressReporter;
use anyhow::Result;
use chrono::Local;
use clap::{Args, Parser, Subcommand};
use std::io::{self, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;

#[derive(Debug, Parser)]
#[command(version, about)]
struct Cli {
    #[arg(long, default_value = "release.toml", global = true)]
    config: PathBuf,

    #[command(subcommand)]
    command: Command,
}

#[derive(Debug, Subcommand)]
enum Command {
    Doctor,
    Plan(ReleaseArguments),
    Publish(PublishArguments),
}

#[derive(Clone, Debug, Args)]
struct ReleaseArguments {
    #[arg(long)]
    release: bool,

    #[arg(long = "target")]
    targets: Vec<String>,

    #[arg(long, conflicts_with = "targets")]
    all: bool,
}

#[derive(Clone, Debug, Args)]
struct PublishArguments {
    #[command(flatten)]
    release: ReleaseArguments,

    #[arg(long)]
    yes: bool,
}

/// Runs the release-tool CLI using the current process arguments and I/O.
///
/// Cargo-based adopters call this from a one-line runner binary, so Cargo owns
/// source acquisition, locking, compilation, and caching without a separate
/// release-tool installation step.
pub fn entrypoint() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("error: {error:#}");
            ExitCode::FAILURE
        }
    }
}

fn run() -> Result<()> {
    let cli = Cli::parse();
    let config = Config::load(&cli.config)?;
    let root = cli
        .config
        .parent()
        .filter(|path| !path.as_os_str().is_empty())
        .unwrap_or_else(|| std::path::Path::new("."));
    match cli.command {
        Command::Doctor => {
            let report = run_doctor(&config, root, Arc::new(SystemCommandRunner))?;
            for capability in report.capabilities {
                let status = match capability.status {
                    CapabilityStatus::Verified => "VERIFIED",
                    CapabilityStatus::Unverifiable => "UNVERIFIABLE",
                };
                println!("{status} {}", capability.description);
            }
            Ok(())
        }
        Command::Plan(arguments) => {
            let runner = Arc::new(SystemCommandRunner);
            let repository = GitRepository::new(root, runner.clone());
            let snapshot = repository.snapshot(&config.repository.branch)?;
            let intent = if arguments.release {
                ReleaseIntent::New
            } else {
                ReleaseIntent::Existing
            };
            let plan = resolve_plan(
                &config,
                &snapshot,
                intent,
                Local::now().date_naive(),
                &arguments.targets,
                arguments.all,
                runner,
            )?;
            print_plan(&plan);
            Ok(())
        }
        Command::Publish(arguments) => {
            let intent = if arguments.release.release {
                ReleaseIntent::New
            } else {
                ReleaseIntent::Existing
            };
            let runner: Arc<dyn CommandRunner> = Arc::new(SystemCommandRunner);
            let assume_yes = arguments.yes;
            let mut confirm = |plan: &ReleasePlan| -> Result<bool> {
                if assume_yes {
                    return Ok(true);
                }
                print!(
                    "Publish {} at commit {}? [y/N] ",
                    plan.release.tag, plan.release.commit
                );
                io::stdout().flush()?;
                let mut answer = String::new();
                io::stdin().read_line(&mut answer)?;
                Ok(matches!(answer.trim(), "y" | "Y" | "yes" | "YES"))
            };
            let outcomes = publish_release_with_progress(
                &config,
                root,
                &PublishRequest {
                    intent,
                    today: Local::now().date_naive(),
                    targets: arguments.release.targets,
                    all_targets: arguments.release.all,
                },
                runner,
                &mut confirm,
                &StderrProgressReporter,
            )?;
            for outcome in outcomes {
                if outcome.skipped {
                    println!(
                        "release-tool {}: verified existing {} at {}; skipped (commit {})",
                        outcome.tool_version, outcome.target, outcome.tag, outcome.commit
                    );
                } else {
                    println!(
                        "release-tool {}: published {} at {} ({})",
                        outcome.tool_version, outcome.target, outcome.tag, outcome.commit
                    );
                }
            }
            Ok(())
        }
    }
}

fn print_plan(plan: &ReleasePlan) {
    println!("release-tool: {}", plan.tool_version);
    println!("repository: {}", plan.release.repository);
    println!("commit: {}", plan.release.commit);
    println!("tag: {}", plan.release.tag);
    println!("sealed: {}", plan.release.tag_already_sealed);
    for target in &plan.targets {
        println!("target: {}", target.name);
        println!("  publisher: {}", target.publisher);
        for artifact in &target.artifacts {
            match artifact {
                ArtifactIdentity::GithubReleaseAsset { name } => {
                    println!("  artifact: github-release:{name}");
                }
                ArtifactIdentity::MavenPackage {
                    group_id,
                    artifact_id,
                    version,
                    extension,
                } => println!("  artifact: maven:{group_id}:{artifact_id}:{version}:{extension}"),
            }
        }
    }
}