Skip to main content

release_kit/migrate/
registry.rs

1//! The migration's record: `registry.yaml` in the workspace.
2//!
3//! Only the `rk migrate` verbs write it. Each source is one listed file
4//! with its checksum, its status, and its history; each group gathers the
5//! sources one predecessor mechanism spread across files and names the
6//! destinations that now carry what the project kept; each snapshot is a
7//! copy of what no Git holds. A status change appends a dated history
8//! entry and never rewrites an earlier one, so the registry is also the
9//! migration's log.
10
11use camino::{Utf8Component, Utf8Path};
12use serde::{Deserialize, Serialize};
13
14use super::inventory::{Kind, Placement};
15use crate::diagnostic::{Diagnostic, Reason};
16use crate::error::RkError;
17
18/// The registry shape this binary writes.
19pub const SCHEMA_VERSION: u32 = 1;
20
21/// Where one source stands.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum Status {
25    /// Listed, and no fate decided yet.
26    Pending,
27    /// Its fate is decided: a group, or a destination.
28    Mapped,
29    /// What it carried is written to its destinations.
30    Written,
31    /// Its destinations were read against it and hold what it carried.
32    Verified,
33    /// Nothing of it is kept, for a stated reason.
34    Dropped,
35}
36
37impl Status {
38    /// The kebab-case word.
39    #[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    /// Parse the word an operator types.
51    ///
52    /// # Errors
53    ///
54    /// A usage error naming the five words.
55    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    /// Whether a source in this status lets the migration close.
69    #[must_use]
70    pub const fn finished(self) -> bool {
71        matches!(self, Self::Verified | Self::Dropped)
72    }
73}
74
75/// One dated status change.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct HistoryEntry {
78    /// When, UTC to the second.
79    pub at: String,
80    /// The status it moved to.
81    pub status: Status,
82    /// Why, on a drop alone.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub reason: Option<String>,
85}
86
87/// One listed file.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct Source {
90    /// A slug made from the path.
91    pub id: String,
92    /// The target-relative path it had.
93    pub path: String,
94    /// The sha256 of the bytes the archive took.
95    pub sha256: String,
96    /// The tool that owns it.
97    pub tool: String,
98    /// What it is to its tool.
99    pub kind: Kind,
100    /// Whether the archive moved it or it stayed in place.
101    pub placement: Placement,
102    /// Free topic labels; sources that share one are candidates for one
103    /// group.
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub tags: Vec<String>,
106    /// The group it belongs to, once mapped into one.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub group: Option<String>,
109    /// Where it stands now.
110    pub status: Status,
111    /// Every status it took, oldest first.
112    pub history: Vec<HistoryEntry>,
113}
114
115/// The sources one mechanism spread across files, and where what the
116/// project kept now lives.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct Group {
119    /// A lowercase slug.
120    pub id: String,
121    /// The member source ids.
122    pub sources: Vec<String>,
123    /// The target-relative paths that carry what the group kept.
124    #[serde(default)]
125    pub destinations: Vec<String>,
126}
127
128/// A copy of what no Git holds, taken before anything destructive.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct Snapshot {
131    /// What it saves, as a slug.
132    pub id: String,
133    /// `git-bundle`, `tag-list`, or a forge snapshot's kind.
134    pub kind: String,
135    /// The workspace-relative file.
136    pub file: String,
137    /// The sha256 of the file.
138    pub sha256: String,
139    /// When it was taken.
140    pub taken_at: String,
141    /// Always false: a snapshot has no second copy, and a rebuild
142    /// recreates the archived files alone.
143    pub second_copy: bool,
144}
145
146/// The whole registry.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct Registry {
149    /// The registry shape.
150    pub schema_version: u32,
151    /// The migration's identifier.
152    pub id: String,
153    /// The commit the archive read every source from.
154    pub base_commit: String,
155    /// Every listed file.
156    pub sources: Vec<Source>,
157    /// Every group the map has made.
158    #[serde(default)]
159    pub groups: Vec<Group>,
160    /// Every snapshot the workspace holds.
161    #[serde(default)]
162    pub snapshots: Vec<Snapshot>,
163}
164
165/// A source id: the path, lowercased, with every run of other characters
166/// turned into one hyphen.
167#[must_use]
168pub fn source_id(path: &str) -> String {
169    slug(path)
170}
171
172/// Give every source a distinct id. In byte order of the paths, each
173/// source takes the slug of its path, or, where an earlier path took that
174/// slug, the slug with the first free `-<n>` counter from 2.
175pub 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/// What the file a snapshot names holds against its recorded checksum.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum Integrity {
195    /// The recorded bytes.
196    Intact,
197    /// No file.
198    Missing,
199    /// Other bytes.
200    Altered,
201}
202
203/// Read the file `snapshot` names under `workspace` against its checksum.
204///
205/// # Errors
206///
207/// A read that fails for any reason but an absent file, so a snapshot
208/// that may still be whole is never mistaken for a damaged one.
209pub 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
220/// Refuse a destination that leaves the target: one judged under the
221/// target at close, which an absolute path or a parent step would turn
222/// into a judgment of another tree.
223fn 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
236/// A lowercase slug of `text`.
237fn 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/// Whether `word` is a lowercase slug, the shape of a group and a tag.
250#[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
260/// Read the registry of the workspace at `workspace`.
261///
262/// # Errors
263///
264/// [`RkError::Io`] for an unreadable file, and a refusal for one that does
265/// not parse at this binary's shape.
266pub 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
294/// Write the registry atomically.
295///
296/// # Errors
297///
298/// A serialization or write failure.
299pub 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/// What one `mark` names and asks.
310#[derive(Debug, Default)]
311pub struct Mark<'a> {
312    /// A source id, a source path, or a group id.
313    pub name: &'a str,
314    /// The status to record.
315    pub status: Option<Status>,
316    /// The group a source joins.
317    pub group: Option<&'a str>,
318    /// Tags a source gains.
319    pub tags: &'a [String],
320    /// Destinations a group gains.
321    pub destinations: &'a [String],
322    /// Why, on a drop.
323    pub reason: Option<&'a str>,
324}
325
326/// What a mark changed: the sources it moved.
327#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
328pub struct Marked {
329    /// `source` or `group`.
330    pub subject: &'static str,
331    /// The name it resolved.
332    pub id: String,
333    /// The sources whose status it recorded.
334    pub sources: Vec<String>,
335}
336
337impl Registry {
338    /// Record one mark, appending a history entry to every source it moves.
339    ///
340    /// # Errors
341    ///
342    /// A usage error for a name that resolves to nothing, a flag that does
343    /// not apply to what the name resolves to, a group or tag that is not
344    /// a slug, and a drop with no reason.
345    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    /// Why the migration cannot close yet, one reason per blocking fact,
447    /// judged against the target's own files.
448    #[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        // A group whose every source was dropped or filed elsewhere
475        // carries nothing, so its planned destinations bind nothing.
476        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        // A destination counts only as a regular file whose resolved path
484        // stays inside the target: a symlink out of it would judge another
485        // tree, and a directory carries nothing a group wrote.
486        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    /// SATISFIES migration:a-mark-appends-and-never-rewrites
554    #[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 = &registry.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    /// Two paths that slug alike take distinct ids, the later path in
662    /// byte order taking the counter, whatever order they arrive in.
663    #[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    /// A destination is judged under the target at close, so a path that
690    /// leaves it refuses as a usage error and records nothing.
691    #[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    /// A group whose sources were all dropped or filed elsewhere names
730    /// destinations that bind nothing, so they hold no close back.
731    #[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    /// A destination that resolves out of the target through a symlink
792    /// is not held, however its words read.
793    #[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    /// A destination that names a directory carries nothing a group
836    /// wrote, so it is not held.
837    #[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(&registry).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}