arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! YBF bump computation (RV2.5).
//!
//! Consumes the per-unit [`ChangeKind`] map produced by change-fragment
//! validation (RV2.3) and the current per-crate versions discovered from
//! `cargo metadata`, and computes the target version for every publishable
//! crate. The bumper is pure — it produces a [`BumpPlan`] describing the
//! intended version transitions without touching any manifest. The
//! planner (RV2.6) and prepare (RV2.7) consume this plan.
//!
//! Bump rules (ADR-0005 Decision §4):
//!
//! - `None` → no bump (the crate keeps its current version).
//! - `Compatible` → FIX bump (`2026.1.3 → 2026.1.4`).
//! - `Breaking` → next BREAK, FIX reset to 0 (`2026.1.5 → 2026.2.0`).
//!
//! `core` atomicity (ADR-0005 Decision §2 / invariant 3): both members of
//! `core` (`arcature` and `arcature-dx`) must bump to the *same* new version.
//! A change fragment for `core` applies to both members; a fragment for
//! only one member would violate the unit's atomicity and is a hard error.

use std::collections::BTreeMap;

use super::ybf::Ybf;
use crate::release::change::ChangeKind;
use crate::release::discovered::DiscoveredCrate;
use crate::release::error::Diagnostic;
use crate::release::metadata::CrateMetadata;

/// The intended version transition for one crate.
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)]
pub(crate) struct CrateBump {
    #[allow(dead_code)]
    pub(crate) crate_name: String,
    pub(crate) from: Ybf,
    pub(crate) to: Ybf,
    pub(crate) changed: bool,
}

/// The complete set of intended version transitions for one release
/// transaction, keyed by crate name and sorted for deterministic output.
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)]
pub(crate) struct BumpPlan {
    pub(crate) bumps: BTreeMap<String, CrateBump>,
}

/// The error returned when the bump cannot be computed — typically a
/// missing or malformed current version, or a `core` atomicity violation.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub(crate) struct BumpError {
    pub(crate) diagnostics: Vec<Diagnostic>,
}

impl std::fmt::Display for BumpError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if self.diagnostics.len() == 1 {
            write!(
                formatter,
                "bump computation failed: {}",
                self.diagnostics[0]
            )
        } else {
            writeln!(
                formatter,
                "bump computation failed ({} findings):",
                self.diagnostics.len()
            )?;
            for d in &self.diagnostics {
                writeln!(formatter, "  {d}")?;
            }
            Ok(())
        }
    }
}

impl std::error::Error for BumpError {}

/// Compute the bump plan from current versions, change fragments, and
/// discovered metadata.
///
/// `current_versions` maps crate name → its current YBF version (read from
/// `cargo metadata` by the caller). `change_kinds` maps unit name → the
/// highest-severity [`ChangeKind`] from consumed fragments (produced by
/// `validate_fragments`). `crates` is the discovered-crate list (to resolve
/// which crates belong to which units and which are publishable).
///
/// For every publishable crate, the plan records the version transition
/// (from → to, with `changed: false` when the unit has no fragment).
#[allow(dead_code)]
pub(crate) fn compute_bump_plan(
    current_versions: &BTreeMap<String, Ybf>,
    change_kinds: &BTreeMap<String, ChangeKind>,
    crates: &[DiscoveredCrate],
) -> Result<BumpPlan, BumpError> {
    let mut diagnostics: Vec<Diagnostic> = Vec::new();
    let mut bumps: BTreeMap<String, CrateBump> = BTreeMap::new();

    // Group publishable crates by release unit so we can enforce core
    // atomicity (all members of a unit bump together).
    let mut units: BTreeMap<String, Vec<&DiscoveredCrate>> = BTreeMap::new();
    for c in crates {
        let unit = match &c.metadata {
            CrateMetadata::Valid(md) if md.publish => match &md.release_unit {
                Some(u) => u.clone(),
                None => continue,
            },
            _ => continue,
        };
        units.entry(unit).or_default().push(c);
    }

    for (unit, members) in &units {
        let kind = change_kinds.get(unit).copied().unwrap_or(ChangeKind::None);

        // Compute the bump once per unit — all members share the same target.
        // Use the first member's current version as the bump anchor; core
        // members must already agree (validated below).
        let anchor = match members.first() {
            Some(c) => match current_versions.get(&c.name) {
                Some(v) => *v,
                None => {
                    diagnostics.push(Diagnostic {
                        crate_name: c.name.clone(),
                        message: format!(
                            "current version not found for {unit} member {name}",
                            name = c.name
                        ),
                    });
                    continue;
                }
            },
            None => continue,
        };

        // For multi-crate units (only `core`), every member's current
        // version must match — otherwise the unit is not atomic.
        if members.len() > 1 {
            for m in members {
                if let Some(v) = current_versions.get(&m.name)
                    && *v != anchor
                {
                    diagnostics.push(Diagnostic {
                        crate_name: m.name.clone(),
                        message: format!(
                            "core member {name} has version {v} but {first} has {anchor}; \
                             release-unit members must share one version",
                            name = m.name,
                            first = members[0].name,
                        ),
                    });
                }
            }
        }

        let target = match kind {
            ChangeKind::None => anchor,
            ChangeKind::Compatible => anchor.bump_fix(),
            ChangeKind::Breaking => anchor.bump_break(),
        };

        for m in members {
            if let Some(from) = current_versions.get(&m.name) {
                bumps.insert(
                    m.name.clone(),
                    CrateBump {
                        crate_name: m.name.clone(),
                        from: *from,
                        to: target,
                        changed: kind != ChangeKind::None,
                    },
                );
            }
        }
    }

    if diagnostics.is_empty() {
        Ok(BumpPlan { bumps })
    } else {
        diagnostics.sort_by(|a, b| {
            a.crate_name
                .cmp(&b.crate_name)
                .then(a.message.cmp(&b.message))
        });
        Err(BumpError { diagnostics })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::release::metadata::ReleaseMetadata;
    use crate::release::metadata::Role;

    fn discovered(name: &str, unit: &str) -> DiscoveredCrate {
        DiscoveredCrate {
            name: name.to_string(),
            manifest_path: std::path::PathBuf::from(format!("/repo/crates/{name}/Cargo.toml")),
            publishable: true,
            proc_macro: name == "arcature-dx",
            metadata: CrateMetadata::Valid(ReleaseMetadata {
                publish: true,
                role: Some(if name == "arcature-dx" {
                    Role::ProcMacro
                } else if unit == "core" {
                    Role::Facade
                } else {
                    Role::Subsystem
                }),
                release_unit: Some(unit.to_string()),
            }),
        }
    }

    fn versions(list: &[(&str, &str)]) -> BTreeMap<String, Ybf> {
        list.iter()
            .map(|(n, v)| (n.to_string(), Ybf::parse(v).unwrap()))
            .collect()
    }

    #[test]
    fn none_keeps_version() {
        let crates = vec![discovered("arcature-auth", "arcature-auth")];
        let current = versions(&[("arcature-auth", "2026.1.0")]);
        let changes = BTreeMap::new();
        let plan = compute_bump_plan(&current, &changes, &crates).unwrap();
        let bump = &plan.bumps["arcature-auth"];
        assert!(!bump.changed);
        assert_eq!(bump.from, Ybf::parse("2026.1.0").unwrap());
        assert_eq!(bump.to, Ybf::parse("2026.1.0").unwrap());
    }

    #[test]
    fn compatible_bumps_fix() {
        let crates = vec![discovered("arcature-auth", "arcature-auth")];
        let current = versions(&[("arcature-auth", "2026.1.3")]);
        let changes = BTreeMap::from([("arcature-auth".to_string(), ChangeKind::Compatible)]);
        let plan = compute_bump_plan(&current, &changes, &crates).unwrap();
        let bump = &plan.bumps["arcature-auth"];
        assert!(bump.changed);
        assert_eq!(bump.to, Ybf::parse("2026.1.4").unwrap());
    }

    #[test]
    fn breaking_bumps_break_and_resets_fix() {
        let crates = vec![discovered("arcature-auth", "arcature-auth")];
        let current = versions(&[("arcature-auth", "2026.1.5")]);
        let changes = BTreeMap::from([("arcature-auth".to_string(), ChangeKind::Breaking)]);
        let plan = compute_bump_plan(&current, &changes, &crates).unwrap();
        let bump = &plan.bumps["arcature-auth"];
        assert_eq!(bump.to, Ybf::parse("2026.2.0").unwrap());
    }

    #[test]
    fn core_bumps_both_members_to_same_version() {
        let crates = vec![
            discovered("arcature", "core"),
            discovered("arcature-dx", "core"),
        ];
        let current = versions(&[("arcature", "2026.1.0"), ("arcature-dx", "2026.1.0")]);
        let changes = BTreeMap::from([("core".to_string(), ChangeKind::Compatible)]);
        let plan = compute_bump_plan(&current, &changes, &crates).unwrap();
        assert_eq!(plan.bumps["arcature"].to, Ybf::parse("2026.1.1").unwrap());
        assert_eq!(
            plan.bumps["arcature-dx"].to,
            Ybf::parse("2026.1.1").unwrap()
        );
    }

    #[test]
    fn core_member_version_mismatch_is_error() {
        let crates = vec![
            discovered("arcature", "core"),
            discovered("arcature-dx", "core"),
        ];
        let current = versions(&[("arcature", "2026.1.0"), ("arcature-dx", "2026.1.1")]);
        let changes = BTreeMap::from([("core".to_string(), ChangeKind::Compatible)]);
        let err = compute_bump_plan(&current, &changes, &crates).expect_err("mismatch");
        assert!(!err.diagnostics.is_empty());
        let msg = err.diagnostics[0].message.clone();
        assert!(msg.contains("must share one version"), "{msg}");
    }

    #[test]
    fn missing_current_version_is_error() {
        let crates = vec![discovered("arcature-auth", "arcature-auth")];
        let current = BTreeMap::new();
        let changes = BTreeMap::from([("arcature-auth".to_string(), ChangeKind::Compatible)]);
        let err = compute_bump_plan(&current, &changes, &crates).expect_err("missing version");
        assert!(err.diagnostics[0].message.contains("not found"));
    }

    #[test]
    fn independent_units_bump_independently() {
        let crates = vec![
            discovered("arcature-auth", "arcature-auth"),
            discovered("arcature-db", "arcature-db"),
        ];
        let current = versions(&[("arcature-auth", "2026.1.0"), ("arcature-db", "2026.1.0")]);
        let changes = BTreeMap::from([
            ("arcature-auth".to_string(), ChangeKind::Breaking),
            ("arcature-db".to_string(), ChangeKind::Compatible),
        ]);
        let plan = compute_bump_plan(&current, &changes, &crates).unwrap();
        assert_eq!(
            plan.bumps["arcature-auth"].to,
            Ybf::parse("2026.2.0").unwrap()
        );
        assert_eq!(
            plan.bumps["arcature-db"].to,
            Ybf::parse("2026.1.1").unwrap()
        );
    }
}