1pub mod git;
17pub mod inventory;
18pub mod registry;
19
20use std::path::{Path, PathBuf};
21
22use camino::{Utf8Path, Utf8PathBuf};
23
24use crate::diagnostic::{Diagnostic, Reason};
25use crate::error::RkError;
26
27pub const SCHEMA: &str = "rk.migrate/1";
29
30pub const MIGRATIONS_DIR: &str = "migrations";
32
33pub const SOURCES_DIR: &str = "sources";
36
37pub const REFS_DIR: &str = "refs";
40
41pub const REGISTRY_FILE: &str = "registry.yaml";
43
44#[must_use]
50pub fn workspace(canonical_target: &Path, id: &str) -> Option<PathBuf> {
51 Some(runs(canonical_target)?.join(id))
52}
53
54#[must_use]
57pub fn runs(canonical_target: &Path) -> Option<PathBuf> {
58 Some(
59 crate::applog::state_root()?
60 .join(MIGRATIONS_DIR)
61 .join(crate::snapshot::repo_key(canonical_target)),
62 )
63}
64
65pub fn claim(runs: &Path, stamp: &str) -> std::io::Result<(String, PathBuf)> {
76 let path = crate::snapshot::claim(runs, stamp)?;
77 let id = path
78 .file_name()
79 .and_then(std::ffi::OsStr::to_str)
80 .map(str::to_owned)
81 .ok_or_else(|| std::io::Error::other("a claimed workspace has no name"))?;
82 Ok((id, path))
83}
84
85#[must_use]
88pub fn free_id(runs: &Path, stamp: &str) -> String {
89 let mut id = stamp.to_owned();
90 let mut counter = 1_u32;
91 while runs.join(&id).exists() {
92 counter += 1;
93 id = format!("{stamp}-{counter}");
94 }
95 id
96}
97
98#[must_use]
105pub fn is_id(word: &str) -> bool {
106 let bytes = word.as_bytes();
109 let (stamp, counter) = bytes.split_at(bytes.len().min(16));
110 let digits = |part: &[u8]| part.iter().all(u8::is_ascii_digit);
111 let stamp_ok = stamp.len() == 16
112 && digits(&stamp[..8])
113 && stamp[8] == b'T'
114 && digits(&stamp[9..15])
115 && stamp[15] == b'Z';
116 let counter_ok = counter.is_empty()
117 || counter.strip_prefix(b"-").is_some_and(|number| {
118 number.first().is_some_and(|first| *first != b'0')
119 && digits(number)
120 && std::str::from_utf8(number)
121 .ok()
122 .and_then(|text| text.parse::<u32>().ok())
123 .is_some_and(|n| n >= 2)
124 });
125 stamp_ok && counter_ok
126}
127
128pub fn open_id(target: &Utf8Path) -> Result<Option<String>, RkError> {
134 Ok(crate::config::load(target.as_std_path())?.and_then(|config| config.migration))
135}
136
137pub fn open_workspace(target: &Utf8Path) -> Result<Option<Utf8PathBuf>, RkError> {
145 let Some(id) = open_id(target)? else {
146 return Ok(None);
147 };
148 let canonical = std::fs::canonicalize(target.as_std_path())?;
149 Ok(workspace(&canonical, &id).and_then(|path| Utf8PathBuf::from_path_buf(path).ok()))
150}
151
152#[must_use]
154pub fn stamp(now: std::time::SystemTime) -> String {
155 at(now).replace(['-', ':'], "")
156}
157
158#[must_use]
160pub fn at(now: std::time::SystemTime) -> String {
161 humantime::format_rfc3339_seconds(now).to_string()
162}
163
164#[must_use]
166pub fn none_open() -> RkError {
167 RkError::refusal(
168 Diagnostic::new(
169 Reason::PrerequisiteUnmet,
170 "no migration is open here; rk migrate archive opens one (migration:the-configuration-names-the-open-migration)",
171 )
172 .expected("a migration key in .release-kit/config.toml")
173 .action("rk migrate inventory --target . lists what an archive would move")
174 .target_state("unchanged"),
175 )
176}
177
178#[must_use]
181pub fn workspace_missing(id: &str, path: &Utf8Path) -> RkError {
182 RkError::refusal(
183 Diagnostic::new(
184 Reason::PrerequisiteUnmet,
185 format!(
186 "the migration {id} is open, and this host holds no workspace for it at {path} (migration:a-rebuild-recreates-the-files-alone)"
187 ),
188 )
189 .expected("the workspace the archive wrote, on this host")
190 .action("rk migrate archive --rebuild --apply --target . recreates the archived files from Git")
191 .target_state("unchanged"),
192 )
193}
194
195#[cfg(test)]
196mod tests {
197 use super::{at, claim, free_id, is_id, stamp};
198
199 #[test]
200 fn an_identifier_is_the_utc_second_it_opened() {
201 let when = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_791_554_525);
202 assert_eq!(at(when), "2026-10-09T14:02:05Z");
203 assert_eq!(stamp(when), "20261009T140205Z");
204 }
205
206 #[test]
209 fn a_second_archive_in_one_second_takes_the_next_free_identifier() {
210 let dir = tempfile::tempdir().expect("a scratch dir exists");
211 let runs = dir.path().join("migrations").join("widget-000000000000");
212 let stamp = "20261009T140205Z";
213 assert_eq!(free_id(&runs, stamp), stamp);
214 let (first, first_path) = claim(&runs, stamp).expect("claims");
215 assert_eq!(free_id(&runs, stamp), "20261009T140205Z-2");
216 let (second, second_path) = claim(&runs, stamp).expect("claims again");
217 assert_eq!(first, stamp);
218 assert_eq!(second, "20261009T140205Z-2");
219 assert_eq!(second_path, runs.join(&second));
220 assert_ne!(first_path, second_path);
221 assert!(is_id(&first) && is_id(&second));
222 }
223
224 #[test]
225 fn an_identifier_is_a_stamp_and_an_optional_counter() {
226 for good in [
227 "20261009T140205Z",
228 "20261009T140205Z-2",
229 "20261009T140205Z-12",
230 ] {
231 assert!(is_id(good), "{good}");
232 }
233 for bad in [
234 "",
235 "20261009T140205",
236 "20261009-140205Z",
237 "20261009T140205Z-",
238 "20261009T140205Z-1",
239 "20261009T140205Z-02",
240 "20261009T140205Z-2a",
241 "../20261009T140205Z",
242 "20261009T140205Z/..",
243 "20261009T140205\u{e9}",
246 "20261009T140205\u{e9}-2",
247 "20261009T140205Z-\u{e9}",
248 "\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}",
249 ] {
250 assert!(!is_id(bad), "{bad}");
251 }
252 }
253}