verctl 0.0.1

Stack-agnostic version PRs from Changesets-format fragments
Documentation
use anyhow::Result;
use ctl_core::prelude::*;
use serde::Serialize;
use std::fmt;
use std::io::{self, Write};
use std::path::Path;
use verctl::cli::{Cli, Command, PrepareArgs, StatusArgs};
use verctl::config::Config;
use verctl::fragment::{self, Bump};
use verctl::github;
use verctl::prepare;
use verctl::publish;
use verctl::release;

const INSTRUCTIONS: &str = include_str!("instructions.md");

fn main() -> ExitCode {
    go::<Cli>("verctl", |cli| {
        let view = cli.format.view(cli.color.color());
        match cli.command {
            Command::Instructions => io::stdout().write_all(INSTRUCTIONS.as_bytes())?,
            Command::Status(args) => view.show(&status_report(&args)?)?,
            Command::Check(args) => {
                let ok = fragment::load_dir(&args.dir)?.len();
                view.show(&CheckReport { ok })?;
            }
            Command::Prepare(args) => view.show(&prepare_report(&args)?)?,
            Command::Publish(args) => view.show(&publish_report(&args)?)?,
        }
        Ok(())
    })
}

#[derive(Serialize)]
struct CheckReport {
    ok: usize,
}

impl Render for CheckReport {
    fn render_pretty(&self) -> String {
        let n = self.ok;
        formatdoc!("ok      {n} fragment(s)")
    }
}

#[derive(Serialize)]
struct StatusReport {
    pending: usize,
    max: String,
    fragments: Vec<StatusFragment>,
}

#[derive(Serialize)]
struct StatusFragment {
    file: String,
    max: String,
    packages: Vec<StatusPackage>,
}

#[derive(Serialize)]
struct StatusPackage {
    name: String,
    bump: String,
}

impl fmt::Display for StatusReport {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.pending == 0 {
            return writedoc!(f, "pending  0");
        }
        let pending = self.pending;
        writedoc!(f, "pending  {pending}\n")?;
        for fragment in &self.fragments {
            let file = fragment.file.as_str();
            for package in &fragment.packages {
                let name = package.name.as_str();
                let bump = package.bump.as_str();
                writedoc!(f, "  {name:<32} {bump:<6} {file}\n")?;
            }
            let max = fragment.max.as_str();
            writedoc!(f, "    max {max}\n")?;
        }
        let max = self.max.as_str();
        writedoc!(f, "max     {max}")
    }
}

impl Render for StatusReport {
    fn render_pretty(&self) -> String {
        self.to_string()
    }
}

#[derive(Clone, Serialize)]
struct PrepareReport {
    bumps: Vec<PrepareBump>,
    changelog: String,
    consume: Vec<String>,
    pr: Option<String>,
    next: Vec<String>,
    dry_run: bool,
}

#[derive(Clone, Serialize)]
struct PrepareBump {
    name: String,
    from: String,
    to: String,
    bump: String,
}

impl fmt::Display for PrepareReport {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.pr.as_deref() == Some("no-op") && self.bumps.is_empty() {
            return writedoc!(f, "no-op   no version-changing fragments");
        }
        for bump in &self.bumps {
            let name = bump.name.as_str();
            let from = bump.from.as_str();
            let to = bump.to.as_str();
            let kind = bump.bump.as_str();
            writedoc!(f, "bump    {name}  {from} -> {to}  ({kind})\n")?;
        }
        for line in self.changelog.lines() {
            writedoc!(f, "log     {line}\n")?;
        }
        for file in &self.consume {
            writedoc!(f, "consume {file}\n")?;
        }
        if let Some(pr) = &self.pr {
            writedoc!(f, "pr      {pr}\n")?;
        }
        for cmd in &self.next {
            writedoc!(f, "next    {cmd}\n")?;
        }
        if self.dry_run {
            writedoc!(f, "dry-run (no files written)\n")?;
        }
        Ok(())
    }
}

impl Render for PrepareReport {
    fn render_pretty(&self) -> String {
        self.to_string()
    }
}

fn prepare_report(args: &PrepareArgs) -> Result<PrepareReport> {
    let dry_run = args.dry_run();
    let config = Config::load(&args.config)?;
    let fragments = fragment::load_dir(&args.dir)?;
    let root = args
        .config
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    let plan = prepare::plan(&config, &fragments, root)?;
    if plan.is_empty() {
        return Ok(PrepareReport {
            bumps: Vec::new(),
            changelog: String::new(),
            consume: Vec::new(),
            pr: args.open_pr().then(|| "no-op".to_owned()),
            next: Vec::new(),
            dry_run,
        });
    }
    let token = if args.open_pr() && !dry_run {
        Some(release::resolve_token()?)
    } else {
        None
    };
    let bumps: Vec<PrepareBump> = plan
        .iter()
        .map(|entry| PrepareBump {
            name: entry.name.clone(),
            from: entry.from.clone(),
            to: entry.to.clone(),
            bump: entry.bump.as_str().to_owned(),
        })
        .collect();
    if dry_run {
        return preview_report(root, &plan, &fragments, bumps, args.open_pr());
    }
    let follow_up = prepare::apply_plan(&plan)?;
    let consumed = release::contributing_fragments(&plan, &fragments);
    let changelog = if args.open_pr() {
        release::changelog_sections(&plan, &fragments)?
    } else {
        String::new()
    };
    let consume: Vec<String> = consumed
        .iter()
        .map(|fragment| {
            fragment
                .path
                .file_name()
                .and_then(|name| name.to_str())
                .unwrap_or("?")
                .to_owned()
        })
        .collect();
    let mut pr = None;
    if let Some(token) = token {
        release::write_changelogs(&root.join("CHANGELOG.md"), &plan, &fragments)?;
        let mut paths: Vec<std::path::PathBuf> =
            plan.iter().map(|entry| entry.path.clone()).collect();
        paths.push(root.join("CHANGELOG.md"));
        paths.extend(consumed.iter().map(|fragment| fragment.path.clone()));
        release::consume_fragments(consumed)?;
        let title = std::env::var("VERCTL_PR_TITLE")
            .unwrap_or_else(|_| "chore(release): version packages".into());
        let message = std::env::var("VERCTL_COMMIT_MESSAGE").unwrap_or_else(|_| title.clone());
        pr = release::open_or_update_pr(root, &token, &title, &message, &paths)?;
    }
    Ok(PrepareReport {
        changelog,
        consume,
        pr,
        next: follow_up,
        dry_run: false,
        bumps,
    })
}

fn preview_report(
    root: &Path,
    plan: &[prepare::PlanEntry],
    fragments: &[fragment::Fragment],
    bumps: Vec<PrepareBump>,
    open_pr: bool,
) -> Result<PrepareReport> {
    let changelog = release::changelog_sections(plan, fragments)?;
    let consume = if open_pr {
        fragments
            .iter()
            .map(|fragment| {
                fragment
                    .path
                    .file_name()
                    .and_then(|name| name.to_str())
                    .unwrap_or("?")
                    .to_owned()
            })
            .collect()
    } else {
        Vec::new()
    };
    let pr = if open_pr {
        Some(match release::resolve_token() {
            Ok(token) => match github::repo(root)
                .and_then(|repo| github::existing_pr(&token, &repo, release::VERSION_BRANCH))
            {
                Ok(Some(url)) => formatdoc!("update {url}"),
                Ok(None) => formatdoc!("open {branch}", branch = release::VERSION_BRANCH),
                Err(_) => formatdoc!(
                    "open or update {branch} (lookup failed)",
                    branch = release::VERSION_BRANCH
                ),
            },
            Err(_) => formatdoc!(
                "open or update {branch} (auth not checked)",
                branch = release::VERSION_BRANCH
            ),
        })
    } else {
        None
    };
    Ok(PrepareReport {
        bumps,
        changelog,
        consume,
        pr,
        next: plan
            .iter()
            .filter_map(|entry| entry.driver.after().map(str::to_owned))
            .collect(),
        dry_run: true,
    })
}

#[derive(Serialize)]
struct PublishReport {
    crates: Vec<String>,
    release: Option<String>,
    dry_run: bool,
}

impl fmt::Display for PublishReport {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.crates.is_empty() && self.release.is_none() {
            return writedoc!(f, "no-op   nothing to publish");
        }
        for entry in &self.crates {
            writedoc!(f, "crate   {entry}\n")?;
        }
        if let Some(url) = &self.release {
            writedoc!(f, "release {url}\n")?;
        }
        if self.dry_run {
            writedoc!(f, "dry-run (nothing published)\n")?;
        }
        Ok(())
    }
}

impl Render for PublishReport {
    fn render_pretty(&self) -> String {
        self.to_string()
    }
}

fn publish_report(args: &verctl::cli::PublishArgs) -> Result<PublishReport> {
    let config = Config::load(&args.config)?;
    let root = args
        .config
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    let outcome = publish::run(&config, root, args.dry_run())?;
    Ok(PublishReport {
        crates: outcome.crates,
        release: outcome.release,
        dry_run: args.dry_run(),
    })
}

fn status_report(args: &StatusArgs) -> Result<StatusReport> {
    let fragments = fragment::load_dir(&args.dir)?;
    let overall = fragments
        .iter()
        .map(fragment::Fragment::max_bump)
        .max()
        .unwrap_or(Bump::None);
    Ok(StatusReport {
        pending: fragments.len(),
        max: overall.as_str().to_owned(),
        fragments: fragments
            .iter()
            .map(|fragment| StatusFragment {
                file: fragment
                    .path
                    .file_name()
                    .and_then(|name| name.to_str())
                    .unwrap_or("?")
                    .to_owned(),
                max: fragment.max_bump().as_str().to_owned(),
                packages: fragment
                    .packages
                    .iter()
                    .map(|package| StatusPackage {
                        name: package.name.clone(),
                        bump: package.bump.as_str().to_owned(),
                    })
                    .collect(),
            })
            .collect(),
    })
}

#[cfg(test)]
mod tests {
    use clap::CommandFactory;
    use verctl::cli::Cli;

    #[test]
    fn cli_debug_assert() {
        ctl_core::parser::verify::<Cli>();
        let _ = Cli::command();
    }
}