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