verctl 0.4.1

Stack-agnostic version PRs from Changesets-format fragments
//! Compare declared manifest versions to the default branch.

use std::fs;
use std::path::Path;

use anyhow::{Result, ensure};
use serde::Serialize;

use crate::config::Config;
use crate::git;
use crate::release::VERSION_BRANCH;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Skip {
    None,
    Ci,
    VersionBranch,
}

impl Skip {
    #[must_use]
    pub fn from_env(root: &Path, config: &Config) -> Self {
        if git::current_branch(root).as_deref() == Some(VERSION_BRANCH) {
            return Self::VersionBranch;
        }
        // Detached Actions checkout: trust the label prepare --pr applied
        // on the event payload. Not GITHUB_HEAD_REF / GITHUB_REF_NAME.
        if crate::github::event_has_label(config.prepare.version_label()) {
            return Self::VersionBranch;
        }
        Self::None
    }

    #[must_use]
    pub fn label(self) -> Option<&'static str> {
        match self {
            Self::None => None,
            Self::Ci => Some("ci"),
            Self::VersionBranch => Some("version-pr"),
        }
    }
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct VersionRow {
    pub name: String,
    pub path: String,
    pub local: String,
    pub remote: Option<String>,
}

impl VersionRow {
    #[must_use]
    pub fn drifted(&self) -> bool {
        self.remote
            .as_deref()
            .is_some_and(|remote| remote != self.local)
    }
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct VersionReport {
    pub skip: Option<String>,
    pub rows: Vec<VersionRow>,
}

impl VersionReport {
    #[must_use]
    pub fn drifted(&self) -> Vec<&VersionRow> {
        self.rows.iter().filter(|row| row.drifted()).collect()
    }

    pub fn require_clean(&self) -> Result<()> {
        let drifted = self.drifted();
        ensure!(
            drifted.is_empty(),
            "manifest versions differ from the merge-base of the default branch ({}); write a .changeset fragment instead of editing versions",
            drifted
                .iter()
                .map(|row| format!(
                    "{name} {remote} -> {local}",
                    name = row.name,
                    remote = row.remote.as_deref().unwrap_or("?"),
                    local = row.local
                ))
                .collect::<Vec<_>>()
                .join(", ")
        );
        Ok(())
    }
}

pub fn report(root: &Path, config: &Config) -> Result<VersionReport> {
    report_with(
        root,
        config,
        Skip::from_env(root, config),
        &git::default_branch_candidates(),
    )
}

pub fn report_with(
    root: &Path,
    config: &Config,
    skip: Skip,
    candidates: &[String],
) -> Result<VersionReport> {
    if let Some(label) = skip.label() {
        return Ok(VersionReport {
            skip: Some(label.into()),
            rows: Vec::new(),
        });
    }
    let rels: Vec<&Path> = config
        .packages
        .iter()
        .map(|spec| spec.path.as_path())
        .collect();
    let remotes = git::files_on_merge_base(root, &rels, candidates)?;
    let mut rows = Vec::new();
    for (spec, remote_raw) in config.packages.iter().zip(remotes) {
        let path = root.join(&spec.path);
        let Ok(local_raw) = fs::read_to_string(&path) else {
            continue;
        };
        let driver = spec.resolve(config, root)?;
        let local = driver.read(&local_raw)?;
        let remote = remote_raw
            .as_deref()
            .map(|raw| driver.read(raw))
            .transpose()?;
        rows.push(VersionRow {
            name: spec.name.clone(),
            path: spec.path.display().to_string(),
            local,
            remote,
        });
    }
    Ok(VersionReport { skip: None, rows })
}

pub fn require(root: &Path, config: &Config) -> Result<VersionReport> {
    let report = report(root, config)?;
    report.require_clean()?;
    Ok(report)
}

pub fn require_with(
    root: &Path,
    config: &Config,
    skip: Skip,
    candidates: &[String],
) -> Result<VersionReport> {
    let report = report_with(root, config, skip, candidates)?;
    report.require_clean()?;
    Ok(report)
}

#[must_use]
pub fn stock_candidates() -> Vec<String> {
    git::default_branch_candidates_from(None)
}