pub mod git;
pub mod inventory;
pub mod registry;
use std::path::{Path, PathBuf};
use camino::{Utf8Path, Utf8PathBuf};
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
pub const SCHEMA: &str = "rk.migrate/1";
pub const MIGRATIONS_DIR: &str = "migrations";
pub const SOURCES_DIR: &str = "sources";
pub const REFS_DIR: &str = "refs";
pub const REGISTRY_FILE: &str = "registry.yaml";
#[must_use]
pub fn workspace(canonical_target: &Path, id: &str) -> Option<PathBuf> {
Some(runs(canonical_target)?.join(id))
}
#[must_use]
pub fn runs(canonical_target: &Path) -> Option<PathBuf> {
Some(
crate::applog::state_root()?
.join(MIGRATIONS_DIR)
.join(crate::snapshot::repo_key(canonical_target)),
)
}
pub fn claim(runs: &Path, stamp: &str) -> std::io::Result<(String, PathBuf)> {
let path = crate::snapshot::claim(runs, stamp)?;
let id = path
.file_name()
.and_then(std::ffi::OsStr::to_str)
.map(str::to_owned)
.ok_or_else(|| std::io::Error::other("a claimed workspace has no name"))?;
Ok((id, path))
}
#[must_use]
pub fn free_id(runs: &Path, stamp: &str) -> String {
let mut id = stamp.to_owned();
let mut counter = 1_u32;
while runs.join(&id).exists() {
counter += 1;
id = format!("{stamp}-{counter}");
}
id
}
#[must_use]
pub fn is_id(word: &str) -> bool {
let bytes = word.as_bytes();
let (stamp, counter) = bytes.split_at(bytes.len().min(16));
let digits = |part: &[u8]| part.iter().all(u8::is_ascii_digit);
let stamp_ok = stamp.len() == 16
&& digits(&stamp[..8])
&& stamp[8] == b'T'
&& digits(&stamp[9..15])
&& stamp[15] == b'Z';
let counter_ok = counter.is_empty()
|| counter.strip_prefix(b"-").is_some_and(|number| {
number.first().is_some_and(|first| *first != b'0')
&& digits(number)
&& std::str::from_utf8(number)
.ok()
.and_then(|text| text.parse::<u32>().ok())
.is_some_and(|n| n >= 2)
});
stamp_ok && counter_ok
}
pub fn open_id(target: &Utf8Path) -> Result<Option<String>, RkError> {
Ok(crate::config::load(target.as_std_path())?.and_then(|config| config.migration))
}
pub fn open_workspace(target: &Utf8Path) -> Result<Option<Utf8PathBuf>, RkError> {
let Some(id) = open_id(target)? else {
return Ok(None);
};
let canonical = std::fs::canonicalize(target.as_std_path())?;
Ok(workspace(&canonical, &id).and_then(|path| Utf8PathBuf::from_path_buf(path).ok()))
}
#[must_use]
pub fn stamp(now: std::time::SystemTime) -> String {
at(now).replace(['-', ':'], "")
}
#[must_use]
pub fn at(now: std::time::SystemTime) -> String {
humantime::format_rfc3339_seconds(now).to_string()
}
#[must_use]
pub fn none_open() -> RkError {
RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
"no migration is open here; rk migrate archive opens one (migration:the-configuration-names-the-open-migration)",
)
.expected("a migration key in .release-kit/config.toml")
.action("rk migrate inventory --target . lists what an archive would move")
.target_state("unchanged"),
)
}
#[must_use]
pub fn workspace_missing(id: &str, path: &Utf8Path) -> RkError {
RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
format!(
"the migration {id} is open, and this host holds no workspace for it at {path} (migration:a-rebuild-recreates-the-files-alone)"
),
)
.expected("the workspace the archive wrote, on this host")
.action("rk migrate archive --rebuild --apply --target . recreates the archived files from Git")
.target_state("unchanged"),
)
}
#[cfg(test)]
mod tests {
use super::{at, claim, free_id, is_id, stamp};
#[test]
fn an_identifier_is_the_utc_second_it_opened() {
let when = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_791_554_525);
assert_eq!(at(when), "2026-10-09T14:02:05Z");
assert_eq!(stamp(when), "20261009T140205Z");
}
#[test]
fn a_second_archive_in_one_second_takes_the_next_free_identifier() {
let dir = tempfile::tempdir().expect("a scratch dir exists");
let runs = dir.path().join("migrations").join("widget-000000000000");
let stamp = "20261009T140205Z";
assert_eq!(free_id(&runs, stamp), stamp);
let (first, first_path) = claim(&runs, stamp).expect("claims");
assert_eq!(free_id(&runs, stamp), "20261009T140205Z-2");
let (second, second_path) = claim(&runs, stamp).expect("claims again");
assert_eq!(first, stamp);
assert_eq!(second, "20261009T140205Z-2");
assert_eq!(second_path, runs.join(&second));
assert_ne!(first_path, second_path);
assert!(is_id(&first) && is_id(&second));
}
#[test]
fn an_identifier_is_a_stamp_and_an_optional_counter() {
for good in [
"20261009T140205Z",
"20261009T140205Z-2",
"20261009T140205Z-12",
] {
assert!(is_id(good), "{good}");
}
for bad in [
"",
"20261009T140205",
"20261009-140205Z",
"20261009T140205Z-",
"20261009T140205Z-1",
"20261009T140205Z-02",
"20261009T140205Z-2a",
"../20261009T140205Z",
"20261009T140205Z/..",
"20261009T140205\u{e9}",
"20261009T140205\u{e9}-2",
"20261009T140205Z-\u{e9}",
"\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}",
] {
assert!(!is_id(bad), "{bad}");
}
}
}