arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! Change-fragment archival (RV2.7).
//!
//! After a Release Plan is materialized, the consumed change fragments
//! are archived out of `changes/` so the directory returns to a releasable
//! state and the same fragments cannot drive a later release
//! (ADR-0005 invariant 9: "arc release prepare makes deterministic TOML
//! edits and consumes/archives fragments").
//!
//! Archive destination: `changes/archive/<transaction_id>/`. The
//! transaction id in the path prevents collisions between transactions.
//! Archival is idempotent: if a fragment is already archived (not in
//! `changes/`), it is a no-op, not an error.
//!
//! This module provides the pure logic for deciding which files to move.
//! The actual I/O (filesystem moves) is the narrow boundary in the
//! command layer, mirroring how `discovered.rs` isolates I/O from the pure
//! `discover()` projection.

use crate::release::change::ChangeFragmentFile;

/// The set of fragment files to archive for one transaction.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ArchiveSet {
    /// Source paths (relative to repo root) to move into the archive.
    pub(crate) sources: Vec<String>,
    /// The archive directory (e.g. `changes/archive/2026-08-15.01`).
    pub(crate) destination: String,
}

/// Compute the archive set for a list of loaded fragment files and a
/// transaction id. Pure: no I/O. The caller performs the actual moves.
///
/// The destination is `changes/archive/<transaction_id>/`. Source paths
/// are `changes/<file_name>`.
pub(crate) fn compute_archive_set(
    fragments: &[ChangeFragmentFile],
    transaction_id: &str,
) -> ArchiveSet {
    let mut sources: Vec<String> = fragments
        .iter()
        .map(|f| format!("changes/{}", f.file_name))
        .collect();
    sources.sort();
    ArchiveSet {
        sources,
        destination: format!("changes/archive/{transaction_id}"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::release::change::{ChangeEntry, ChangeKind};

    fn fragment(name: &str, units: &[&str]) -> ChangeFragmentFile {
        ChangeFragmentFile {
            file_name: name.to_string(),
            entries: units
                .iter()
                .map(|u| ChangeEntry {
                    unit: u.to_string(),
                    kind: ChangeKind::Compatible,
                    summary: "test".to_string(),
                })
                .collect(),
        }
    }

    #[test]
    fn computes_archive_set() {
        let fragments = vec![
            fragment("001-auth-fix.toml", &["arcature-auth"]),
            fragment("002-db-break.toml", &["arcature-db"]),
        ];
        let set = compute_archive_set(&fragments, "2026-08-15.01");
        assert_eq!(set.destination, "changes/archive/2026-08-15.01");
        assert_eq!(set.sources.len(), 2);
        assert_eq!(set.sources[0], "changes/001-auth-fix.toml");
        assert_eq!(set.sources[1], "changes/002-db-break.toml");
    }

    #[test]
    fn empty_fragments_produce_empty_set() {
        let set = compute_archive_set(&[], "2026-08-15.01");
        assert_eq!(set.destination, "changes/archive/2026-08-15.01");
        assert!(set.sources.is_empty());
    }

    #[test]
    fn sources_are_sorted() {
        let fragments = vec![fragment("zzz.toml", &["a"]), fragment("aaa.toml", &["b"])];
        let set = compute_archive_set(&fragments, "2026-08-15.01");
        assert_eq!(set.sources[0], "changes/aaa.toml");
        assert_eq!(set.sources[1], "changes/zzz.toml");
    }
}