1use camino::{Utf8Component, Utf8Path};
12use serde::{Deserialize, Serialize};
13
14use super::inventory::{Kind, Placement};
15use crate::diagnostic::{Diagnostic, Reason};
16use crate::error::RkError;
17
18pub const SCHEMA_VERSION: u32 = 1;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum Status {
25 Pending,
27 Mapped,
29 Written,
31 Verified,
33 Dropped,
35}
36
37impl Status {
38 #[must_use]
40 pub const fn as_str(self) -> &'static str {
41 match self {
42 Self::Pending => "pending",
43 Self::Mapped => "mapped",
44 Self::Written => "written",
45 Self::Verified => "verified",
46 Self::Dropped => "dropped",
47 }
48 }
49
50 pub fn parse(word: &str) -> Result<Self, RkError> {
56 match word {
57 "pending" => Ok(Self::Pending),
58 "mapped" => Ok(Self::Mapped),
59 "written" => Ok(Self::Written),
60 "verified" => Ok(Self::Verified),
61 "dropped" => Ok(Self::Dropped),
62 other => Err(RkError::Usage(format!(
63 "unknown status '{other}'; the statuses are: pending, mapped, written, verified, dropped"
64 ))),
65 }
66 }
67
68 #[must_use]
70 pub const fn finished(self) -> bool {
71 matches!(self, Self::Verified | Self::Dropped)
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct HistoryEntry {
78 pub at: String,
80 pub status: Status,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub reason: Option<String>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct Source {
90 pub id: String,
92 pub path: String,
94 pub sha256: String,
96 pub tool: String,
98 pub kind: Kind,
100 pub placement: Placement,
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub tags: Vec<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub group: Option<String>,
109 pub status: Status,
111 pub history: Vec<HistoryEntry>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct Group {
119 pub id: String,
121 pub sources: Vec<String>,
123 #[serde(default)]
125 pub destinations: Vec<String>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct Snapshot {
131 pub id: String,
133 pub kind: String,
135 pub file: String,
137 pub sha256: String,
139 pub taken_at: String,
141 pub second_copy: bool,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct Registry {
149 pub schema_version: u32,
151 pub id: String,
153 pub base_commit: String,
155 pub sources: Vec<Source>,
157 #[serde(default)]
159 pub groups: Vec<Group>,
160 #[serde(default)]
162 pub snapshots: Vec<Snapshot>,
163}
164
165#[must_use]
168pub fn source_id(path: &str) -> String {
169 slug(path)
170}
171
172pub fn assign_ids(sources: &mut [Source]) {
176 let mut order: Vec<usize> = (0..sources.len()).collect();
177 order.sort_by(|left, right| sources[*left].path.cmp(&sources[*right].path));
178 let mut taken = std::collections::BTreeSet::new();
179 for index in order {
180 let base = slug(&sources[index].path);
181 let mut id = base.clone();
182 let mut counter = 2_u32;
183 while taken.contains(&id) {
184 id = format!("{base}-{counter}");
185 counter += 1;
186 }
187 taken.insert(id.clone());
188 sources[index].id = id;
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum Integrity {
195 Intact,
197 Missing,
199 Altered,
201}
202
203pub fn integrity(workspace: &Utf8Path, snapshot: &Snapshot) -> std::io::Result<Integrity> {
210 match std::fs::read(workspace.join(&snapshot.file)) {
211 Ok(bytes) if crate::digest::Digest::of(&bytes).to_string() == snapshot.sha256 => {
212 Ok(Integrity::Intact)
213 }
214 Ok(_) => Ok(Integrity::Altered),
215 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Integrity::Missing),
216 Err(error) => Err(error),
217 }
218}
219
220fn check_destinations(destinations: &[String]) -> Result<(), RkError> {
224 let escaping = destinations.iter().find(|destination| {
225 Utf8Path::new(destination.as_str())
226 .components()
227 .any(|part| !matches!(part, Utf8Component::Normal(_) | Utf8Component::CurDir))
228 });
229 escaping.map_or(Ok(()), |destination| {
230 Err(RkError::Usage(format!(
231 "the destination {destination} leaves the target; name a path relative to the target root"
232 )))
233 })
234}
235
236fn slug(text: &str) -> String {
238 let mut out = String::new();
239 for c in text.chars() {
240 if c.is_ascii_alphanumeric() {
241 out.push(c.to_ascii_lowercase());
242 } else if !out.ends_with('-') {
243 out.push('-');
244 }
245 }
246 out.trim_matches('-').to_owned()
247}
248
249#[must_use]
251pub fn is_slug(word: &str) -> bool {
252 !word.is_empty()
253 && !word.starts_with('-')
254 && !word.ends_with('-')
255 && word
256 .chars()
257 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
258}
259
260pub fn load(workspace: &Utf8Path) -> Result<Registry, RkError> {
267 let path = workspace.join(super::REGISTRY_FILE);
268 let text = std::fs::read_to_string(&path)?;
269 let registry: Registry = yaml_serde::from_str(&text).map_err(|error| {
270 RkError::refusal(
271 Diagnostic::new(
272 Reason::RecordInvalid,
273 format!("{path} is not a migration registry: {error}"),
274 )
275 .expected("the registry only rk migrate writes")
276 .target_state("unchanged"),
277 )
278 })?;
279 if registry.schema_version != SCHEMA_VERSION {
280 return Err(RkError::refusal(
281 Diagnostic::new(
282 Reason::UnsupportedSchema,
283 format!(
284 "{path} declares schema_version {}, and this binary knows only {SCHEMA_VERSION}",
285 registry.schema_version
286 ),
287 )
288 .target_state("unchanged"),
289 ));
290 }
291 Ok(registry)
292}
293
294pub fn save(workspace: &Utf8Path, registry: &Registry) -> Result<(), RkError> {
300 let text = yaml_serde::to_string(registry)
301 .map_err(|error| anyhow::anyhow!("the registry does not serialize: {error}"))?;
302 crate::atomic::write(
303 workspace.join(super::REGISTRY_FILE).as_std_path(),
304 text.as_bytes(),
305 )?;
306 Ok(())
307}
308
309#[derive(Debug, Default)]
311pub struct Mark<'a> {
312 pub name: &'a str,
314 pub status: Option<Status>,
316 pub group: Option<&'a str>,
318 pub tags: &'a [String],
320 pub destinations: &'a [String],
322 pub reason: Option<&'a str>,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
328pub struct Marked {
329 pub subject: &'static str,
331 pub id: String,
333 pub sources: Vec<String>,
335}
336
337impl Registry {
338 pub fn mark(&mut self, mark: &Mark<'_>, at: &str) -> Result<Marked, RkError> {
346 let status = mark
347 .status
348 .ok_or_else(|| RkError::Usage("name the status to record".into()))?;
349 if status == Status::Dropped && mark.reason.is_none_or(|reason| reason.trim().is_empty()) {
350 return Err(RkError::Usage(
351 "a drop records its reason: pass --reason \"<why>\" (migration:a-mark-appends-and-never-rewrites)".into(),
352 ));
353 }
354 if status != Status::Dropped && mark.reason.is_some() {
355 return Err(RkError::Usage("--reason belongs to a drop alone".into()));
356 }
357 check_destinations(mark.destinations)?;
358 for word in mark
359 .group
360 .into_iter()
361 .chain(mark.tags.iter().map(String::as_str))
362 {
363 if !is_slug(word) {
364 return Err(RkError::Usage(format!(
365 "'{word}' is not a lowercase slug of letters, digits, and hyphens"
366 )));
367 }
368 }
369 let entry = HistoryEntry {
370 at: at.to_owned(),
371 status,
372 reason: mark.reason.map(str::to_owned),
373 };
374 if let Some(index) = self
375 .sources
376 .iter()
377 .position(|source| source.id == mark.name || source.path == mark.name)
378 {
379 if !mark.destinations.is_empty() {
380 return Err(RkError::Usage(
381 "--destination belongs to a group; name the group, or file this source with --group".into(),
382 ));
383 }
384 let id = self.sources[index].id.clone();
385 if let Some(group) = mark.group {
386 for other in &mut self.groups {
387 other.sources.retain(|member| *member != id);
388 }
389 match self.groups.iter_mut().find(|other| other.id == group) {
390 Some(existing) => existing.sources.push(id.clone()),
391 None => self.groups.push(Group {
392 id: group.to_owned(),
393 sources: vec![id.clone()],
394 destinations: Vec::new(),
395 }),
396 }
397 self.sources[index].group = Some(group.to_owned());
398 }
399 let source = &mut self.sources[index];
400 for tag in mark.tags {
401 if !source.tags.contains(tag) {
402 source.tags.push(tag.clone());
403 }
404 }
405 source.status = status;
406 source.history.push(entry);
407 return Ok(Marked {
408 subject: "source",
409 id: id.clone(),
410 sources: vec![id],
411 });
412 }
413 if let Some(group) = self.groups.iter_mut().find(|group| group.id == mark.name) {
414 if mark.group.is_some() || !mark.tags.is_empty() {
415 return Err(RkError::Usage(
416 "--group and --tag belong to a source; a group gains destinations alone".into(),
417 ));
418 }
419 for destination in mark.destinations {
420 if !group.destinations.contains(destination) {
421 group.destinations.push(destination.clone());
422 }
423 }
424 let members = group.sources.clone();
425 let id = group.id.clone();
426 for source in self
427 .sources
428 .iter_mut()
429 .filter(|source| members.contains(&source.id))
430 {
431 source.status = status;
432 source.history.push(entry.clone());
433 }
434 return Ok(Marked {
435 subject: "group",
436 id,
437 sources: members,
438 });
439 }
440 Err(RkError::Usage(format!(
441 "'{}' names no source and no group in the migration {}; rk migrate status --target . lists them",
442 mark.name, self.id
443 )))
444 }
445
446 #[must_use]
449 pub fn unfinished(&self, target: &Utf8Path) -> Vec<String> {
450 let mut reasons = Vec::new();
451 for source in &self.sources {
452 if !source.status.finished() {
453 reasons.push(format!("{} is {}", source.path, source.status.as_str()));
454 continue;
455 }
456 if source.status != Status::Verified {
457 continue;
458 }
459 let Some(group) = source
460 .group
461 .as_deref()
462 .and_then(|id| self.groups.iter().find(|group| group.id == id))
463 else {
464 reasons.push(format!(
465 "{} is verified and belongs to no group",
466 source.path
467 ));
468 continue;
469 };
470 if group.destinations.is_empty() {
471 reasons.push(format!("the group {} names no destination", group.id));
472 }
473 }
474 let live = |group: &Group| {
477 group.sources.iter().any(|member| {
478 self.sources
479 .iter()
480 .any(|source| source.id == *member && source.status != Status::Dropped)
481 })
482 };
483 let root = std::fs::canonicalize(target).ok();
487 let held = |destination: &str| {
488 std::fs::canonicalize(target.join(destination)).is_ok_and(|path| {
489 path.is_file() && root.as_ref().is_some_and(|root| path.starts_with(root))
490 })
491 };
492 for group in self.groups.iter().filter(|group| live(group)) {
493 for destination in &group.destinations {
494 if !held(destination) {
495 reasons.push(format!(
496 "the group {} names {destination}, which the target does not hold",
497 group.id
498 ));
499 }
500 }
501 }
502 reasons.sort();
503 reasons.dedup();
504 reasons
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::{
511 Group, HistoryEntry, Mark, Registry, SCHEMA_VERSION, Source, Status, assign_ids, source_id,
512 };
513 use crate::migrate::inventory::{Kind, Placement};
514
515 fn registry() -> Registry {
516 let source = |path: &str| Source {
517 id: source_id(path),
518 path: path.to_owned(),
519 sha256: "0".repeat(64),
520 tool: "release-please".to_owned(),
521 kind: Kind::ReleaseConfig,
522 placement: Placement::Archived,
523 tags: Vec::new(),
524 group: None,
525 status: Status::Pending,
526 history: vec![HistoryEntry {
527 at: "2026-10-09T14:00:00Z".to_owned(),
528 status: Status::Pending,
529 reason: None,
530 }],
531 };
532 Registry {
533 schema_version: SCHEMA_VERSION,
534 id: "20261009T140000Z".to_owned(),
535 base_commit: "4f1c2e0".to_owned(),
536 sources: vec![
537 source("release-please-config.json"),
538 source(".release-please-manifest.json"),
539 ],
540 groups: Vec::<Group>::new(),
541 snapshots: Vec::new(),
542 }
543 }
544
545 #[test]
546 fn a_source_id_is_a_slug_of_its_path() {
547 assert_eq!(
548 source_id(".github/workflows/release-please.yml"),
549 "github-workflows-release-please-yml"
550 );
551 }
552
553 #[test]
555 fn a_mark_appends_history_and_never_rewrites_an_earlier_entry() {
556 let mut registry = registry();
557 let before = registry.sources[0].history.clone();
558 let tags = vec!["versioning".to_owned()];
559 registry
560 .mark(
561 &Mark {
562 name: "release-please-config.json",
563 status: Some(Status::Mapped),
564 group: Some("release-please"),
565 tags: &tags,
566 ..Mark::default()
567 },
568 "2026-10-09T15:00:00Z",
569 )
570 .expect("the source files into a new group");
571 let destinations = vec!["release-plz.toml".to_owned()];
572 let marked = registry
573 .mark(
574 &Mark {
575 name: "release-please",
576 status: Some(Status::Written),
577 destinations: &destinations,
578 ..Mark::default()
579 },
580 "2026-10-09T16:00:00Z",
581 )
582 .expect("the group moves its members");
583 assert_eq!(marked.sources, ["release-please-config-json"]);
584 let source = ®istry.sources[0];
585 assert_eq!(&source.history[..before.len()], &before[..]);
586 assert_eq!(
587 source
588 .history
589 .iter()
590 .map(|entry| entry.status)
591 .collect::<Vec<_>>(),
592 [Status::Pending, Status::Mapped, Status::Written]
593 );
594 assert_eq!(source.tags, ["versioning"]);
595 assert_eq!(registry.groups[0].destinations, ["release-plz.toml"]);
596 }
597
598 #[test]
599 fn a_drop_needs_its_reason_and_a_flag_must_fit_its_subject() {
600 let mut registry = registry();
601 let at = "2026-10-09T15:00:00Z";
602 assert!(
603 registry
604 .mark(
605 &Mark {
606 name: "release-please-config.json",
607 status: Some(Status::Dropped),
608 ..Mark::default()
609 },
610 at
611 )
612 .is_err()
613 );
614 let destinations = vec!["x".to_owned()];
615 assert!(
616 registry
617 .mark(
618 &Mark {
619 name: "release-please-config.json",
620 status: Some(Status::Mapped),
621 destinations: &destinations,
622 ..Mark::default()
623 },
624 at
625 )
626 .is_err()
627 );
628 assert!(
629 registry
630 .mark(
631 &Mark {
632 name: "nothing-here",
633 status: Some(Status::Mapped),
634 ..Mark::default()
635 },
636 at
637 )
638 .is_err()
639 );
640 let marked = registry
641 .mark(
642 &Mark {
643 name: "release-please-config-json",
644 status: Some(Status::Dropped),
645 reason: Some("release-plz replaces it"),
646 ..Mark::default()
647 },
648 at,
649 )
650 .expect("a drop with its reason records");
651 assert_eq!(marked.subject, "source");
652 assert_eq!(
653 registry.sources[0]
654 .history
655 .last()
656 .and_then(|entry| entry.reason.as_deref()),
657 Some("release-plz replaces it")
658 );
659 }
660
661 #[test]
664 fn colliding_source_paths_take_distinct_ids() {
665 let mut registry = registry();
666 for source in &mut registry.sources {
667 source.path = source
668 .path
669 .replace("release-please-config.json", "goreleaser.yaml");
670 source.path = source
671 .path
672 .replace(".release-please-manifest.json", ".goreleaser.yaml");
673 }
674 assign_ids(&mut registry.sources);
675 let ids: Vec<(&str, &str)> = registry
676 .sources
677 .iter()
678 .map(|source| (source.path.as_str(), source.id.as_str()))
679 .collect();
680 assert_eq!(
681 ids,
682 [
683 ("goreleaser.yaml", "goreleaser-yaml-2"),
684 (".goreleaser.yaml", "goreleaser-yaml"),
685 ]
686 );
687 }
688
689 #[test]
692 fn a_destination_outside_the_target_refuses() {
693 let at = "2026-10-09T15:00:00Z";
694 for destination in ["/etc/release.toml", "../release.toml", "a/../../b"] {
695 let mut registry = registry();
696 registry
697 .mark(
698 &Mark {
699 name: "release-please-config.json",
700 status: Some(Status::Mapped),
701 group: Some("release-please"),
702 ..Mark::default()
703 },
704 at,
705 )
706 .expect("the source files into a group");
707 let before = registry.clone();
708 let destinations = vec![destination.to_owned()];
709 let error = registry
710 .mark(
711 &Mark {
712 name: "release-please",
713 status: Some(Status::Written),
714 destinations: &destinations,
715 ..Mark::default()
716 },
717 at,
718 )
719 .expect_err("an escaping destination refuses");
720 assert_eq!(error.exit_code(), 64, "{destination}");
721 assert!(
722 error.to_string().contains("leaves the target"),
723 "{destination}: {error}"
724 );
725 assert_eq!(registry, before, "{destination}: nothing was recorded");
726 }
727 }
728
729 #[test]
732 fn a_group_with_no_live_source_does_not_block_close() {
733 let target = tempfile::tempdir().expect("a scratch target exists");
734 let target = camino::Utf8Path::from_path(target.path()).expect("UTF-8");
735 let mut registry = registry();
736 let at = "2026-10-09T15:00:00Z";
737 let destinations = vec!["release-plz.toml".to_owned()];
738 for (name, status) in [
739 ("release-please-config.json", Status::Mapped),
740 (".release-please-manifest.json", Status::Mapped),
741 ] {
742 registry
743 .mark(
744 &Mark {
745 name,
746 status: Some(status),
747 group: Some("release-please"),
748 ..Mark::default()
749 },
750 at,
751 )
752 .expect("files into the group");
753 }
754 registry
755 .mark(
756 &Mark {
757 name: "release-please",
758 status: Some(Status::Written),
759 destinations: &destinations,
760 ..Mark::default()
761 },
762 at,
763 )
764 .expect("the group plans its destination");
765 assert!(
766 registry
767 .unfinished(target)
768 .iter()
769 .any(|reason| reason.contains("release-plz.toml")),
770 "a live group's absent destination blocks"
771 );
772 for name in [
773 "release-please-config.json",
774 ".release-please-manifest.json",
775 ] {
776 registry
777 .mark(
778 &Mark {
779 name,
780 status: Some(Status::Dropped),
781 reason: Some("the convention replaces it"),
782 ..Mark::default()
783 },
784 at,
785 )
786 .expect("drops");
787 }
788 assert_eq!(registry.unfinished(target), Vec::<String>::new());
789 }
790
791 #[test]
794 fn a_destination_reached_through_a_symlink_out_of_the_target_does_not_count() {
795 let dir = tempfile::tempdir().expect("a scratch target exists");
796 let outside = tempfile::tempdir().expect("an outside directory exists");
797 std::fs::write(outside.path().join("release.toml"), "").expect("writes");
798 std::os::unix::fs::symlink(outside.path(), dir.path().join("docs")).expect("links");
799 let target = camino::Utf8Path::from_path(dir.path()).expect("UTF-8");
800 let mut registry = registry();
801 let at = "2026-10-09T15:00:00Z";
802 registry
803 .mark(
804 &Mark {
805 name: "release-please-config.json",
806 status: Some(Status::Mapped),
807 group: Some("release-please"),
808 ..Mark::default()
809 },
810 at,
811 )
812 .expect("files into the group");
813 let destinations = vec!["docs/release.toml".to_owned()];
814 registry
815 .mark(
816 &Mark {
817 name: "release-please",
818 status: Some(Status::Written),
819 destinations: &destinations,
820 ..Mark::default()
821 },
822 at,
823 )
824 .expect("a lexically relative destination records");
825 assert!(
826 registry
827 .unfinished(target)
828 .iter()
829 .any(|reason| reason.contains("docs/release.toml")),
830 "{:?}",
831 registry.unfinished(target)
832 );
833 }
834
835 #[test]
838 fn a_directory_destination_does_not_count() {
839 let dir = tempfile::tempdir().expect("a scratch target exists");
840 std::fs::create_dir_all(dir.path().join(".github/workflows")).expect("mkdir");
841 let target = camino::Utf8Path::from_path(dir.path()).expect("UTF-8");
842 let mut registry = registry();
843 let at = "2026-10-09T15:00:00Z";
844 registry
845 .mark(
846 &Mark {
847 name: "release-please-config.json",
848 status: Some(Status::Mapped),
849 group: Some("release-please"),
850 ..Mark::default()
851 },
852 at,
853 )
854 .expect("files into the group");
855 let destinations = vec![".github/workflows".to_owned()];
856 registry
857 .mark(
858 &Mark {
859 name: "release-please",
860 status: Some(Status::Written),
861 destinations: &destinations,
862 ..Mark::default()
863 },
864 at,
865 )
866 .expect("the group records its destination");
867 assert!(
868 registry
869 .unfinished(target)
870 .iter()
871 .any(|reason| reason.contains(".github/workflows")),
872 "{:?}",
873 registry.unfinished(target)
874 );
875 }
876
877 #[test]
878 fn the_registry_round_trips_through_yaml() {
879 let registry = registry();
880 let text = yaml_serde::to_string(®istry).expect("serializes");
881 let back: Registry = yaml_serde::from_str(&text).expect("parses");
882 assert_eq!(back, registry);
883 assert!(text.contains("status: pending"), "{text}");
884 }
885}