Skip to main content

release_kit/
landing.rs

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