Skip to main content

release_kit/
landing.rs

1//! The target-side landing model: parameter resolution, what a target
2//! currently holds, and the direct writes.
3//!
4//! Every landable file has a declared kind, `rendered` files release-kit
5//! owns and may rewrite, `seeded` files the target tunes, `state` files
6//! the release automation maintains, and a `rendered` file's bytes are a
7//! deterministic function of the embedded sources plus the landing
8//! parameters, so a later command can compare what is on disk against
9//! what would be written.
10//!
11//! The pure pieces of that model, the kind table, the token rendering,
12//! the block templating, the splice and marker judgments, the pair
13//! selection, and the Nix crate-shape judgment, have one implementation
14//! in [`crate::projection`] and are re-exported here under their old
15//! names. What lives in this file is [`Params`], the resolved input every
16//! projection takes, the readers of a target's recorded destinations, and
17//! the submodules that lock, write, and record.
18pub mod apply;
19pub mod invariants;
20pub mod lock;
21pub mod manifest;
22
23use camino::Utf8Path;
24
25pub use crate::projection::{
26    AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
27    GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION, HOOKS_END, Kind,
28    LINE_PREFIX_RE_TOKEN, LINE_PREFIX_TOKEN, NIX_DESTINATIONS, NIX_WITHHOLDABLE, OWNER_TOKEN,
29    REPO_PLACEHOLDER, REPO_TOKEN, SCOPE_SHAPE, SCOPE_SHAPE_TOKEN, SECURITY_SPANS, STYLE_TOKEN,
30    TRUNK_BRANCH_TOKEN, authored, block_markers, destinations, extract_block, hooks_marker_defect,
31    kind_of, marker_defect, render, scope_is_shaped, splice_hooks_block, splice_marked_block,
32    substitute,
33};
34pub use manifest::{Style, Workflow};
35use serde::Serialize;
36
37use crate::diagnostic::{Diagnostic, Reason};
38use crate::error::RkError;
39
40/// The complete input to a projection. Comparisons reconstruct it from
41/// the landing record; landing verbs resolve their candidate inputs.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Params {
44    tech: String,
45    forge: String,
46    repo: String,
47    workflow: Workflow,
48    style: Option<Style>,
49    nix: bool,
50    trunk: String,
51    line_prefix: String,
52    security_contact: String,
53    security_response: String,
54}
55
56/// Explicit invocation answers; absence falls through to configuration.
57#[derive(Default)]
58pub struct Inputs<'a> {
59    /// Binding override.
60    pub tech: Option<&'a str>,
61    /// Forge override.
62    pub forge: Option<&'a str>,
63    /// Repository override.
64    pub repo: Option<&'a str>,
65    /// Workflow override.
66    pub workflow: Option<Workflow>,
67    /// Release style override.
68    pub style: Option<Style>,
69    /// Nix capability override.
70    pub nix: Option<bool>,
71}
72
73/// Compatibility policy for a landing candidate.
74#[derive(Clone, Copy, PartialEq, Eq)]
75pub enum Purpose {
76    /// A first landing.
77    Init,
78    /// A preview may leave the repository unresolved.
79    Preview,
80    /// An existing record supplies compatibility answers.
81    Upgrade,
82    /// A pre-record target requires an explicit release style.
83    Adopt,
84}
85
86impl Params {
87    /// Reconstruct every projection parameter from the record alone,
88    /// including the compatibility defaults applied when it was loaded.
89    #[must_use]
90    pub fn from_record(record: &manifest::Manifest) -> Self {
91        Self {
92            tech: record.tech.clone(),
93            forge: record.forge.clone(),
94            repo: record.parameters.repo.clone(),
95            workflow: record.parameters.workflow,
96            style: record.parameters.style,
97            nix: record.parameters.nix,
98            trunk: record.parameters.trunk.clone(),
99            line_prefix: record.parameters.line_prefix.clone(),
100            security_contact: record.parameters.security_contact.clone(),
101            security_response: record.parameters.security_response.clone(),
102        }
103    }
104
105    /// Resolve flags, configuration, recorded compatibility inputs or detection,
106    /// and finally the compiled defaults. Comparisons use `from_record` alone.
107    ///
108    /// # Errors
109    /// Refuses unresolved identity or a style an existing target has not answered.
110    pub fn resolve(
111        target: &Utf8Path,
112        flags: &Inputs<'_>,
113        config: Option<&crate::config::Config>,
114        record: Option<&manifest::Manifest>,
115        purpose: Purpose,
116    ) -> Result<Self, RkError> {
117        let answer = |flag: Option<&str>, configured: Option<&str>, recorded: Option<&str>| {
118            flag.or_else(|| configured.filter(|value| !value.is_empty()))
119                .or(recorded)
120                .map(str::to_owned)
121        };
122        let forge = answer(
123            flags.forge,
124            config.map(|c| c.project.forge.as_str()),
125            record.map(|r| r.forge.as_str()),
126        );
127        let repo = answer(
128            flags.repo,
129            config.map(|c| c.project.repo.as_str()),
130            record.map(|r| r.parameters.repo.as_str()),
131        );
132        let resolved = resolve(target, forge.as_deref(), repo.as_deref())?;
133        let tech = answer(
134            flags.tech,
135            config.map(|c| c.project.tech.as_str()),
136            record.map(|r| r.tech.as_str()),
137        )
138        .or_else(|| crate::detect::tech_of(target.as_std_path()).map(str::to_owned))
139        .ok_or_else(|| {
140            RkError::missing(
141                Diagnostic::new(
142                    Reason::TargetNotFound,
143                    "no technology detected: the target has no version file",
144                )
145                .action("pass --tech <rust|python|bash>"),
146            )
147        })?;
148        crate::projection::check_pair(&tech, &resolved.forge)?;
149        let workflow = flags
150            .workflow
151            .or_else(|| config.and_then(|c| c.landing.workflow))
152            .or_else(|| record.map(|r| r.parameters.workflow))
153            .unwrap_or(if purpose == Purpose::Adopt {
154                Workflow::Branches
155            } else {
156                Workflow::Worktree
157            });
158        let style = flags
159            .style
160            .or_else(|| config.and_then(|c| c.landing.style))
161            .or_else(|| record.and_then(|r| r.parameters.style));
162        let style = match (style, purpose) {
163            (None, Purpose::Upgrade | Purpose::Adopt) => return Err(RkError::Usage("the target carries no style parameter; set landing.style in .release-kit/config.toml or pass --style <trunk|lines>".into())),
164            (value, _) => Some(value.unwrap_or(Style::Trunk)),
165        };
166        let repo = resolved
167            .repo
168            .or_else(|| (purpose == Purpose::Preview).then(|| REPO_PLACEHOLDER.to_owned()))
169            .ok_or_else(repo_unresolved)?;
170        let trunk = config
171            .and_then(|c| c.project.trunk.clone())
172            .or_else(|| record.map(|r| r.parameters.trunk.clone()))
173            .unwrap_or_else(|| crate::config::TRUNK_DEFAULT.to_owned());
174        let line_prefix = config
175            .and_then(|c| c.setup.line_prefix.clone())
176            .or_else(|| record.map(|r| r.parameters.line_prefix.clone()))
177            .unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned());
178        // An explicitly present key wins, including an empty contact,
179        // which is how a target resets a recorded custom contact. An
180        // omitted key falls through to the record, so an upgrade under an
181        // older configuration keeps the policy the target already carries.
182        let security_contact = config
183            .and_then(|c| c.security.contact.clone())
184            .or_else(|| record.map(|r| r.parameters.security_contact.clone()))
185            .unwrap_or_default();
186        let security_contact =
187            crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
188        let security_response = config
189            .and_then(|c| c.security.response.clone())
190            .or_else(|| record.map(|r| r.parameters.security_response.clone()))
191            .unwrap_or_else(|| crate::config::RESPONSE_DEFAULT.to_owned());
192        let security_response = crate::config::canonical_response(&security_response)
193            .map_err(crate::config::invalid)?;
194        Ok(Self {
195            tech,
196            forge: resolved.forge,
197            repo,
198            workflow,
199            style,
200            nix: flags
201                .nix
202                .or_else(|| config.and_then(|c| c.landing.nix))
203                .or_else(|| record.map(|r| r.parameters.nix))
204                .unwrap_or(false),
205            trunk,
206            line_prefix,
207            security_contact,
208            security_response,
209        })
210    }
211
212    /// The binding selected for this landing.
213    #[must_use]
214    pub fn tech(&self) -> &str {
215        &self.tech
216    }
217
218    /// The forge selected for this landing.
219    #[must_use]
220    pub fn forge(&self) -> &str {
221        &self.forge
222    }
223
224    /// Whether this landing opted into Nix.
225    #[must_use]
226    pub const fn nix(&self) -> bool {
227        self.nix
228    }
229
230    /// The project path used by parameter-bearing blocks.
231    #[must_use]
232    pub fn repo(&self) -> &str {
233        &self.repo
234    }
235
236    /// The mode used by parameter-bearing blocks.
237    #[must_use]
238    pub const fn workflow(&self) -> Workflow {
239        self.workflow
240    }
241
242    /// The release style used by parameter-bearing blocks.
243    #[must_use]
244    pub const fn style(&self) -> Option<Style> {
245        self.style
246    }
247
248    /// The one permanent branch this landing writes into its artifacts.
249    #[must_use]
250    pub fn trunk(&self) -> &str {
251        &self.trunk
252    }
253
254    /// The release-line prefix this landing writes into its artifacts.
255    #[must_use]
256    pub fn line_prefix(&self) -> &str {
257        &self.line_prefix
258    }
259
260    /// The contact the landed policy names, empty for the forge's own
261    /// authored wording.
262    #[must_use]
263    pub fn security_contact(&self) -> &str {
264        &self.security_contact
265    }
266
267    /// The acknowledgment window the landed policy promises.
268    #[must_use]
269    pub fn security_response(&self) -> &str {
270        &self.security_response
271    }
272}
273
274#[cfg(test)]
275impl Params {
276    /// A parameter set for tests alone. Production code reaches `Params`
277    /// through `from_record` and `resolve` and through nothing else, and
278    /// this constructor is compiled out of the shipped binary.
279    pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
280        Self {
281            tech: "rust".to_owned(),
282            forge: "github".to_owned(),
283            repo: repo.to_owned(),
284            workflow: Workflow::Worktree,
285            style,
286            nix: false,
287            trunk: crate::config::TRUNK_DEFAULT.to_owned(),
288            line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
289            security_contact: String::new(),
290            security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
291        }
292    }
293
294    /// The same set with the two security parameters answered.
295    pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
296        Self {
297            security_contact: contact.to_owned(),
298            security_response: response.to_owned(),
299            ..Self::for_test("acme/widget", Some(Style::Trunk))
300        }
301    }
302
303    /// The same set with the Nix opt-in answered.
304    pub(crate) fn set_nix_for_test(&mut self, nix: bool) {
305        self.nix = nix;
306    }
307}
308
309/// One destination a landing withholds, with why.
310#[derive(Debug, Clone, Serialize)]
311pub struct Withheld {
312    /// The destination that stays out.
313    pub path: String,
314    /// The reason, stated once per destination so a machine reader needs
315    /// no join.
316    pub reason: String,
317}
318
319/// The bytes a recorded destination currently holds, by the placement
320/// its name implies.
321///
322/// The marked block for `AGENTS.md` and `.pre-commit-config.yaml`, the
323/// whole file otherwise. `None` means the file — or the block — is
324/// absent.
325///
326/// # Errors
327///
328/// Any read failure other than the file being absent.
329pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
330    let path = target.join(destination);
331    let bytes = match std::fs::read(&path) {
332        Ok(bytes) => bytes,
333        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
334        Err(e) => return Err(e),
335    };
336    if let Some((begin, end)) = block_markers(destination) {
337        let text = String::from_utf8_lossy(&bytes);
338        Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
339    } else {
340        Ok(Some(bytes))
341    }
342}
343
344/// What one detection pass resolved for a target-side verb, with the
345/// override flags applied.
346#[derive(Debug)]
347pub struct Resolved {
348    /// The forge whose files apply.
349    pub forge: String,
350    /// The project path, where a flag or the remote names one.
351    pub repo: Option<String>,
352}
353
354/// Resolve forge and repository in one pass: the flags override, the
355/// `origin` remote answers otherwise.
356///
357/// An unrecognized host refuses rather than defaulting — landing one
358/// forge's files into the other forge's project is a half-configured
359/// repository that looks done.
360///
361/// # Errors
362///
363/// Returns [`RkError::Usage`] for an unknown `--forge` value, and a
364/// refusal naming the override when no forge resolves.
365pub fn resolve(
366    target: &Utf8Path,
367    forge_flag: Option<&str>,
368    repo_flag: Option<&str>,
369) -> Result<Resolved, RkError> {
370    let forge_flag = forge_flag
371        .map(|name| {
372            crate::detect::Forge::parse(name).ok_or_else(|| {
373                RkError::Usage(format!(
374                    "unknown forge '{name}'; the forges are: github, gitlab"
375                ))
376            })
377        })
378        .transpose()?;
379    let detected = crate::detect::detect(target.as_std_path());
380    let forge = forge_flag
381        .or(detected.forge)
382        .map(|forge| forge.as_str().to_owned())
383        .ok_or_else(|| {
384            let message = detected.host.map_or_else(
385                || "no forge detected: the target has no origin remote".to_owned(),
386                |host| format!("no forge detected: the host {host} is not recognized"),
387            );
388            RkError::refusal(
389                Diagnostic::new(Reason::ForgeUndetected, message)
390                    .expected("a github.com or gitlab remote, or --forge")
391                    .action("pass --forge <github|gitlab>"),
392            )
393        })?;
394    Ok(Resolved {
395        forge,
396        repo: repo_flag.map(str::to_owned).or(detected.repo),
397    })
398}
399
400/// The refusal a verb answers when it needs the `repo` parameter and
401/// neither a flag nor the remote supplies one.
402#[must_use]
403pub fn repo_unresolved() -> RkError {
404    RkError::missing(
405        Diagnostic::new(
406            Reason::ForgeUndetected,
407            "no repository detected: the target has no origin remote",
408        )
409        .expected("an origin remote naming the project")
410        .action("pass --repo <path>"),
411    )
412}
413
414#[cfg(test)]
415mod tests {
416    use super::{
417        AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
418        GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION, HOOKS_END, Kind,
419        SCOPE_SHAPE, Style, Workflow, extract_block, kind_of, render, splice_hooks_block,
420        splice_marked_block,
421    };
422    use crate::embedded;
423    use crate::projection::{self, Projection, ProjectionInput, TargetEvidence};
424
425    /// The candidate destinations for `params` over a target that holds
426    /// nothing, in destination order.
427    fn destinations(params: &super::Params) -> Vec<String> {
428        Projection::compute(&ProjectionInput {
429            params: params.clone(),
430            evidence: TargetEvidence {
431                crate_shape: projection::CrateShape {
432                    cargo_toml: Some(
433                        "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned(),
434                    ),
435                    cargo_lock: true,
436                    main_rs: true,
437                },
438                ..TargetEvidence::default()
439            },
440        })
441        .expect("the pair projects")
442        .candidates
443        .into_iter()
444        .map(|candidate| candidate.destination)
445        .collect()
446    }
447
448    fn routing_block(workflow: Workflow) -> String {
449        projection::routing_block(workflow).expect("the binary embeds the block")
450    }
451
452    fn hooks_block(workflow: Workflow) -> String {
453        projection::hooks_block(workflow).expect("the binary embeds the block")
454    }
455
456    fn glossary_block() -> String {
457        projection::glossary_block().expect("the binary embeds the block")
458    }
459
460    /// The splice returns the document's bytes; every assertion below
461    /// reads them back as text, which every fixture here is.
462    fn spliced(existing: Option<&str>, block: &str) -> String {
463        String::from_utf8(splice_marked_block(existing.map(str::as_bytes), block))
464            .expect("the fixtures are text")
465    }
466
467    #[test]
468    fn private_reporting_path_tokens_are_reproducible() {
469        for repo in [
470            "acme/widget",
471            "acme/group/widget",
472            "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
473        ] {
474            assert_eq!(
475                super::render(
476                    b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
477                    &super::Params::for_test(repo, Some(super::Style::Trunk))
478                ),
479                format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
480            );
481        }
482        assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
483    }
484
485    /// Both forge policies carry exactly one ordered pair of every
486    /// security marker. The span renderer treats anything else as a
487    /// source defect and leaves the bytes alone, so this test is what
488    /// keeps a defect out of a release rather than out of one landing.
489    #[test]
490    fn each_forge_policy_carries_one_ordered_pair_of_every_span() {
491        for forge in ["github", "gitlab"] {
492            let bytes = embedded::SNIPPETS
493                .get_file(format!("_shared/{forge}/SECURITY.md"))
494                .expect("the policy ships")
495                .contents();
496            let text = String::from_utf8_lossy(bytes);
497            for (begin, end) in super::SECURITY_SPANS {
498                let begin = String::from_utf8_lossy(begin);
499                let end = String::from_utf8_lossy(end);
500                assert_eq!(text.matches(begin.as_ref()).count(), 1, "{forge} {begin}");
501                assert_eq!(text.matches(end.as_ref()).count(), 1, "{forge} {end}");
502                assert!(
503                    text.find(begin.as_ref()) < text.find(end.as_ref()),
504                    "{forge}: {begin} must precede {end}"
505                );
506            }
507        }
508    }
509
510    /// The default answers reproduce each forge's authored policy exactly,
511    /// markers removed and each forge's own wording kept; an answered one
512    /// states it; and a contact spelling a token name lands literally,
513    /// because the spans resolve after every substitution.
514    #[test]
515    fn the_security_spans_render_per_answer() {
516        for forge in ["github", "gitlab"] {
517            let bytes = embedded::SNIPPETS
518                .get_file(format!("_shared/{forge}/SECURITY.md"))
519                .expect("the policy ships")
520                .contents();
521            let authored = String::from_utf8_lossy(bytes);
522            let stripped = {
523                let mut text = authored.clone().into_owned();
524                for (begin, end) in super::SECURITY_SPANS {
525                    text = text.replace(&String::from_utf8_lossy(begin).into_owned(), "");
526                    text = text.replace(&String::from_utf8_lossy(end).into_owned(), "");
527                }
528                text
529            };
530            let default = super::Params {
531                forge: forge.to_owned(),
532                ..super::Params::for_test_security("", crate::config::RESPONSE_DEFAULT)
533            };
534            let rendered = String::from_utf8(render(bytes, &default)).expect("text");
535            assert_eq!(
536                rendered,
537                stripped.replace("RK_REPO", "acme/widget"),
538                "{forge}: the default answers must reproduce the authored policy"
539            );
540            assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
541
542            let answered = super::Params {
543                forge: forge.to_owned(),
544                ..super::Params::for_test_security("OWNER RK_REPO <team@acme.example>", "14 days")
545            };
546            let rendered = String::from_utf8(render(bytes, &answered)).expect("text");
547            assert!(
548                rendered.contains("OWNER RK_REPO <team@acme.example>"),
549                "{forge}: a contact spelling a token name lands literally: {rendered}"
550            );
551            assert!(
552                rendered.contains("Maintainers acknowledge a report within 14 days."),
553                "{forge}: {rendered}"
554            );
555            assert!(
556                rendered.contains("This policy commits to no disclosure deadline."),
557                "{forge}: {rendered}"
558            );
559            assert!(
560                !rendered.contains("best-effort basis"),
561                "{forge}: a stated window replaces the best-effort sentence: {rendered}"
562            );
563            assert!(
564                !rendered.contains("no response or disclosure deadline"),
565                "{forge}: a stated window contradicts the response disclaimer: {rendered}"
566            );
567        }
568    }
569
570    /// A defective span leaves the bytes alone rather than producing a
571    /// half-written sentence: the source test above is what catches one.
572    #[test]
573    fn a_defective_span_renders_unchanged() {
574        let (begin, end) = super::SECURITY_SPANS[0];
575        let begin = String::from_utf8_lossy(begin).into_owned();
576        let end = String::from_utf8_lossy(end).into_owned();
577        let params = super::Params::for_test_security("team@acme.example", "1 day");
578        for baseline in [
579            format!("contact {begin}a maintainer\n"),
580            format!("contact a maintainer{end}\n"),
581            format!("contact {end}a maintainer{begin}\n"),
582            "contact a maintainer\n".to_owned(),
583        ] {
584            assert_eq!(
585                render(baseline.as_bytes(), &params),
586                baseline.as_bytes(),
587                "{baseline}"
588            );
589        }
590    }
591
592    /// Every snippet destination has a declared kind: a new landable file
593    /// without a classification fails here, not at a landing. The shared
594    /// zone's files are enumerated the same way.
595    #[test]
596    fn the_kind_table_closes_over_every_snippet() {
597        for tech_dir in embedded::SNIPPETS.dirs() {
598            for pair_dir in tech_dir.dirs() {
599                let prefix = format!("{}/", pair_dir.path().to_string_lossy());
600                for (path, _) in embedded::walk(pair_dir) {
601                    let destination = path.strip_prefix(&prefix).unwrap_or(&path);
602                    assert!(
603                        kind_of(destination).is_some(),
604                        "{destination}: no declared kind"
605                    );
606                }
607            }
608        }
609        for block in BLOCK_DESTINATIONS {
610            assert_eq!(kind_of(block), Some(Kind::Rendered), "{block}");
611        }
612        assert_eq!(kind_of("something-else.txt"), None);
613    }
614
615    /// Substitution is total and derives from the repo parameter's first
616    /// segment, so a nested GitLab project path still yields its root
617    /// namespace. The scope shape rests on no parameter, so it renders
618    /// under every landing.
619    #[test]
620    fn rendering_substitutes_every_owner_occurrence() {
621        let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
622        let rendered = render(baseline, &super::Params::for_test("acme/sub/widget", None));
623        let text = String::from_utf8(rendered).expect("rendered bytes stay text");
624        assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
625
626        let baseline = b"match (RK_SCOPE_SHAPE)\n";
627        let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
628        let text = String::from_utf8(rendered).expect("rendered bytes stay text");
629        assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
630    }
631
632    /// The one scope shape is a bracket expression an extended regular
633    /// expression takes verbatim: lowercase, and with the `-` last, where
634    /// it stands for itself rather than opening a range.
635    #[test]
636    fn the_scope_shape_drops_into_the_title_check() {
637        assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
638        assert!(
639            !SCOPE_SHAPE.contains('\''),
640            "the title checks single-quote it"
641        );
642    }
643
644    /// The predicate `rk message --check` calls and the pattern the title
645    /// checks render admit exactly the same characters. The pattern is
646    /// expanded here from its own text, so editing one owner without the
647    /// other fails: the desk and the forge judge one language.
648    #[test]
649    fn the_scope_predicate_and_the_rendered_pattern_agree() {
650        let body = SCOPE_SHAPE
651            .strip_prefix('[')
652            .and_then(|rest| rest.strip_suffix("]+"))
653            .expect("the shape is one bracket expression, repeated");
654        let chars: Vec<char> = body.chars().collect();
655        let mut admitted = std::collections::BTreeSet::new();
656        let mut at = 0;
657        while at < chars.len() {
658            // A `-` with a neighbour on each side opens a range; last, it
659            // stands for itself, which is why the shape ends with it.
660            if at + 2 < chars.len() && chars[at + 1] == '-' {
661                for c in chars[at]..=chars[at + 2] {
662                    admitted.insert(c);
663                }
664                at += 3;
665            } else {
666                admitted.insert(chars[at]);
667                at += 1;
668            }
669        }
670        for byte in 0..=127u8 {
671            let c = char::from(byte);
672            assert_eq!(
673                super::scope_is_shaped(&c.to_string()),
674                admitted.contains(&c),
675                "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
676            );
677        }
678        assert!(super::scope_is_shaped("guides/release"));
679        assert!(!super::scope_is_shaped(""), "a scope is never empty");
680        assert!(!super::scope_is_shaped("Specs Ugly"));
681    }
682
683    /// The shared zone composes into every pair, lands first, and is
684    /// absent from the technology listing an unknown tech names.
685    #[test]
686    fn the_shared_zone_composes_into_the_pair() {
687        let github = destinations(&super::Params {
688            forge: "github".to_owned(),
689            ..super::Params::for_test("acme/widget", Some(Style::Trunk))
690        });
691        assert!(
692            github.contains(&".github/workflows/pr-title.yml".to_owned()),
693            "the shared title check lands with the pair"
694        );
695        let gitlab = destinations(&super::Params {
696            forge: "gitlab".to_owned(),
697            ..super::Params::for_test("acme/widget", Some(Style::Trunk))
698        });
699        assert!(
700            gitlab.contains(&".gitlab/ci/mr-title.yml".to_owned()),
701            "the shared title job lands with the pair"
702        );
703        let err =
704            projection::check_pair("_shared", "github").expect_err("the shared zone is no tech");
705        let listing = err.to_string();
706        let bindings = listing
707            .split("the bindings are:")
708            .nth(1)
709            .expect("the refusal lists the bindings");
710        assert!(!bindings.contains("_shared"), "{listing}");
711    }
712
713    /// A loaded record reaches the projection unchanged, including old
714    /// records' absent style and the two workflow modes.
715    #[test]
716    fn params_from_a_record_round_trips() {
717        use super::{Params, manifest};
718        let dir = tempfile::tempdir().expect("a scratch target exists");
719        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
720        for tech in ["rust", "bash"] {
721            for forge in ["github", "gitlab"] {
722                for workflow in [Workflow::Branches, Workflow::Worktree] {
723                    for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
724                        for nix in [false, true] {
725                            let record = manifest::Manifest {
726                                schema_version: manifest::SCHEMA_VERSION,
727                                rk_version: "0.1.0".to_owned(),
728                                origin: "init".to_owned(),
729                                tech: tech.to_owned(),
730                                forge: forge.to_owned(),
731                                landed_at: "2026-08-29T00:00:00Z".to_owned(),
732                                parameters: manifest::Parameters {
733                                    repo: "acme/team/widget".to_owned(),
734                                    workflow,
735                                    style,
736                                    nix,
737                                    trunk: crate::config::TRUNK_DEFAULT.to_owned(),
738                                    line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
739                                    security_contact: String::new(),
740                                    security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
741                                },
742                                files: Vec::new(),
743                                pins: std::collections::BTreeMap::new(),
744                            };
745                            manifest::write(target, &record).expect("the record writes");
746                            let loaded = manifest::load(target)
747                                .expect("the record loads")
748                                .expect("the record exists");
749                            let params = Params::from_record(&loaded);
750                            assert_eq!(params.tech, tech);
751                            assert_eq!(params.forge, forge);
752                            assert_eq!(params.repo(), "acme/team/widget");
753                            assert_eq!(params.workflow(), workflow);
754                            assert_eq!(params.style(), style);
755                            assert_eq!(params.nix, nix);
756                            // The loaded record and the same answers given
757                            // directly project the same candidate tree.
758                            let mut direct = super::Params::for_test("acme/team/widget", style);
759                            direct.tech = tech.to_owned();
760                            direct.forge = forge.to_owned();
761                            direct.workflow = workflow;
762                            direct.nix = nix;
763                            assert_eq!(params, direct);
764                            let projected = destinations(&params);
765                            for block in
766                                [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION]
767                            {
768                                assert!(projected.contains(&block.to_owned()), "{block}");
769                            }
770                            for destination in super::NIX_DESTINATIONS {
771                                assert_eq!(
772                                    projected.contains(&destination.to_owned()),
773                                    nix && tech == "rust",
774                                    "{tech} {forge} nix={nix}: {destination}"
775                                );
776                            }
777                        }
778                    }
779                }
780            }
781        }
782    }
783
784    fn resolved_test_params(
785        tech: &str,
786        resolved: &super::Resolved,
787        workflow: Workflow,
788        style: Option<Style>,
789        nix: bool,
790    ) -> Result<super::Params, crate::error::RkError> {
791        super::Params::resolve(
792            camino::Utf8Path::new("."),
793            &super::Inputs {
794                tech: Some(tech),
795                forge: Some(&resolved.forge),
796                repo: resolved.repo.as_deref(),
797                workflow: Some(workflow),
798                style,
799                nix: Some(nix),
800            },
801            None,
802            None,
803            super::Purpose::Init,
804        )
805    }
806
807    /// A rendered projection carries no unsubstituted token and no
808    /// mechanical sentinel; the one judgment sentinel stays in its seeded
809    /// file.
810    #[test]
811    fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
812        let params = resolved_test_params(
813            "rust",
814            &super::Resolved {
815                forge: "github".to_owned(),
816                repo: Some("acme/widget".to_owned()),
817            },
818            Workflow::Branches,
819            Some(Style::Trunk),
820            false,
821        )
822        .expect("the parameters resolve");
823        let entries = Projection::compute(&ProjectionInput {
824            params,
825            evidence: TargetEvidence::default(),
826        })
827        .expect("the pair projects")
828        .candidates;
829        let workflow = entries
830            .iter()
831            .find(|entry| entry.destination.ends_with("release-plz.yml"))
832            .expect("the workflow projects");
833        assert_eq!(workflow.kind, Kind::Rendered);
834        let text = String::from_utf8_lossy(&workflow.bytes);
835        assert!(!text.contains("OWNER"), "an owner token survived rendering");
836        assert!(text.contains("'acme'"));
837        assert!(!text.contains("TODO(release-kit)"));
838        let title = entries
839            .iter()
840            .find(|entry| entry.destination.ends_with("pr-title.yml"))
841            .expect("the title check projects");
842        let text = String::from_utf8_lossy(&title.bytes);
843        assert!(text.contains(SCOPE_SHAPE), "{text}");
844        assert!(
845            !text.contains("RK_SCOPE_SHAPE"),
846            "a scope token survived: {text}"
847        );
848        let seeded = entries
849            .iter()
850            .find(|entry| entry.destination == "release-plz.toml")
851            .expect("the seeded file projects");
852        assert_eq!(seeded.kind, Kind::Seeded);
853        let authored = embedded::SNIPPETS
854            .get_file("rust/github/release-plz.toml")
855            .expect("the seed ships")
856            .contents();
857        assert_eq!(seeded.bytes, authored, "a seeded file lands as authored");
858        assert!(String::from_utf8_lossy(&seeded.bytes).contains("TODO(release-kit)"));
859        for block in BLOCK_DESTINATIONS {
860            let entry = entries
861                .iter()
862                .find(|entry| entry.destination == block)
863                .expect("every block is part of the projection");
864            let text = String::from_utf8_lossy(&entry.bytes);
865            assert!(
866                !text.contains("RK_SCOPE_SHAPE"),
867                "{block} kept a token: {text}"
868            );
869        }
870    }
871
872    /// The Nix destinations project only under the opt-in: off, none of
873    /// them appears; on, the rust pairs carry them — the gitlab pair too,
874    /// minus the workflow, which is a forge file the gitlab pair does
875    /// not ship — and a pair without them projects the smaller product.
876    #[test]
877    fn the_nix_destinations_project_only_under_the_opt_in() {
878        use super::NIX_DESTINATIONS;
879        let paths = |nix: bool, forge: &str| -> Vec<String> {
880            destinations(
881                &resolved_test_params(
882                    "rust",
883                    &super::Resolved {
884                        forge: forge.to_owned(),
885                        repo: Some("acme/widget".to_owned()),
886                    },
887                    Workflow::Worktree,
888                    Some(Style::Trunk),
889                    nix,
890                )
891                .expect("the parameters resolve"),
892            )
893        };
894        let off = paths(false, "github");
895        for destination in NIX_DESTINATIONS {
896            assert!(!off.contains(&destination.to_owned()), "{destination}");
897        }
898        let on = paths(true, "github");
899        for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
900            assert!(on.contains(&destination.to_owned()), "{destination}");
901        }
902        // The capability lands no workflow, so both forges land the same
903        // set: a job proving the build holds a merge only inside the
904        // workflow the required check needs, and that one is the
905        // target's own.
906        let gitlab = paths(true, "gitlab");
907        assert!(gitlab.contains(&"nix/package.nix".to_owned()));
908        assert!(
909            !on.iter()
910                .chain(gitlab.iter())
911                .any(|destination| destination.contains("nix.yml"))
912        );
913        let bash = destinations(
914            &resolved_test_params(
915                "bash",
916                &super::Resolved {
917                    forge: "github".to_owned(),
918                    repo: Some("acme/widget".to_owned()),
919                },
920                Workflow::Worktree,
921                Some(Style::Trunk),
922                true,
923            )
924            .expect("the parameters resolve"),
925        );
926        assert!(
927            bash.iter()
928                .all(|destination| !NIX_DESTINATIONS.contains(&destination.as_str()))
929        );
930    }
931
932    /// The github and gitlab copies of the forge-independent Nix seeds
933    /// stay byte-identical: the loader composes exactly two layers and has
934    /// no technology-wide zone, so the duplication is deliberate and this
935    /// parity test is what keeps it honest.
936    #[test]
937    fn the_nix_seeds_are_identical_across_forge_pairs() {
938        for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
939            let github = embedded::SNIPPETS
940                .get_file(format!("rust/github/{name}"))
941                .expect("the github copy ships")
942                .contents();
943            let gitlab = embedded::SNIPPETS
944                .get_file(format!("rust/gitlab/{name}"))
945                .expect("the gitlab copy ships")
946                .contents();
947            assert_eq!(github, gitlab, "{name} diverged between the pairs");
948        }
949    }
950
951    /// The withhold judgment: a flake pair of the target's own withholds
952    /// the pair and the workflow while the package expression lands, a
953    /// crate shape the seed does not support withholds everything, and a
954    /// clean single-crate target withholds nothing.
955    #[test]
956    fn the_nix_withhold_judgment_covers_the_three_shapes() {
957        use super::NIX_DESTINATIONS;
958        let dir = tempfile::tempdir().expect("a scratch target exists");
959        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
960        let project = |nix: bool| {
961            let params = resolved_test_params(
962                "rust",
963                &super::Resolved {
964                    forge: "github".to_owned(),
965                    repo: Some("acme/widget".to_owned()),
966                },
967                Workflow::Worktree,
968                Some(Style::Trunk),
969                nix,
970            )
971            .expect("the parameters resolve");
972            let evidence = TargetEvidence::gather(target, None).expect("the evidence reads");
973            Projection::compute(&ProjectionInput { params, evidence }).expect("the pair projects")
974        };
975        let withheld = |projection: &Projection| -> Vec<String> {
976            projection
977                .omissions
978                .iter()
979                .map(|omission| omission.destination.clone())
980                .collect()
981        };
982        let landed = |projection: &Projection, destination: &str| {
983            projection
984                .candidates
985                .iter()
986                .any(|candidate| candidate.destination == destination)
987        };
988
989        // No Cargo.toml: the whole capability is withheld by name.
990        let all = project(true);
991        assert_eq!(
992            withheld(&all),
993            ["flake.lock", "flake.nix", "nix/package.nix"]
994        );
995        assert!(
996            all.candidates
997                .iter()
998                .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
999        );
1000
1001        // A single crate with its own flake: the seed pair is withheld,
1002        // and the package expression still lands.
1003        std::fs::write(
1004            target.join("Cargo.toml"),
1005            "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
1006        )
1007        .expect("the crate manifest writes");
1008        std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
1009        std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
1010        std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
1011        std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
1012        let all = project(true);
1013        assert_eq!(withheld(&all), ["flake.lock", "flake.nix"]);
1014        assert!(landed(&all, "nix/package.nix"));
1015
1016        // A clean single crate: nothing is withheld.
1017        std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
1018        let all = project(true);
1019        assert!(all.omissions.is_empty());
1020        assert!(landed(&all, "flake.nix"));
1021
1022        // Off, the judgment does not even look.
1023        let all = project(false);
1024        assert!(all.omissions.is_empty());
1025        assert!(!landed(&all, "flake.nix"));
1026    }
1027
1028    /// The glossary takes the same three shapes the routing block does,
1029    /// and the marker pair it shares with `AGENTS.md` is what makes one
1030    /// splice serve both.
1031    #[test]
1032    fn the_glossary_splices_into_every_shape() {
1033        let owned = glossary_block();
1034        let block = owned.as_str();
1035
1036        let fresh = spliced(None, block);
1037        assert_eq!(fresh, format!("{block}\n"));
1038        assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1039
1040        let own = "# Glossary\n\n- `spike` — a throwaway branch.\n";
1041        let appended = spliced(Some(own), block);
1042        assert!(appended.starts_with(own));
1043        assert_eq!(
1044            extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1045            Some(block)
1046        );
1047
1048        let stale = appended.replace("full-implement", "do-everything");
1049        let refreshed = spliced(Some(&stale), block);
1050        assert_eq!(
1051            extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1052            Some(block)
1053        );
1054        assert_eq!(
1055            refreshed.matches("BEGIN release-kit").count(),
1056            1,
1057            "a re-splice must replace, not accumulate"
1058        );
1059    }
1060
1061    /// Every line the target wrote below the end marker survives a
1062    /// re-splice byte for byte: the block owns its marked lines and the
1063    /// document belongs to the target.
1064    #[test]
1065    fn the_glossary_leaves_the_targets_region_alone() {
1066        let owned = glossary_block();
1067        let block = owned.as_str();
1068        let below = "\n## Our own terms\n\n- `spike` — a throwaway branch, never merged.\n";
1069        let landed = format!("{block}\n{below}");
1070
1071        let refreshed = spliced(Some(&landed), block);
1072        assert!(
1073            refreshed.ends_with(below),
1074            "the target's own region changed: {refreshed}"
1075        );
1076        assert_eq!(
1077            extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1078            Some(block)
1079        );
1080    }
1081
1082    /// Appending keeps the document whole: trailing spaces, blank lines,
1083    /// and a missing final newline are the target's bytes, and a block
1084    /// that owns its marked lines alone rewrites none of them.
1085    #[test]
1086    fn an_append_rewrites_no_byte_the_target_wrote() {
1087        let owned = glossary_block();
1088        let block = owned.as_str();
1089        for own in [
1090            "# Glossary\n\n- `spike` — throwaway.   \n\n\n",
1091            "# Glossary\n\n- `spike` — throwaway.",
1092            "# Glossary\r\n\r\n- `spike` — throwaway.\r\n",
1093        ] {
1094            let appended = spliced(Some(own), block);
1095            assert!(
1096                appended.starts_with(own),
1097                "the target's bytes changed: {appended:?}"
1098            );
1099            assert_eq!(
1100                extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1101                Some(block),
1102                "{appended:?}"
1103            );
1104            let marker = appended.find(BLOCK_BEGIN).expect("the block landed");
1105            assert!(
1106                appended[..marker].ends_with('\n'),
1107                "the block must open its own line: {appended:?}"
1108            );
1109        }
1110    }
1111
1112    /// A document the target wrote is bytes, not text. A splice that
1113    /// decoded it would replace an invalid sequence with U+FFFD and
1114    /// rewrite a byte outside the markers, which the rule forbids.
1115    #[test]
1116    fn a_splice_decodes_no_byte_the_target_wrote() {
1117        let owned = glossary_block();
1118        let block = owned.as_str();
1119
1120        // Appending: the invalid byte sits in the target's own document.
1121        let own = b"# Glossary\n\ncaf\xe9\n";
1122        let appended = splice_marked_block(Some(own), block);
1123        assert!(
1124            appended.starts_with(own),
1125            "the target's bytes changed: {appended:?}"
1126        );
1127        assert!(!appended.contains(&0xEF), "a replacement character landed");
1128
1129        // Replacing: the invalid byte sits below the end marker.
1130        let mut landed = Vec::new();
1131        landed.extend_from_slice(block.replace("full-implement", "do-everything").as_bytes());
1132        landed.extend_from_slice(b"\n\ncaf\xe9\n");
1133        let refreshed = splice_marked_block(Some(&landed), block);
1134        assert!(
1135            refreshed.ends_with(b"\n\ncaf\xe9\n"),
1136            "the target's region below the markers changed: {refreshed:?}"
1137        );
1138        assert!(refreshed.starts_with(block.as_bytes()), "{refreshed:?}");
1139    }
1140
1141    /// The glossary carries no parameter, so the same bytes land in
1142    /// every target: no token survives it and no mode changes it.
1143    #[test]
1144    fn the_glossary_block_carries_no_parameter() {
1145        let block = glossary_block();
1146        assert!(block.starts_with(BLOCK_BEGIN), "{block}");
1147        assert!(block.ends_with(BLOCK_END), "{block}");
1148        assert!(!block.contains("RK_"), "a token survived: {block}");
1149        assert!(!block.contains("OWNER"), "an owner token survived: {block}");
1150        for term in [
1151            "implement-and-request",
1152            "implement-and-merge",
1153            "full-implement",
1154        ] {
1155            assert!(block.contains(term), "{term} is missing from {block}");
1156        }
1157        assert!(
1158            routing_block(Workflow::Worktree).contains(GLOSSARY_DESTINATION),
1159            "the routing block must name the destination it indexes"
1160        );
1161    }
1162
1163    #[test]
1164    fn the_block_splices_into_every_agents_shape() {
1165        let owned = routing_block(Workflow::Branches);
1166        let block = owned.as_str();
1167        let fresh = spliced(None, block);
1168        assert_eq!(fresh, format!("{block}\n"));
1169        assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1170
1171        let appended = spliced(Some("# My project\n\nOwn rules.\n"), block);
1172        assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1173        assert_eq!(
1174            extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1175            Some(block)
1176        );
1177
1178        let stale = appended.replace("Never author a tag", "Do author a tag");
1179        let refreshed = spliced(Some(&stale), block);
1180        assert_eq!(
1181            extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1182            Some(block)
1183        );
1184        assert!(refreshed.starts_with("# My project"));
1185        assert_eq!(
1186            refreshed.matches("BEGIN release-kit").count(),
1187            1,
1188            "a re-splice must replace, not accumulate"
1189        );
1190    }
1191
1192    /// The hook block lands under `repos:` in every honest shape and
1193    /// refuses the one dishonest shape by name.
1194    #[test]
1195    fn the_hook_block_splices_under_repos() {
1196        let owned = hooks_block(Workflow::Branches);
1197        let block = owned.as_str();
1198        let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
1199        assert!(fresh.starts_with(HOOK_TYPES_LINE));
1200        assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1201        assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1202
1203        let own =
1204            "repos:\n  - repo: https://example.com/own\n    rev: v1\n    hooks:\n      - id: own\n";
1205        let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1206        assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1207        assert!(spliced.contains("- id: own"), "the target's hooks survive");
1208        assert!(
1209            !spliced.contains(HOOK_TYPES_LINE),
1210            "an existing file's top level is the skills' duty, not the splice's"
1211        );
1212
1213        let stale = spliced.replace("--force-scope", "--no-scope");
1214        let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1215        assert_eq!(
1216            extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1217            Some(block)
1218        );
1219        assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1220
1221        let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1222            .expect_err("no repos: line refuses");
1223        assert!(err.contains("repos:"), "{err}");
1224
1225        // The hooks between the markers execute, so ownership is exactly
1226        // one well-formed block: a duplicate or an unmatched marker
1227        // refuses rather than leaving a stale block active.
1228        let doubled = format!("repos:\n{block}\n{block}\n");
1229        let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1230        assert!(err.contains("one block"), "{err}");
1231        let unmatched = "repos:\n# BEGIN release-kit\n  - repo: local\n";
1232        let err =
1233            splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1234        assert!(err.contains("unmatched"), "{err}");
1235    }
1236
1237    /// Both modes of both blocks: the guard entry and the skip pair exist
1238    /// exactly in the worktree mode, one orientation line differs in the
1239    /// routing block, the rest is byte-identical, no mode token survives
1240    /// substitution, and the rendered grammar is [`BRANCH_GRAMMAR`], the
1241    /// one owner.
1242    #[test]
1243    fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1244        let worktree_hooks = hooks_block(Workflow::Worktree);
1245        let branches_hooks = hooks_block(Workflow::Branches);
1246        assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1247        assert!(
1248            worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1249            "{worktree_hooks}"
1250        );
1251        assert!(!branches_hooks.contains("rk-worktree-location"));
1252        assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1253        for block in [&worktree_hooks, &branches_hooks] {
1254            assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1255            for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
1256                assert!(!block.contains(token), "{token} survived: {block}");
1257            }
1258        }
1259        // A hook entry renders as a YAML plain scalar, where a colon
1260        // followed by a space ends the scalar and breaks the whole file
1261        // — the defect dogfood caught in the guard's refusal messages —
1262        // so no entry value may carry one.
1263        for block in [&worktree_hooks, &branches_hooks] {
1264            for line in block.lines() {
1265                if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1266                    assert!(
1267                        !value.contains(": "),
1268                        "an entry value breaks the YAML plain scalar: {line}"
1269                    );
1270                }
1271            }
1272        }
1273        let guard_line = worktree_hooks
1274            .lines()
1275            .position(|line| line.contains("id: rk-worktree-location"))
1276            .expect("the guard entry exists");
1277        let name_line = worktree_hooks
1278            .lines()
1279            .position(|line| line.contains("id: rk-branch-name"))
1280            .expect("the name hook exists");
1281        assert!(
1282            guard_line > name_line,
1283            "the guard lands directly after rk-branch-name"
1284        );
1285
1286        let worktree_routing = routing_block(Workflow::Worktree);
1287        let branches_routing = routing_block(Workflow::Branches);
1288        assert!(worktree_routing.contains("This project works in worktrees"));
1289        assert!(branches_routing.contains("Branches are worked in the main checkout"));
1290        for block in [&worktree_routing, &branches_routing] {
1291            assert!(block.contains("Create or remove a worktree"));
1292            assert!(block.contains("`rk worktree add <branch>`"));
1293            assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1294        }
1295        let differing: Vec<(&str, &str)> = worktree_routing
1296            .lines()
1297            .zip(branches_routing.lines())
1298            .filter(|(a, b)| a != b)
1299            .collect();
1300        assert_eq!(
1301            differing.len(),
1302            1,
1303            "exactly one routing line differs per mode: {differing:?}"
1304        );
1305    }
1306
1307    /// One definition of an ill-formed hook file, for every reader: the
1308    /// well-formed shapes pass and each ambiguous shape names a defect.
1309    #[test]
1310    fn the_hook_marker_defects_are_named() {
1311        use super::hooks_marker_defect;
1312        let owned = hooks_block(Workflow::Branches);
1313        let block = owned.as_str();
1314        assert_eq!(hooks_marker_defect(""), None);
1315        assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1316        for (case, text) in [
1317            (
1318                "a second begin",
1319                format!("repos:\n{block}\n# BEGIN release-kit\n"),
1320            ),
1321            (
1322                "a second end",
1323                format!("repos:\n{block}\n# END release-kit\n"),
1324            ),
1325            (
1326                "an unpaired begin",
1327                "repos:\n# BEGIN release-kit\n".to_owned(),
1328            ),
1329            ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1330            (
1331                "an end before its begin",
1332                "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1333            ),
1334        ] {
1335            assert!(
1336                hooks_marker_defect(&text).is_some(),
1337                "{case} must be a defect"
1338            );
1339        }
1340    }
1341}