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, 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, 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    /// The candidate destinations for `params` over a target that holds
162    /// nothing, in destination order.
163    fn destinations(params: &super::Params) -> Vec<String> {
164        Projection::compute(&ProjectionInput {
165            params: params.clone(),
166            evidence: TargetEvidence {
167                crate_shape: projection::CrateShape {
168                    cargo_toml: Some(
169                        "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned(),
170                    ),
171                    cargo_lock: true,
172                    main_rs: true,
173                },
174                ..TargetEvidence::default()
175            },
176        })
177        .expect("the pair projects")
178        .candidates
179        .into_iter()
180        .map(|candidate| candidate.destination)
181        .collect()
182    }
183
184    fn routing_block(mode: CheckoutMode) -> String {
185        projection::routing_block(mode).expect("the binary embeds the block")
186    }
187
188    fn hooks_block(mode: CheckoutMode) -> String {
189        projection::hooks_block(mode).expect("the binary embeds the block")
190    }
191
192    fn glossary_block() -> String {
193        projection::glossary_block().expect("the binary embeds the block")
194    }
195
196    /// The splice returns the document's bytes; every assertion below
197    /// reads them back as text, which every fixture here is.
198    fn spliced(existing: Option<&str>, block: &str) -> String {
199        String::from_utf8(splice_marked_block(existing.map(str::as_bytes), block))
200            .expect("the fixtures are text")
201    }
202
203    #[test]
204    fn private_reporting_path_tokens_are_reproducible() {
205        for repo in [
206            "acme/widget",
207            "acme/group/widget",
208            "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
209        ] {
210            assert_eq!(
211                super::render(
212                    b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
213                    &super::Params::for_test(repo, Some(super::Style::Trunk))
214                ),
215                format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
216            );
217        }
218        assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
219    }
220
221    /// Both forge policies carry exactly one ordered pair of every
222    /// security marker. The span renderer treats anything else as a
223    /// source defect and leaves the bytes alone, so this test is what
224    /// keeps a defect out of a release rather than out of one landing.
225    #[test]
226    fn each_forge_policy_carries_one_ordered_pair_of_every_span() {
227        for forge in ["github", "gitlab"] {
228            let bytes = embedded::SNIPPETS
229                .get_file(format!("_shared/{forge}/SECURITY.md"))
230                .expect("the policy ships")
231                .contents();
232            let text = String::from_utf8_lossy(bytes);
233            for (begin, end) in super::SECURITY_SPANS {
234                let begin = String::from_utf8_lossy(begin);
235                let end = String::from_utf8_lossy(end);
236                assert_eq!(text.matches(begin.as_ref()).count(), 1, "{forge} {begin}");
237                assert_eq!(text.matches(end.as_ref()).count(), 1, "{forge} {end}");
238                assert!(
239                    text.find(begin.as_ref()) < text.find(end.as_ref()),
240                    "{forge}: {begin} must precede {end}"
241                );
242            }
243        }
244    }
245
246    /// The default answers reproduce each forge's authored policy exactly,
247    /// markers removed and each forge's own wording kept; an answered one
248    /// states it; and a contact spelling a token name lands literally,
249    /// because the spans resolve after every substitution.
250    #[test]
251    fn the_security_spans_render_per_answer() {
252        for forge in ["github", "gitlab"] {
253            let bytes = embedded::SNIPPETS
254                .get_file(format!("_shared/{forge}/SECURITY.md"))
255                .expect("the policy ships")
256                .contents();
257            let authored = String::from_utf8_lossy(bytes);
258            let stripped = {
259                let mut text = authored.clone().into_owned();
260                for (begin, end) in super::SECURITY_SPANS {
261                    text = text.replace(&String::from_utf8_lossy(begin).into_owned(), "");
262                    text = text.replace(&String::from_utf8_lossy(end).into_owned(), "");
263                }
264                text
265            };
266            let mut default = super::Params::for_test_security("", crate::config::RESPONSE_DEFAULT);
267            default.set_pair_for_test("rust", forge);
268            let rendered = String::from_utf8(render(bytes, &default)).expect("text");
269            assert_eq!(
270                rendered,
271                stripped.replace("RK_REPO", "acme/widget"),
272                "{forge}: the default answers must reproduce the authored policy"
273            );
274            assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
275
276            let mut answered =
277                super::Params::for_test_security("OWNER RK_REPO <team@acme.example>", "14 days");
278            answered.set_pair_for_test("rust", forge);
279            let rendered = String::from_utf8(render(bytes, &answered)).expect("text");
280            assert!(
281                rendered.contains("OWNER RK_REPO <team@acme.example>"),
282                "{forge}: a contact spelling a token name lands literally: {rendered}"
283            );
284            assert!(
285                rendered.contains("Maintainers acknowledge a report within 14 days."),
286                "{forge}: {rendered}"
287            );
288            assert!(
289                rendered.contains("This policy commits to no disclosure deadline."),
290                "{forge}: {rendered}"
291            );
292            assert!(
293                !rendered.contains("best-effort basis"),
294                "{forge}: a stated window replaces the best-effort sentence: {rendered}"
295            );
296            assert!(
297                !rendered.contains("no response or disclosure deadline"),
298                "{forge}: a stated window contradicts the response disclaimer: {rendered}"
299            );
300        }
301    }
302
303    /// A defective span leaves the bytes alone rather than producing a
304    /// half-written sentence: the source test above is what catches one.
305    #[test]
306    fn a_defective_span_renders_unchanged() {
307        let (begin, end) = super::SECURITY_SPANS[0];
308        let begin = String::from_utf8_lossy(begin).into_owned();
309        let end = String::from_utf8_lossy(end).into_owned();
310        let params = super::Params::for_test_security("team@acme.example", "1 day");
311        for baseline in [
312            format!("contact {begin}a maintainer\n"),
313            format!("contact a maintainer{end}\n"),
314            format!("contact {end}a maintainer{begin}\n"),
315            "contact a maintainer\n".to_owned(),
316        ] {
317            assert_eq!(
318                render(baseline.as_bytes(), &params),
319                baseline.as_bytes(),
320                "{baseline}"
321            );
322        }
323    }
324
325    /// Every snippet destination has a declared kind: a new landable file
326    /// without a classification fails here, not at a landing. The shared
327    /// zone's files are enumerated the same way.
328    #[test]
329    fn the_kind_table_closes_over_every_snippet() {
330        for tech_dir in embedded::SNIPPETS.dirs() {
331            for pair_dir in tech_dir.dirs() {
332                let prefix = format!("{}/", pair_dir.path().to_string_lossy());
333                for (path, _) in embedded::walk(pair_dir) {
334                    let destination = path.strip_prefix(&prefix).unwrap_or(&path);
335                    assert!(
336                        kind_of(destination).is_some(),
337                        "{destination}: no declared kind"
338                    );
339                }
340            }
341        }
342        for block in BLOCK_DESTINATIONS {
343            assert_eq!(kind_of(block), Some(Kind::Rendered), "{block}");
344        }
345        assert_eq!(kind_of("something-else.txt"), None);
346    }
347
348    /// Substitution is total and derives from the repo parameter's first
349    /// segment, so a nested GitLab project path still yields its root
350    /// namespace. The scope shape rests on no parameter, so it renders
351    /// under every landing.
352    #[test]
353    fn rendering_substitutes_every_owner_occurrence() {
354        let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
355        let rendered = render(baseline, &super::Params::for_test("acme/sub/widget", None));
356        let text = String::from_utf8(rendered).expect("rendered bytes stay text");
357        assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
358
359        let baseline = b"match (RK_SCOPE_SHAPE)\n";
360        let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
361        let text = String::from_utf8(rendered).expect("rendered bytes stay text");
362        assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
363    }
364
365    /// The one scope shape is a bracket expression an extended regular
366    /// expression takes verbatim: lowercase, and with the `-` last, where
367    /// it stands for itself rather than opening a range.
368    #[test]
369    fn the_scope_shape_drops_into_the_title_check() {
370        assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
371        assert!(
372            !SCOPE_SHAPE.contains('\''),
373            "the title checks single-quote it"
374        );
375    }
376
377    /// The predicate `rk message --check` calls and the pattern the title
378    /// checks render admit exactly the same characters. The pattern is
379    /// expanded here from its own text, so editing one owner without the
380    /// other fails: the desk and the forge judge one language.
381    #[test]
382    fn the_scope_predicate_and_the_rendered_pattern_agree() {
383        let body = SCOPE_SHAPE
384            .strip_prefix('[')
385            .and_then(|rest| rest.strip_suffix("]+"))
386            .expect("the shape is one bracket expression, repeated");
387        let chars: Vec<char> = body.chars().collect();
388        let mut admitted = std::collections::BTreeSet::new();
389        let mut at = 0;
390        while at < chars.len() {
391            // A `-` with a neighbour on each side opens a range; last, it
392            // stands for itself, which is why the shape ends with it.
393            if at + 2 < chars.len() && chars[at + 1] == '-' {
394                for c in chars[at]..=chars[at + 2] {
395                    admitted.insert(c);
396                }
397                at += 3;
398            } else {
399                admitted.insert(chars[at]);
400                at += 1;
401            }
402        }
403        for byte in 0..=127u8 {
404            let c = char::from(byte);
405            assert_eq!(
406                super::scope_is_shaped(&c.to_string()),
407                admitted.contains(&c),
408                "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
409            );
410        }
411        assert!(super::scope_is_shaped("guides/release"));
412        assert!(!super::scope_is_shaped(""), "a scope is never empty");
413        assert!(!super::scope_is_shaped("Specs Ugly"));
414    }
415
416    /// The forge's own capabilities land with every automatic pair on that
417    /// forge: the title gate and the reporting policy, and the shared zone
418    /// is never a technology.
419    #[test]
420    fn the_shared_zone_composes_into_the_pair() {
421        let mut github = super::Params::for_test("acme/widget", Some(Style::Trunk));
422        github.set_pair_for_test("rust", "github");
423        let github = destinations(&github);
424        assert!(
425            github.contains(&".github/workflows/pr-title.yml".to_owned()),
426            "the shared title check lands with the pair"
427        );
428        assert!(github.contains(&"SECURITY.md".to_owned()));
429        let mut gitlab = super::Params::for_test("acme/widget", Some(Style::Trunk));
430        gitlab.set_pair_for_test("rust", "gitlab");
431        let gitlab = destinations(&gitlab);
432        assert!(
433            gitlab.contains(&".gitlab/ci/mr-title.yml".to_owned()),
434            "the shared title job lands with the pair"
435        );
436        assert!(
437            !crate::profile::catalog::known_drivers()
438                .iter()
439                .any(|driver| driver.starts_with('_')),
440            "the shared zone is no driver"
441        );
442    }
443
444    /// A loaded record reaches the projection unchanged, including old
445    /// records' absent style and the two checkout modes.
446    #[test]
447    fn params_from_a_record_round_trips() {
448        use super::{Params, manifest};
449        let dir = tempfile::tempdir().expect("a scratch target exists");
450        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
451        for tech in ["rust", "bash"] {
452            for forge in ["github", "gitlab"] {
453                for checkout_mode in [CheckoutMode::MainWorktree, CheckoutMode::LinkedWorktree] {
454                    for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
455                        for ((nix, scorecard), code_scanning) in [
456                            ((false, false), None),
457                            ((false, true), Some(Provider::Semgrep)),
458                            ((true, false), Some(Provider::CodeQl)),
459                            ((true, true), None),
460                        ] {
461                            let record = manifest::Manifest {
462                                schema_version: manifest::SCHEMA_VERSION,
463                                rk_version: "0.1.0".to_owned(),
464                                origin: "init".to_owned(),
465                                landed_at: "2026-08-29T00:00:00Z".to_owned(),
466                                profile: ProfileSnapshot {
467                                    technologies: vec![tech.to_owned()],
468                                    forge: Some(forge.to_owned()),
469                                    release: ReleaseIntent {
470                                        mode: ReleaseMode::Automatic,
471                                        driver: Some(tech.to_owned()),
472                                        style,
473                                        line_prefix: Some(
474                                            crate::config::LINE_PREFIX_DEFAULT.to_owned(),
475                                        ),
476                                    },
477                                },
478                                git: GitWorkflow {
479                                    trunk: crate::config::TRUNK_DEFAULT.to_owned(),
480                                    checkout_mode,
481                                },
482                                capabilities: CapabilityRequests {
483                                    nix_packaging: nix,
484                                    reporting_policy: true,
485                                    scorecard,
486                                    code_scanning,
487                                },
488                                parameters: manifest::Parameters {
489                                    repo: "acme/team/widget".to_owned(),
490                                    security_contact: String::new(),
491                                    security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
492                                },
493                                files: Vec::new(),
494                                pins: std::collections::BTreeMap::new(),
495                            };
496                            manifest::write(target, &record).expect("the record writes");
497                            let loaded = manifest::load(target)
498                                .expect("the record loads")
499                                .expect("the record exists");
500                            let params = Params::from_record(&loaded);
501                            assert_eq!(params.driver(), Some(tech));
502                            assert_eq!(params.forge(), Some(forge));
503                            assert_eq!(params.repo(), "acme/team/widget");
504                            assert_eq!(params.checkout_mode(), checkout_mode);
505                            assert_eq!(params.style(), style);
506                            assert_eq!(params.nix_packaging(), nix);
507                            assert_eq!(params.scorecard(), scorecard);
508                            assert_eq!(params.code_scanning(), code_scanning);
509                            // The loaded record and the same answers given
510                            // directly project the same candidate tree.
511                            let mut direct = super::Params::for_test("acme/team/widget", style);
512                            direct.set_pair_for_test(tech, forge);
513                            direct.set_checkout_mode_for_test(checkout_mode);
514                            direct.set_nix_for_test(nix);
515                            direct.set_scorecard_for_test(scorecard);
516                            direct.set_code_scanning_for_test(code_scanning);
517                            assert_eq!(params, direct);
518                            let projected = destinations(&params);
519                            for block in
520                                [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION]
521                            {
522                                assert!(projected.contains(&block.to_owned()), "{block}");
523                            }
524                            for destination in super::NIX_DESTINATIONS {
525                                assert_eq!(
526                                    projected.contains(&destination.to_owned()),
527                                    nix && tech == "rust",
528                                    "{tech} {forge} nix={nix}: {destination}"
529                                );
530                            }
531                            // The Scorecard workflow ships in the shared
532                            // GitHub zone alone, so the request reaches
533                            // every binding and no GitLab landing.
534                            for destination in super::SCORECARD_DESTINATIONS {
535                                assert_eq!(
536                                    projected.contains(&destination.to_owned()),
537                                    scorecard && forge == "github",
538                                    "{tech} {forge} scorecard={scorecard}: {destination}"
539                                );
540                            }
541                        }
542                    }
543                }
544            }
545        }
546    }
547
548    fn resolved_test_params(
549        tech: &str,
550        resolved: &super::Resolved,
551        checkout_mode: CheckoutMode,
552        style: Option<Style>,
553        nix: bool,
554        scorecard: bool,
555        code_scanning: Option<Provider>,
556    ) -> Result<super::Params, crate::error::RkError> {
557        let technologies = vec![tech.to_owned()];
558        super::Params::resolve(
559            camino::Utf8Path::new("."),
560            &super::Inputs {
561                technologies: &technologies,
562                forge: Some(&resolved.forge),
563                repo: resolved.repo.as_deref(),
564                release_mode: Some(ReleaseMode::Automatic),
565                release_driver: Some(tech),
566                style,
567                trunk: None,
568                checkout_mode: Some(checkout_mode),
569                nix: Some(nix),
570                reporting_policy: None,
571                scorecard: Some(scorecard),
572                code_scanning: Some(code_scanning),
573            },
574            None,
575            None,
576            super::Purpose::Init,
577        )
578    }
579
580    /// A rendered projection carries no unsubstituted token and no
581    /// mechanical sentinel; the one judgment sentinel stays in its seeded
582    /// file.
583    #[test]
584    fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
585        let params = resolved_test_params(
586            "rust",
587            &super::Resolved {
588                forge: "github".to_owned(),
589                repo: Some("acme/widget".to_owned()),
590            },
591            CheckoutMode::MainWorktree,
592            Some(Style::Trunk),
593            false,
594            false,
595            None,
596        )
597        .expect("the parameters resolve");
598        let entries = Projection::compute(&ProjectionInput {
599            params,
600            evidence: TargetEvidence::default(),
601        })
602        .expect("the pair projects")
603        .candidates;
604        let workflow = entries
605            .iter()
606            .find(|entry| entry.destination.ends_with("release-plz.yml"))
607            .expect("the workflow projects");
608        assert_eq!(workflow.kind, Kind::Rendered);
609        let text = String::from_utf8_lossy(&workflow.bytes);
610        assert!(!text.contains("OWNER"), "an owner token survived rendering");
611        assert!(text.contains("'acme'"));
612        assert!(!text.contains("TODO(release-kit)"));
613        let title = entries
614            .iter()
615            .find(|entry| entry.destination.ends_with("pr-title.yml"))
616            .expect("the title check projects");
617        let text = String::from_utf8_lossy(&title.bytes);
618        assert!(text.contains(SCOPE_SHAPE), "{text}");
619        assert!(
620            !text.contains("RK_SCOPE_SHAPE"),
621            "a scope token survived: {text}"
622        );
623        let seeded = entries
624            .iter()
625            .find(|entry| entry.destination == "release-plz.toml")
626            .expect("the seeded file projects");
627        assert_eq!(seeded.kind, Kind::Seeded);
628        let authored = embedded::SNIPPETS
629            .get_file("rust/github/release-plz.toml")
630            .expect("the seed ships")
631            .contents();
632        assert_eq!(seeded.bytes, authored, "a seeded file lands as authored");
633        assert!(String::from_utf8_lossy(&seeded.bytes).contains("TODO(release-kit)"));
634        for block in BLOCK_DESTINATIONS {
635            let entry = entries
636                .iter()
637                .find(|entry| entry.destination == block)
638                .expect("every block is part of the projection");
639            let text = String::from_utf8_lossy(&entry.bytes);
640            assert!(
641                !text.contains("RK_SCOPE_SHAPE"),
642                "{block} kept a token: {text}"
643            );
644        }
645    }
646
647    /// The Nix destinations project only under the opt-in: off, none of
648    /// them appears; on, the rust pairs carry them — the gitlab pair too,
649    /// minus the workflow, which is a forge file the gitlab pair does
650    /// not ship — and a pair without them projects the smaller product.
651    #[test]
652    fn the_nix_destinations_project_only_under_the_opt_in() {
653        use super::NIX_DESTINATIONS;
654        let paths = |nix: bool, forge: &str| -> Vec<String> {
655            destinations(
656                &resolved_test_params(
657                    "rust",
658                    &super::Resolved {
659                        forge: forge.to_owned(),
660                        repo: Some("acme/widget".to_owned()),
661                    },
662                    CheckoutMode::LinkedWorktree,
663                    Some(Style::Trunk),
664                    nix,
665                    false,
666                    None,
667                )
668                .expect("the parameters resolve"),
669            )
670        };
671        let off = paths(false, "github");
672        for destination in NIX_DESTINATIONS {
673            assert!(!off.contains(&destination.to_owned()), "{destination}");
674        }
675        let on = paths(true, "github");
676        for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
677            assert!(on.contains(&destination.to_owned()), "{destination}");
678        }
679        // The capability lands no workflow, so both forges land the same
680        // set: a job proving the build holds a merge only inside the
681        // workflow the required check needs, and that one is the
682        // target's own.
683        let gitlab = paths(true, "gitlab");
684        assert!(gitlab.contains(&"nix/package.nix".to_owned()));
685        assert!(
686            !on.iter()
687                .chain(gitlab.iter())
688                .any(|destination| destination.contains("nix.yml"))
689        );
690        let bash = destinations(
691            &resolved_test_params(
692                "bash",
693                &super::Resolved {
694                    forge: "github".to_owned(),
695                    repo: Some("acme/widget".to_owned()),
696                },
697                CheckoutMode::LinkedWorktree,
698                Some(Style::Trunk),
699                true,
700                false,
701                None,
702            )
703            .expect("the parameters resolve"),
704        );
705        assert!(
706            bash.iter()
707                .all(|destination| !NIX_DESTINATIONS.contains(&destination.as_str()))
708        );
709    }
710
711    /// The github and gitlab copies of the forge-independent Nix seeds
712    /// stay byte-identical: the loader composes exactly two layers and has
713    /// no technology-wide zone, so the duplication is deliberate and this
714    /// parity test is what keeps it honest.
715    #[test]
716    fn the_nix_seeds_are_identical_across_forge_pairs() {
717        for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
718            let github = embedded::SNIPPETS
719                .get_file(format!("rust/github/{name}"))
720                .expect("the github copy ships")
721                .contents();
722            let gitlab = embedded::SNIPPETS
723                .get_file(format!("rust/gitlab/{name}"))
724                .expect("the gitlab copy ships")
725                .contents();
726            assert_eq!(github, gitlab, "{name} diverged between the pairs");
727        }
728    }
729
730    /// The withhold judgment: a flake pair of the target's own withholds
731    /// the pair and the workflow while the package expression lands, a
732    /// crate shape the seed does not support withholds everything, and a
733    /// clean single-crate target withholds nothing.
734    #[test]
735    fn the_nix_withhold_judgment_covers_the_three_shapes() {
736        use super::NIX_DESTINATIONS;
737        let dir = tempfile::tempdir().expect("a scratch target exists");
738        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
739        let project = |nix: bool| {
740            let params = resolved_test_params(
741                "rust",
742                &super::Resolved {
743                    forge: "github".to_owned(),
744                    repo: Some("acme/widget".to_owned()),
745                },
746                CheckoutMode::LinkedWorktree,
747                Some(Style::Trunk),
748                nix,
749                false,
750                None,
751            )
752            .expect("the parameters resolve");
753            let evidence = TargetEvidence::gather(target, None).expect("the evidence reads");
754            Projection::compute(&ProjectionInput { params, evidence }).expect("the pair projects")
755        };
756        let withheld = |projection: &Projection| -> Vec<String> {
757            projection
758                .omissions
759                .iter()
760                .map(|omission| omission.destination.clone())
761                .collect()
762        };
763        let landed = |projection: &Projection, destination: &str| {
764            projection
765                .candidates
766                .iter()
767                .any(|candidate| candidate.destination == destination)
768        };
769
770        // No Cargo.toml: the whole capability is withheld by name.
771        let all = project(true);
772        assert_eq!(
773            withheld(&all),
774            ["flake.lock", "flake.nix", "nix/package.nix"]
775        );
776        assert!(
777            all.candidates
778                .iter()
779                .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
780        );
781
782        // A single crate with its own flake: the seed pair is withheld,
783        // and the package expression still lands.
784        std::fs::write(
785            target.join("Cargo.toml"),
786            "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
787        )
788        .expect("the crate manifest writes");
789        std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
790        std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
791        std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
792        std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
793        let all = project(true);
794        assert_eq!(withheld(&all), ["flake.lock", "flake.nix"]);
795        assert!(landed(&all, "nix/package.nix"));
796
797        // A clean single crate: nothing is withheld.
798        std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
799        let all = project(true);
800        assert!(all.omissions.is_empty());
801        assert!(landed(&all, "flake.nix"));
802
803        // Off, the judgment does not even look.
804        let all = project(false);
805        assert!(all.omissions.is_empty());
806        assert!(!landed(&all, "flake.nix"));
807    }
808
809    /// The glossary takes the same three shapes the routing block does,
810    /// and the marker pair it shares with `AGENTS.md` is what makes one
811    /// splice serve both.
812    #[test]
813    fn the_glossary_splices_into_every_shape() {
814        let owned = glossary_block();
815        let block = owned.as_str();
816
817        let fresh = spliced(None, block);
818        assert_eq!(fresh, format!("{block}\n"));
819        assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
820
821        let own = "# Glossary\n\n- `spike` — a throwaway branch.\n";
822        let appended = spliced(Some(own), block);
823        assert!(appended.starts_with(own));
824        assert_eq!(
825            extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
826            Some(block)
827        );
828
829        let stale = appended.replace("full-implement", "do-everything");
830        let refreshed = spliced(Some(&stale), block);
831        assert_eq!(
832            extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
833            Some(block)
834        );
835        assert_eq!(
836            refreshed.matches("BEGIN release-kit").count(),
837            1,
838            "a re-splice must replace, not accumulate"
839        );
840    }
841
842    /// Every line the target wrote below the end marker survives a
843    /// re-splice byte for byte: the block owns its marked lines and the
844    /// document belongs to the target.
845    #[test]
846    fn the_glossary_leaves_the_targets_region_alone() {
847        let owned = glossary_block();
848        let block = owned.as_str();
849        let below = "\n## Our own terms\n\n- `spike` — a throwaway branch, never merged.\n";
850        let landed = format!("{block}\n{below}");
851
852        let refreshed = spliced(Some(&landed), block);
853        assert!(
854            refreshed.ends_with(below),
855            "the target's own region changed: {refreshed}"
856        );
857        assert_eq!(
858            extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
859            Some(block)
860        );
861    }
862
863    /// Appending keeps the document whole: trailing spaces, blank lines,
864    /// and a missing final newline are the target's bytes, and a block
865    /// that owns its marked lines alone rewrites none of them.
866    #[test]
867    fn an_append_rewrites_no_byte_the_target_wrote() {
868        let owned = glossary_block();
869        let block = owned.as_str();
870        for own in [
871            "# Glossary\n\n- `spike` — throwaway.   \n\n\n",
872            "# Glossary\n\n- `spike` — throwaway.",
873            "# Glossary\r\n\r\n- `spike` — throwaway.\r\n",
874        ] {
875            let appended = spliced(Some(own), block);
876            assert!(
877                appended.starts_with(own),
878                "the target's bytes changed: {appended:?}"
879            );
880            assert_eq!(
881                extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
882                Some(block),
883                "{appended:?}"
884            );
885            let marker = appended.find(BLOCK_BEGIN).expect("the block landed");
886            assert!(
887                appended[..marker].ends_with('\n'),
888                "the block must open its own line: {appended:?}"
889            );
890        }
891    }
892
893    /// A document the target wrote is bytes, not text. A splice that
894    /// decoded it would replace an invalid sequence with U+FFFD and
895    /// rewrite a byte outside the markers, which the rule forbids.
896    #[test]
897    fn a_splice_decodes_no_byte_the_target_wrote() {
898        let owned = glossary_block();
899        let block = owned.as_str();
900
901        // Appending: the invalid byte sits in the target's own document.
902        let own = b"# Glossary\n\ncaf\xe9\n";
903        let appended = splice_marked_block(Some(own), block);
904        assert!(
905            appended.starts_with(own),
906            "the target's bytes changed: {appended:?}"
907        );
908        assert!(!appended.contains(&0xEF), "a replacement character landed");
909
910        // Replacing: the invalid byte sits below the end marker.
911        let mut landed = Vec::new();
912        landed.extend_from_slice(block.replace("full-implement", "do-everything").as_bytes());
913        landed.extend_from_slice(b"\n\ncaf\xe9\n");
914        let refreshed = splice_marked_block(Some(&landed), block);
915        assert!(
916            refreshed.ends_with(b"\n\ncaf\xe9\n"),
917            "the target's region below the markers changed: {refreshed:?}"
918        );
919        assert!(refreshed.starts_with(block.as_bytes()), "{refreshed:?}");
920    }
921
922    /// The glossary carries no parameter, so the same bytes land in
923    /// every target: no token survives it and no mode changes it.
924    #[test]
925    fn the_glossary_block_carries_no_parameter() {
926        let block = glossary_block();
927        assert!(block.starts_with(BLOCK_BEGIN), "{block}");
928        assert!(block.ends_with(BLOCK_END), "{block}");
929        assert!(!block.contains("RK_"), "a token survived: {block}");
930        assert!(!block.contains("OWNER"), "an owner token survived: {block}");
931        for term in [
932            "implement-and-request",
933            "implement-and-merge",
934            "full-implement",
935        ] {
936            assert!(block.contains(term), "{term} is missing from {block}");
937        }
938        assert!(
939            routing_block(CheckoutMode::LinkedWorktree).contains(GLOSSARY_DESTINATION),
940            "the routing block must name the destination it indexes"
941        );
942    }
943
944    #[test]
945    fn the_block_splices_into_every_agents_shape() {
946        let owned = routing_block(CheckoutMode::MainWorktree);
947        let block = owned.as_str();
948        let fresh = spliced(None, block);
949        assert_eq!(fresh, format!("{block}\n"));
950        assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
951
952        let appended = spliced(Some("# My project\n\nOwn rules.\n"), block);
953        assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
954        assert_eq!(
955            extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
956            Some(block)
957        );
958
959        let stale = appended.replace("Never author a tag", "Do author a tag");
960        let refreshed = spliced(Some(&stale), block);
961        assert_eq!(
962            extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
963            Some(block)
964        );
965        assert!(refreshed.starts_with("# My project"));
966        assert_eq!(
967            refreshed.matches("BEGIN release-kit").count(),
968            1,
969            "a re-splice must replace, not accumulate"
970        );
971    }
972
973    /// The hook block lands under `repos:` in every honest shape and
974    /// refuses the one dishonest shape by name.
975    #[test]
976    fn the_hook_block_splices_under_repos() {
977        let owned = hooks_block(CheckoutMode::MainWorktree);
978        let block = owned.as_str();
979        let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
980        assert!(fresh.starts_with(HOOK_TYPES_LINE));
981        assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
982        assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
983
984        let own =
985            "repos:\n  - repo: https://example.com/own\n    rev: v1\n    hooks:\n      - id: own\n";
986        let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
987        assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
988        assert!(spliced.contains("- id: own"), "the target's hooks survive");
989        assert!(
990            !spliced.contains(HOOK_TYPES_LINE),
991            "an existing file's top level is the skills' duty, not the splice's"
992        );
993
994        let stale = spliced.replace("--force-scope", "--no-scope");
995        let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
996        assert_eq!(
997            extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
998            Some(block)
999        );
1000        assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1001
1002        let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1003            .expect_err("no repos: line refuses");
1004        assert!(err.contains("repos:"), "{err}");
1005
1006        // The hooks between the markers execute, so ownership is exactly
1007        // one well-formed block: a duplicate or an unmatched marker
1008        // refuses rather than leaving a stale block active.
1009        let doubled = format!("repos:\n{block}\n{block}\n");
1010        let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1011        assert!(err.contains("one block"), "{err}");
1012        let unmatched = "repos:\n# BEGIN release-kit\n  - repo: local\n";
1013        let err =
1014            splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1015        assert!(err.contains("unmatched"), "{err}");
1016    }
1017
1018    /// Both modes of both blocks: the guard entry and the skip pair exist
1019    /// exactly in the worktree mode, one orientation line differs in the
1020    /// routing block, the rest is byte-identical, no mode token survives
1021    /// substitution, and the rendered grammar is [`BRANCH_GRAMMAR`], the
1022    /// one owner.
1023    #[test]
1024    fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1025        let worktree_hooks = hooks_block(CheckoutMode::LinkedWorktree);
1026        let branches_hooks = hooks_block(CheckoutMode::MainWorktree);
1027        assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1028        assert!(
1029            worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1030            "{worktree_hooks}"
1031        );
1032        assert!(!branches_hooks.contains("rk-worktree-location"));
1033        assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1034        for block in [&worktree_hooks, &branches_hooks] {
1035            assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1036            for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
1037                assert!(!block.contains(token), "{token} survived: {block}");
1038            }
1039        }
1040        // A hook entry renders as a YAML plain scalar, where a colon
1041        // followed by a space ends the scalar and breaks the whole file
1042        // — the defect dogfood caught in the guard's refusal messages —
1043        // so no entry value may carry one.
1044        for block in [&worktree_hooks, &branches_hooks] {
1045            for line in block.lines() {
1046                if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1047                    assert!(
1048                        !value.contains(": "),
1049                        "an entry value breaks the YAML plain scalar: {line}"
1050                    );
1051                }
1052            }
1053        }
1054        let guard_line = worktree_hooks
1055            .lines()
1056            .position(|line| line.contains("id: rk-worktree-location"))
1057            .expect("the guard entry exists");
1058        let name_line = worktree_hooks
1059            .lines()
1060            .position(|line| line.contains("id: rk-branch-name"))
1061            .expect("the name hook exists");
1062        assert!(
1063            guard_line > name_line,
1064            "the guard lands directly after rk-branch-name"
1065        );
1066
1067        let worktree_routing = routing_block(CheckoutMode::LinkedWorktree);
1068        let branches_routing = routing_block(CheckoutMode::MainWorktree);
1069        assert!(worktree_routing.contains("This project works in worktrees"));
1070        assert!(branches_routing.contains("Branches are worked in the main checkout"));
1071        for block in [&worktree_routing, &branches_routing] {
1072            assert!(block.contains("Create or remove a worktree"));
1073            assert!(block.contains("`rk worktree add <branch>`"));
1074            assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
1075        }
1076        let differing: Vec<(&str, &str)> = worktree_routing
1077            .lines()
1078            .zip(branches_routing.lines())
1079            .filter(|(a, b)| a != b)
1080            .collect();
1081        assert_eq!(
1082            differing.len(),
1083            1,
1084            "exactly one routing line differs per mode: {differing:?}"
1085        );
1086    }
1087
1088    /// One definition of an ill-formed hook file, for every reader: the
1089    /// well-formed shapes pass and each ambiguous shape names a defect.
1090    #[test]
1091    fn the_hook_marker_defects_are_named() {
1092        use super::hooks_marker_defect;
1093        let owned = hooks_block(CheckoutMode::MainWorktree);
1094        let block = owned.as_str();
1095        assert_eq!(hooks_marker_defect(""), None);
1096        assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1097        for (case, text) in [
1098            (
1099                "a second begin",
1100                format!("repos:\n{block}\n# BEGIN release-kit\n"),
1101            ),
1102            (
1103                "a second end",
1104                format!("repos:\n{block}\n# END release-kit\n"),
1105            ),
1106            (
1107                "an unpaired begin",
1108                "repos:\n# BEGIN release-kit\n".to_owned(),
1109            ),
1110            ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1111            (
1112                "an end before its begin",
1113                "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1114            ),
1115        ] {
1116            assert!(
1117                hooks_marker_defect(&text).is_some(),
1118                "{case} must be a defect"
1119            );
1120        }
1121    }
1122}