use camino::{Utf8Component, Utf8Path};
use serde::{Deserialize, Serialize};
use super::inventory::{Kind, Placement};
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Status {
Pending,
Mapped,
Written,
Verified,
Dropped,
}
impl Status {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Mapped => "mapped",
Self::Written => "written",
Self::Verified => "verified",
Self::Dropped => "dropped",
}
}
pub fn parse(word: &str) -> Result<Self, RkError> {
match word {
"pending" => Ok(Self::Pending),
"mapped" => Ok(Self::Mapped),
"written" => Ok(Self::Written),
"verified" => Ok(Self::Verified),
"dropped" => Ok(Self::Dropped),
other => Err(RkError::Usage(format!(
"unknown status '{other}'; the statuses are: pending, mapped, written, verified, dropped"
))),
}
}
#[must_use]
pub const fn finished(self) -> bool {
matches!(self, Self::Verified | Self::Dropped)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HistoryEntry {
pub at: String,
pub status: Status,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Source {
pub id: String,
pub path: String,
pub sha256: String,
pub tool: String,
pub kind: Kind,
pub placement: Placement,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
pub status: Status,
pub history: Vec<HistoryEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Group {
pub id: String,
pub sources: Vec<String>,
#[serde(default)]
pub destinations: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Snapshot {
pub id: String,
pub kind: String,
pub file: String,
pub sha256: String,
pub taken_at: String,
pub second_copy: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Registry {
pub schema_version: u32,
pub id: String,
pub base_commit: String,
pub sources: Vec<Source>,
#[serde(default)]
pub groups: Vec<Group>,
#[serde(default)]
pub snapshots: Vec<Snapshot>,
}
#[must_use]
pub fn source_id(path: &str) -> String {
slug(path)
}
pub fn assign_ids(sources: &mut [Source]) {
let mut order: Vec<usize> = (0..sources.len()).collect();
order.sort_by(|left, right| sources[*left].path.cmp(&sources[*right].path));
let mut taken = std::collections::BTreeSet::new();
for index in order {
let base = slug(&sources[index].path);
let mut id = base.clone();
let mut counter = 2_u32;
while taken.contains(&id) {
id = format!("{base}-{counter}");
counter += 1;
}
taken.insert(id.clone());
sources[index].id = id;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Integrity {
Intact,
Missing,
Altered,
}
pub fn integrity(workspace: &Utf8Path, snapshot: &Snapshot) -> std::io::Result<Integrity> {
match std::fs::read(workspace.join(&snapshot.file)) {
Ok(bytes) if crate::digest::Digest::of(&bytes).to_string() == snapshot.sha256 => {
Ok(Integrity::Intact)
}
Ok(_) => Ok(Integrity::Altered),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Integrity::Missing),
Err(error) => Err(error),
}
}
fn check_destinations(destinations: &[String]) -> Result<(), RkError> {
let escaping = destinations.iter().find(|destination| {
Utf8Path::new(destination.as_str())
.components()
.any(|part| !matches!(part, Utf8Component::Normal(_) | Utf8Component::CurDir))
});
escaping.map_or(Ok(()), |destination| {
Err(RkError::Usage(format!(
"the destination {destination} leaves the target; name a path relative to the target root"
)))
})
}
fn slug(text: &str) -> String {
let mut out = String::new();
for c in text.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
out.trim_matches('-').to_owned()
}
#[must_use]
pub fn is_slug(word: &str) -> bool {
!word.is_empty()
&& !word.starts_with('-')
&& !word.ends_with('-')
&& word
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
pub fn load(workspace: &Utf8Path) -> Result<Registry, RkError> {
let path = workspace.join(super::REGISTRY_FILE);
let text = std::fs::read_to_string(&path)?;
let registry: Registry = yaml_serde::from_str(&text).map_err(|error| {
RkError::refusal(
Diagnostic::new(
Reason::RecordInvalid,
format!("{path} is not a migration registry: {error}"),
)
.expected("the registry only rk migrate writes")
.target_state("unchanged"),
)
})?;
if registry.schema_version != SCHEMA_VERSION {
return Err(RkError::refusal(
Diagnostic::new(
Reason::UnsupportedSchema,
format!(
"{path} declares schema_version {}, and this binary knows only {SCHEMA_VERSION}",
registry.schema_version
),
)
.target_state("unchanged"),
));
}
Ok(registry)
}
pub fn save(workspace: &Utf8Path, registry: &Registry) -> Result<(), RkError> {
let text = yaml_serde::to_string(registry)
.map_err(|error| anyhow::anyhow!("the registry does not serialize: {error}"))?;
crate::atomic::write(
workspace.join(super::REGISTRY_FILE).as_std_path(),
text.as_bytes(),
)?;
Ok(())
}
#[derive(Debug, Default)]
pub struct Mark<'a> {
pub name: &'a str,
pub status: Option<Status>,
pub group: Option<&'a str>,
pub tags: &'a [String],
pub destinations: &'a [String],
pub reason: Option<&'a str>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Marked {
pub subject: &'static str,
pub id: String,
pub sources: Vec<String>,
}
impl Registry {
pub fn mark(&mut self, mark: &Mark<'_>, at: &str) -> Result<Marked, RkError> {
let status = mark
.status
.ok_or_else(|| RkError::Usage("name the status to record".into()))?;
if status == Status::Dropped && mark.reason.is_none_or(|reason| reason.trim().is_empty()) {
return Err(RkError::Usage(
"a drop records its reason: pass --reason \"<why>\" (migration:a-mark-appends-and-never-rewrites)".into(),
));
}
if status != Status::Dropped && mark.reason.is_some() {
return Err(RkError::Usage("--reason belongs to a drop alone".into()));
}
check_destinations(mark.destinations)?;
for word in mark
.group
.into_iter()
.chain(mark.tags.iter().map(String::as_str))
{
if !is_slug(word) {
return Err(RkError::Usage(format!(
"'{word}' is not a lowercase slug of letters, digits, and hyphens"
)));
}
}
let entry = HistoryEntry {
at: at.to_owned(),
status,
reason: mark.reason.map(str::to_owned),
};
if let Some(index) = self
.sources
.iter()
.position(|source| source.id == mark.name || source.path == mark.name)
{
if !mark.destinations.is_empty() {
return Err(RkError::Usage(
"--destination belongs to a group; name the group, or file this source with --group".into(),
));
}
let id = self.sources[index].id.clone();
if let Some(group) = mark.group {
for other in &mut self.groups {
other.sources.retain(|member| *member != id);
}
match self.groups.iter_mut().find(|other| other.id == group) {
Some(existing) => existing.sources.push(id.clone()),
None => self.groups.push(Group {
id: group.to_owned(),
sources: vec![id.clone()],
destinations: Vec::new(),
}),
}
self.sources[index].group = Some(group.to_owned());
}
let source = &mut self.sources[index];
for tag in mark.tags {
if !source.tags.contains(tag) {
source.tags.push(tag.clone());
}
}
source.status = status;
source.history.push(entry);
return Ok(Marked {
subject: "source",
id: id.clone(),
sources: vec![id],
});
}
if let Some(group) = self.groups.iter_mut().find(|group| group.id == mark.name) {
if mark.group.is_some() || !mark.tags.is_empty() {
return Err(RkError::Usage(
"--group and --tag belong to a source; a group gains destinations alone".into(),
));
}
for destination in mark.destinations {
if !group.destinations.contains(destination) {
group.destinations.push(destination.clone());
}
}
let members = group.sources.clone();
let id = group.id.clone();
for source in self
.sources
.iter_mut()
.filter(|source| members.contains(&source.id))
{
source.status = status;
source.history.push(entry.clone());
}
return Ok(Marked {
subject: "group",
id,
sources: members,
});
}
Err(RkError::Usage(format!(
"'{}' names no source and no group in the migration {}; rk migrate status --target . lists them",
mark.name, self.id
)))
}
#[must_use]
pub fn unfinished(&self, target: &Utf8Path) -> Vec<String> {
let mut reasons = Vec::new();
for source in &self.sources {
if !source.status.finished() {
reasons.push(format!("{} is {}", source.path, source.status.as_str()));
continue;
}
if source.status != Status::Verified {
continue;
}
let Some(group) = source
.group
.as_deref()
.and_then(|id| self.groups.iter().find(|group| group.id == id))
else {
reasons.push(format!(
"{} is verified and belongs to no group",
source.path
));
continue;
};
if group.destinations.is_empty() {
reasons.push(format!("the group {} names no destination", group.id));
}
}
let live = |group: &Group| {
group.sources.iter().any(|member| {
self.sources
.iter()
.any(|source| source.id == *member && source.status != Status::Dropped)
})
};
let root = std::fs::canonicalize(target).ok();
let held = |destination: &str| {
std::fs::canonicalize(target.join(destination)).is_ok_and(|path| {
path.is_file() && root.as_ref().is_some_and(|root| path.starts_with(root))
})
};
for group in self.groups.iter().filter(|group| live(group)) {
for destination in &group.destinations {
if !held(destination) {
reasons.push(format!(
"the group {} names {destination}, which the target does not hold",
group.id
));
}
}
}
reasons.sort();
reasons.dedup();
reasons
}
}
#[cfg(test)]
mod tests {
use super::{
Group, HistoryEntry, Mark, Registry, SCHEMA_VERSION, Source, Status, assign_ids, source_id,
};
use crate::migrate::inventory::{Kind, Placement};
fn registry() -> Registry {
let source = |path: &str| Source {
id: source_id(path),
path: path.to_owned(),
sha256: "0".repeat(64),
tool: "release-please".to_owned(),
kind: Kind::ReleaseConfig,
placement: Placement::Archived,
tags: Vec::new(),
group: None,
status: Status::Pending,
history: vec![HistoryEntry {
at: "2026-10-09T14:00:00Z".to_owned(),
status: Status::Pending,
reason: None,
}],
};
Registry {
schema_version: SCHEMA_VERSION,
id: "20261009T140000Z".to_owned(),
base_commit: "4f1c2e0".to_owned(),
sources: vec![
source("release-please-config.json"),
source(".release-please-manifest.json"),
],
groups: Vec::<Group>::new(),
snapshots: Vec::new(),
}
}
#[test]
fn a_source_id_is_a_slug_of_its_path() {
assert_eq!(
source_id(".github/workflows/release-please.yml"),
"github-workflows-release-please-yml"
);
}
#[test]
fn a_mark_appends_history_and_never_rewrites_an_earlier_entry() {
let mut registry = registry();
let before = registry.sources[0].history.clone();
let tags = vec!["versioning".to_owned()];
registry
.mark(
&Mark {
name: "release-please-config.json",
status: Some(Status::Mapped),
group: Some("release-please"),
tags: &tags,
..Mark::default()
},
"2026-10-09T15:00:00Z",
)
.expect("the source files into a new group");
let destinations = vec!["release-plz.toml".to_owned()];
let marked = registry
.mark(
&Mark {
name: "release-please",
status: Some(Status::Written),
destinations: &destinations,
..Mark::default()
},
"2026-10-09T16:00:00Z",
)
.expect("the group moves its members");
assert_eq!(marked.sources, ["release-please-config-json"]);
let source = ®istry.sources[0];
assert_eq!(&source.history[..before.len()], &before[..]);
assert_eq!(
source
.history
.iter()
.map(|entry| entry.status)
.collect::<Vec<_>>(),
[Status::Pending, Status::Mapped, Status::Written]
);
assert_eq!(source.tags, ["versioning"]);
assert_eq!(registry.groups[0].destinations, ["release-plz.toml"]);
}
#[test]
fn a_drop_needs_its_reason_and_a_flag_must_fit_its_subject() {
let mut registry = registry();
let at = "2026-10-09T15:00:00Z";
assert!(
registry
.mark(
&Mark {
name: "release-please-config.json",
status: Some(Status::Dropped),
..Mark::default()
},
at
)
.is_err()
);
let destinations = vec!["x".to_owned()];
assert!(
registry
.mark(
&Mark {
name: "release-please-config.json",
status: Some(Status::Mapped),
destinations: &destinations,
..Mark::default()
},
at
)
.is_err()
);
assert!(
registry
.mark(
&Mark {
name: "nothing-here",
status: Some(Status::Mapped),
..Mark::default()
},
at
)
.is_err()
);
let marked = registry
.mark(
&Mark {
name: "release-please-config-json",
status: Some(Status::Dropped),
reason: Some("release-plz replaces it"),
..Mark::default()
},
at,
)
.expect("a drop with its reason records");
assert_eq!(marked.subject, "source");
assert_eq!(
registry.sources[0]
.history
.last()
.and_then(|entry| entry.reason.as_deref()),
Some("release-plz replaces it")
);
}
#[test]
fn colliding_source_paths_take_distinct_ids() {
let mut registry = registry();
for source in &mut registry.sources {
source.path = source
.path
.replace("release-please-config.json", "goreleaser.yaml");
source.path = source
.path
.replace(".release-please-manifest.json", ".goreleaser.yaml");
}
assign_ids(&mut registry.sources);
let ids: Vec<(&str, &str)> = registry
.sources
.iter()
.map(|source| (source.path.as_str(), source.id.as_str()))
.collect();
assert_eq!(
ids,
[
("goreleaser.yaml", "goreleaser-yaml-2"),
(".goreleaser.yaml", "goreleaser-yaml"),
]
);
}
#[test]
fn a_destination_outside_the_target_refuses() {
let at = "2026-10-09T15:00:00Z";
for destination in ["/etc/release.toml", "../release.toml", "a/../../b"] {
let mut registry = registry();
registry
.mark(
&Mark {
name: "release-please-config.json",
status: Some(Status::Mapped),
group: Some("release-please"),
..Mark::default()
},
at,
)
.expect("the source files into a group");
let before = registry.clone();
let destinations = vec![destination.to_owned()];
let error = registry
.mark(
&Mark {
name: "release-please",
status: Some(Status::Written),
destinations: &destinations,
..Mark::default()
},
at,
)
.expect_err("an escaping destination refuses");
assert_eq!(error.exit_code(), 64, "{destination}");
assert!(
error.to_string().contains("leaves the target"),
"{destination}: {error}"
);
assert_eq!(registry, before, "{destination}: nothing was recorded");
}
}
#[test]
fn a_group_with_no_live_source_does_not_block_close() {
let target = tempfile::tempdir().expect("a scratch target exists");
let target = camino::Utf8Path::from_path(target.path()).expect("UTF-8");
let mut registry = registry();
let at = "2026-10-09T15:00:00Z";
let destinations = vec!["release-plz.toml".to_owned()];
for (name, status) in [
("release-please-config.json", Status::Mapped),
(".release-please-manifest.json", Status::Mapped),
] {
registry
.mark(
&Mark {
name,
status: Some(status),
group: Some("release-please"),
..Mark::default()
},
at,
)
.expect("files into the group");
}
registry
.mark(
&Mark {
name: "release-please",
status: Some(Status::Written),
destinations: &destinations,
..Mark::default()
},
at,
)
.expect("the group plans its destination");
assert!(
registry
.unfinished(target)
.iter()
.any(|reason| reason.contains("release-plz.toml")),
"a live group's absent destination blocks"
);
for name in [
"release-please-config.json",
".release-please-manifest.json",
] {
registry
.mark(
&Mark {
name,
status: Some(Status::Dropped),
reason: Some("the convention replaces it"),
..Mark::default()
},
at,
)
.expect("drops");
}
assert_eq!(registry.unfinished(target), Vec::<String>::new());
}
#[test]
fn a_destination_reached_through_a_symlink_out_of_the_target_does_not_count() {
let dir = tempfile::tempdir().expect("a scratch target exists");
let outside = tempfile::tempdir().expect("an outside directory exists");
std::fs::write(outside.path().join("release.toml"), "").expect("writes");
std::os::unix::fs::symlink(outside.path(), dir.path().join("docs")).expect("links");
let target = camino::Utf8Path::from_path(dir.path()).expect("UTF-8");
let mut registry = registry();
let at = "2026-10-09T15:00:00Z";
registry
.mark(
&Mark {
name: "release-please-config.json",
status: Some(Status::Mapped),
group: Some("release-please"),
..Mark::default()
},
at,
)
.expect("files into the group");
let destinations = vec!["docs/release.toml".to_owned()];
registry
.mark(
&Mark {
name: "release-please",
status: Some(Status::Written),
destinations: &destinations,
..Mark::default()
},
at,
)
.expect("a lexically relative destination records");
assert!(
registry
.unfinished(target)
.iter()
.any(|reason| reason.contains("docs/release.toml")),
"{:?}",
registry.unfinished(target)
);
}
#[test]
fn a_directory_destination_does_not_count() {
let dir = tempfile::tempdir().expect("a scratch target exists");
std::fs::create_dir_all(dir.path().join(".github/workflows")).expect("mkdir");
let target = camino::Utf8Path::from_path(dir.path()).expect("UTF-8");
let mut registry = registry();
let at = "2026-10-09T15:00:00Z";
registry
.mark(
&Mark {
name: "release-please-config.json",
status: Some(Status::Mapped),
group: Some("release-please"),
..Mark::default()
},
at,
)
.expect("files into the group");
let destinations = vec![".github/workflows".to_owned()];
registry
.mark(
&Mark {
name: "release-please",
status: Some(Status::Written),
destinations: &destinations,
..Mark::default()
},
at,
)
.expect("the group records its destination");
assert!(
registry
.unfinished(target)
.iter()
.any(|reason| reason.contains(".github/workflows")),
"{:?}",
registry.unfinished(target)
);
}
#[test]
fn the_registry_round_trips_through_yaml() {
let registry = registry();
let text = yaml_serde::to_string(®istry).expect("serializes");
let back: Registry = yaml_serde::from_str(&text).expect("parses");
assert_eq!(back, registry);
assert!(text.contains("status: pending"), "{text}");
}
}