Skip to main content

release_kit/
landing.rs

1//! The target-side landing model: file kinds, parameter rendering, and
2//! the routing block.
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 payload plus the landing parameters, so
8//! a later command can compare what is on disk against what would be
9//! written. The kinds are declared here, beside the payload, never
10//! inferred at runtime; a test holds the table closed over every snippet.
11
12pub mod invariants;
13pub mod manifest;
14
15use camino::Utf8Path;
16use serde::{Deserialize, Serialize};
17
18pub use manifest::{Style, Workflow};
19
20use crate::atomic;
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::error::RkError;
23use crate::release::{self, ReleaseManifest, ReleaseSource};
24
25/// The complete input to a payload projection. Comparisons reconstruct it
26/// from the landing record; landing verbs resolve their candidate inputs.
27#[derive(Debug)]
28pub struct Params {
29    tech: String,
30    forge: String,
31    repo: String,
32    workflow: Workflow,
33    style: Option<Style>,
34    nix: bool,
35    trunk: String,
36    line_prefix: String,
37    security_contact: String,
38    security_response: String,
39}
40
41/// Explicit invocation answers; absence falls through to configuration.
42#[derive(Default)]
43pub struct Inputs<'a> {
44    /// Binding override.
45    pub tech: Option<&'a str>,
46    /// Forge override.
47    pub forge: Option<&'a str>,
48    /// Repository override.
49    pub repo: Option<&'a str>,
50    /// Workflow override.
51    pub workflow: Option<Workflow>,
52    /// Release style override.
53    pub style: Option<Style>,
54    /// Nix capability override.
55    pub nix: Option<bool>,
56}
57
58/// Compatibility policy for a landing candidate.
59#[derive(Clone, Copy, PartialEq, Eq)]
60pub enum Purpose {
61    /// A first landing.
62    Init,
63    /// A preview may leave the repository unresolved.
64    Preview,
65    /// An existing record supplies compatibility answers.
66    Upgrade,
67    /// A pre-record target requires an explicit release style.
68    Adopt,
69}
70
71impl Params {
72    /// Reconstruct every projection parameter from the record alone,
73    /// including the compatibility defaults applied when it was loaded.
74    #[must_use]
75    pub fn from_record(record: &manifest::Manifest) -> Self {
76        Self {
77            tech: record.tech.clone(),
78            forge: record.forge.clone(),
79            repo: record.parameters.repo.clone(),
80            workflow: record.parameters.workflow,
81            style: record.parameters.style,
82            nix: record.parameters.nix,
83            trunk: record.parameters.trunk.clone(),
84            line_prefix: record.parameters.line_prefix.clone(),
85            security_contact: record.parameters.security_contact.clone(),
86            security_response: record.parameters.security_response.clone(),
87        }
88    }
89
90    /// Resolve flags, configuration, recorded compatibility inputs or detection,
91    /// and finally the compiled defaults. Comparisons use `from_record` alone.
92    ///
93    /// # Errors
94    /// Refuses unresolved identity or a style an existing target has not answered.
95    pub fn resolve(
96        source: &dyn ReleaseSource,
97        target: &Utf8Path,
98        flags: &Inputs<'_>,
99        config: Option<&crate::config::Config>,
100        record: Option<&manifest::Manifest>,
101        purpose: Purpose,
102    ) -> Result<Self, RkError> {
103        let answer = |flag: Option<&str>, configured: Option<&str>, recorded: Option<&str>| {
104            flag.or_else(|| configured.filter(|value| !value.is_empty()))
105                .or(recorded)
106                .map(str::to_owned)
107        };
108        let forge = answer(
109            flags.forge,
110            config.map(|c| c.project.forge.as_str()),
111            record.map(|r| r.forge.as_str()),
112        );
113        let repo = answer(
114            flags.repo,
115            config.map(|c| c.project.repo.as_str()),
116            record.map(|r| r.parameters.repo.as_str()),
117        );
118        let resolved = resolve(target, forge.as_deref(), repo.as_deref())?;
119        let tech = answer(
120            flags.tech,
121            config.map(|c| c.project.tech.as_str()),
122            record.map(|r| r.tech.as_str()),
123        )
124        .or_else(|| crate::detect::tech_of(target.as_std_path()).map(str::to_owned))
125        .ok_or_else(|| {
126            RkError::missing(
127                Diagnostic::new(
128                    Reason::TargetNotFound,
129                    "no technology detected: the target has no version file",
130                )
131                .action("pass --tech <rust|python|bash>"),
132            )
133        })?;
134        pair_files(source, &tech, &resolved.forge)?;
135        let workflow = flags
136            .workflow
137            .or_else(|| config.and_then(|c| c.landing.workflow))
138            .or_else(|| record.map(|r| r.parameters.workflow))
139            .unwrap_or(if purpose == Purpose::Adopt {
140                Workflow::Branches
141            } else {
142                Workflow::Worktree
143            });
144        let style = flags
145            .style
146            .or_else(|| config.and_then(|c| c.landing.style))
147            .or_else(|| record.and_then(|r| r.parameters.style));
148        let style = match (style, purpose) {
149            (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())),
150            (value, _) => Some(value.unwrap_or(Style::Trunk)),
151        };
152        let repo = resolved
153            .repo
154            .or_else(|| (purpose == Purpose::Preview).then(|| REPO_PLACEHOLDER.to_owned()))
155            .ok_or_else(repo_unresolved)?;
156        let trunk = config
157            .and_then(|c| c.project.trunk.clone())
158            .or_else(|| record.map(|r| r.parameters.trunk.clone()))
159            .unwrap_or_else(|| crate::config::TRUNK_DEFAULT.to_owned());
160        let line_prefix = config
161            .and_then(|c| c.setup.line_prefix.clone())
162            .or_else(|| record.map(|r| r.parameters.line_prefix.clone()))
163            .unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned());
164        // An explicitly present key wins, including an empty contact,
165        // which is how a target resets a recorded custom contact. An
166        // omitted key falls through to the record, so an upgrade under an
167        // older configuration keeps the policy the target already carries.
168        let security_contact = config
169            .and_then(|c| c.security.contact.clone())
170            .or_else(|| record.map(|r| r.parameters.security_contact.clone()))
171            .unwrap_or_default();
172        let security_contact =
173            crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
174        let security_response = config
175            .and_then(|c| c.security.response.clone())
176            .or_else(|| record.map(|r| r.parameters.security_response.clone()))
177            .unwrap_or_else(|| crate::config::RESPONSE_DEFAULT.to_owned());
178        let security_response = crate::config::canonical_response(&security_response)
179            .map_err(crate::config::invalid)?;
180        Ok(Self {
181            tech,
182            forge: resolved.forge,
183            repo,
184            workflow,
185            style,
186            nix: flags
187                .nix
188                .or_else(|| config.and_then(|c| c.landing.nix))
189                .or_else(|| record.map(|r| r.parameters.nix))
190                .unwrap_or(false),
191            trunk,
192            line_prefix,
193            security_contact,
194            security_response,
195        })
196    }
197
198    /// The binding selected for this landing.
199    #[must_use]
200    pub fn tech(&self) -> &str {
201        &self.tech
202    }
203
204    /// The forge selected for this landing.
205    #[must_use]
206    pub fn forge(&self) -> &str {
207        &self.forge
208    }
209
210    /// Whether this landing opted into Nix.
211    #[must_use]
212    pub const fn nix(&self) -> bool {
213        self.nix
214    }
215
216    /// The project path used by parameter-bearing blocks.
217    #[must_use]
218    pub fn repo(&self) -> &str {
219        &self.repo
220    }
221
222    /// The mode used by parameter-bearing blocks.
223    #[must_use]
224    pub const fn workflow(&self) -> Workflow {
225        self.workflow
226    }
227
228    /// The release style used by parameter-bearing blocks.
229    #[must_use]
230    pub const fn style(&self) -> Option<Style> {
231        self.style
232    }
233
234    /// The one permanent branch this landing writes into its artifacts.
235    #[must_use]
236    pub fn trunk(&self) -> &str {
237        &self.trunk
238    }
239
240    /// The release-line prefix this landing writes into its artifacts.
241    #[must_use]
242    pub fn line_prefix(&self) -> &str {
243        &self.line_prefix
244    }
245
246    /// The contact the landed policy names, empty for the forge's own
247    /// authored wording.
248    #[must_use]
249    pub fn security_contact(&self) -> &str {
250        &self.security_contact
251    }
252
253    /// The acknowledgment window the landed policy promises.
254    #[must_use]
255    pub fn security_response(&self) -> &str {
256        &self.security_response
257    }
258}
259
260#[cfg(test)]
261impl Params {
262    /// A parameter set for tests alone. Production code reaches `Params`
263    /// through `from_record` and `resolve` and through nothing else, and
264    /// this constructor is compiled out of the shipped binary.
265    pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
266        Self {
267            tech: "rust".to_owned(),
268            forge: "github".to_owned(),
269            repo: repo.to_owned(),
270            workflow: Workflow::Worktree,
271            style,
272            nix: false,
273            trunk: crate::config::TRUNK_DEFAULT.to_owned(),
274            line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
275            security_contact: String::new(),
276            security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
277        }
278    }
279
280    /// The same set with the two security parameters answered.
281    pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
282        Self {
283            security_contact: contact.to_owned(),
284            security_response: response.to_owned(),
285            ..Self::for_test("acme/widget", Some(Style::Trunk))
286        }
287    }
288}
289
290/// Who owns a landed file's bytes after landing.
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(rename_all = "lowercase")]
293pub enum Kind {
294    /// release-kit owns it: a newer payload re-renders it, and a target
295    /// edit is a conflict.
296    Rendered,
297    /// The target owns it: a starting point the project tunes, reported
298    /// and never rewritten.
299    Seeded,
300    /// The release automation owns it: never written after the first
301    /// landing, never compared.
302    State,
303}
304
305impl Kind {
306    /// The wire and report form.
307    #[must_use]
308    pub const fn as_str(self) -> &'static str {
309        match self {
310            Self::Rendered => "rendered",
311            Self::Seeded => "seeded",
312            Self::State => "state",
313        }
314    }
315}
316
317/// The declared classification: every landable destination and its kind.
318/// The workflow and pipeline files carry the release automation and the
319/// OIDC permission, so release-kit owns them; the tool configurations are
320/// per-project judgment; the two state files are rewritten by the release
321/// automation itself.
322const KINDS: [(&str, Kind); 16] = [
323    (".github/workflows/release-plz.yml", Kind::Rendered),
324    (".github/workflows/release-please.yml", Kind::Rendered),
325    (".github/workflows/release.yml", Kind::Rendered),
326    (".github/workflows/pr-title.yml", Kind::Rendered),
327    (".gitlab-ci.yml", Kind::Rendered),
328    ("SECURITY.md", Kind::Rendered),
329    (".gitlab/ci/mr-title.yml", Kind::Rendered),
330    ("release-plz.toml", Kind::Seeded),
331    ("dist-workspace.toml", Kind::Seeded),
332    ("release-please-config.json", Kind::Seeded),
333    ("cliff.toml", Kind::Seeded),
334    ("nix/package.nix", Kind::Seeded),
335    ("flake.nix", Kind::Seeded),
336    (".release-please-manifest.json", Kind::State),
337    ("VERSION", Kind::State),
338    ("flake.lock", Kind::State),
339];
340
341/// The destinations of the opt-in Nix capability, present in a projection
342/// only where the landing's `nix` parameter is on.
343///
344/// The parameter is recorded, so `status`, `upgrade`, and `adopt` can
345/// reconstruct whether these files are supposed to exist: an absent file
346/// under `nix = false` is not wanted, never drifted.
347///
348/// The capability lands no workflow, on either forge, and each forge's
349/// reason is its own. On GitHub a job gates the merge only inside the
350/// workflow the required check needs, and that workflow is the target's
351/// own. On GitLab the merge check is the whole pipeline, and a target's
352/// jobs live in the child pipeline the rendered parent triggers, which the
353/// target owns. The bindings serve the job for both.
354pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
355
356/// The subset a target with a flake of its own keeps out: the seed pair,
357/// whose files would sit beside a flake release-kit did not author.
358///
359/// The seeded package expression is not in it — it lands either way, as
360/// the starting point the target integrates by hand.
361pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
362
363/// The declared kind of a destination, or `None` for a file the payload
364/// does not classify.
365#[must_use]
366pub fn kind_of(destination: &str) -> Option<Kind> {
367    if destination == AGENTS_DESTINATION
368        || destination == GLOSSARY_DESTINATION
369        || destination == HOOKS_DESTINATION
370    {
371        return Some(Kind::Rendered);
372    }
373    KINDS
374        .iter()
375        .find(|(name, _)| *name == destination)
376        .map(|(_, kind)| *kind)
377}
378
379/// Every destination the payload can land, in declaration order.
380///
381/// The whole files and the three block destinations. The classification
382/// reads it to ask whether a destination is already present at a target.
383pub fn destinations() -> impl Iterator<Item = &'static str> {
384    KINDS
385        .iter()
386        .map(|(name, _)| *name)
387        .chain(BLOCK_DESTINATIONS)
388}
389
390/// The mechanical substitution sites in `rendered` files.
391///
392/// Known values, substituted identically everywhere each appears. The
393/// owner is derived from the landing's `repo` parameter and the scope
394/// shape from [`SCOPE_SHAPE`], so the landed bytes stay a deterministic
395/// function of payload plus parameters.
396pub const OWNER_TOKEN: &[u8] = b"OWNER";
397
398/// The repository a preview stands in for where nothing answered.
399///
400/// It is a placeholder, never a project path: a plan that would render
401/// it into a target is blocked, and only a preview may carry it.
402pub const REPO_PLACEHOLDER: &str = "OWNER";
403
404/// The full recorded project path, including nested namespaces.
405pub const REPO_TOKEN: &[u8] = b"RK_REPO";
406
407/// The one scope shape: the title checks' regular expression.
408pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
409
410/// The recorded release style: `trunk` arms the bot's request in the
411/// landed release workflow, `lines` leaves every request unarmed.
412pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
413
414/// The one permanent branch. A landed release trigger, ref guard, and
415/// branch guard each name it, so a target whose trunk is not `master`
416/// needs its own answer in its own bytes.
417pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
418
419/// The release-line branch prefix, naming the lines a release trigger
420/// accepts beside the trunk.
421pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
422
423/// The same prefix, escaped for a slash-delimited regular expression.
424///
425/// A GitLab rule names a line that way, and a raw `release/` would close
426/// the delimiter and break the pipeline, so the two forms are two tokens.
427/// This one substitutes first: the plain token is its own prefix.
428pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
429
430/// The three replaceable spans of a landed security policy, each as its
431/// ordered begin and end marker.
432///
433/// A span is not a token. Each forge's policy carries its own authored
434/// prose inside the markers, so a landing that answers neither security
435/// parameter strips the markers and reproduces the file the forge's
436/// snippet states, byte for byte and in that forge's own words. A landing
437/// that answers one replaces the interior of the spans that fact belongs
438/// to. The markers are HTML comments because the snippet is Markdown a
439/// reader may open before it is ever rendered.
440pub const SECURITY_SPANS: [(&[u8], &[u8]); 3] = [
441    (
442        b"<!--RK_SECURITY_CONTACT_BEGIN-->",
443        b"<!--RK_SECURITY_CONTACT_END-->",
444    ),
445    (
446        b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
447        b"<!--RK_SECURITY_RESPONSE_END-->",
448    ),
449    (
450        b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
451        b"<!--RK_SECURITY_DEADLINE_END-->",
452    ),
453];
454
455/// The sentence a policy with an acknowledgment window states in place of
456/// the forge's best-effort wording.
457fn acknowledgment(response: &str) -> String {
458    format!("Maintainers acknowledge a report within {response}.")
459}
460
461/// What a policy with an acknowledgment window says about deadlines: the
462/// authored sentence disclaims a response deadline, which a stated window
463/// contradicts, so only the disclosure half survives.
464const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
465
466/// The replacement for each span under one parameter set, or `None` where
467/// the forge's authored interior stands.
468fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 3] {
469    let contact = (!params.security_contact().is_empty())
470        .then(|| params.security_contact().as_bytes().to_vec());
471    let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
472    [
473        contact,
474        promised.then(|| acknowledgment(params.security_response()).into_bytes()),
475        promised.then(|| DISCLOSURE_ONLY.to_vec()),
476    ]
477}
478
479/// One marked span replaced, or the markers alone removed.
480///
481/// Exactly one ordered begin and end pair is a span; anything else is a
482/// payload defect a test holds, so this leaves such bytes untouched rather
483/// than growing a runtime failure mode into every rendered file.
484fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
485    let ordered = find(baseline, begin)
486        .zip(find(baseline, end))
487        .filter(|(start, stop)| stop > start);
488    let Some((start, stop)) = ordered else {
489        return baseline.to_vec();
490    };
491    let mut out = Vec::with_capacity(baseline.len());
492    out.extend_from_slice(&baseline[..start]);
493    out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
494    out.extend_from_slice(&baseline[stop + end.len()..]);
495    out
496}
497
498/// Substitute the landing parameters into a `rendered` file's bytes.
499///
500/// The repository's owner — the project path's first segment — replaces
501/// every `OWNER` occurrence; the full path replaces `RK_REPO` last.
502/// The one scope shape replaces the scope
503/// token, and the recorded style replaces the style token. The scope
504/// shape rests on no parameter, so it substitutes always. An unresolved
505/// style leaves its token standing, which only a preview renders under:
506/// an apply refuses before reaching here.
507///
508/// The trunk and the line prefix substitute from the same parameters, so
509/// a target that renames either carries the new name in every artifact
510/// that names it rather than in the binary's behavior alone.
511///
512/// The security policy's marked spans resolve last, after every token, so
513/// a contact that happens to spell a token name lands literally rather
514/// than being read as one more substitution site.
515#[must_use]
516pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
517    let repo = params.repo();
518    let owner = repo.split('/').next().unwrap_or(repo);
519    let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
520    if let Some(style) = params.style() {
521        out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
522    }
523    out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
524    out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
525    let escaped = params.line_prefix().replace('/', "\\/");
526    out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
527    out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
528    out = substitute(&out, REPO_TOKEN, repo.as_bytes());
529    for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
530        out = replace_span(&out, begin, end, value.as_deref());
531    }
532    out
533}
534
535/// Every `token` occurrence replaced with `value`.
536pub(crate) fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
537    let mut out = Vec::with_capacity(baseline.len());
538    let mut rest = baseline;
539    while let Some(at) = find(rest, token) {
540        out.extend_from_slice(&rest[..at]);
541        out.extend_from_slice(value);
542        rest = &rest[at + token.len()..];
543    }
544    out.extend_from_slice(rest);
545    out
546}
547
548/// First occurrence of `needle` in `haystack`.
549fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
550    haystack
551        .windows(needle.len())
552        .position(|window| window == needle)
553}
554
555/// The destination the routing block splices into.
556pub const AGENTS_DESTINATION: &str = "AGENTS.md";
557
558/// The block's opening marker.
559pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
560
561/// The block's closing marker.
562pub const BLOCK_END: &str = "<!-- END release-kit -->";
563
564/// The destination the glossary block splices into.
565///
566/// The document is the target's own vocabulary, so the block shares
567/// `AGENTS.md`'s marker pair and owns nothing outside it.
568pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
569
570/// The destination the hook block splices into.
571pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
572
573/// Every block destination, in the order a landing writes them.
574///
575/// A block destination owns the lines between its markers and nothing
576/// else, so every verb that asks whether a destination is block-placed
577/// reads this one list.
578pub const BLOCK_DESTINATIONS: [&str; 3] =
579    [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
580
581/// The hook block's opening marker, a YAML comment at column zero.
582pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
583
584/// The hook block's closing marker.
585pub const HOOKS_END: &str = "# END release-kit";
586
587/// The top-level key the fresh hook file carries and the skills verify on
588/// an existing one: the commit-msg and pre-push hooks run only where their
589/// hook types are installed.
590pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
591
592/// The authored routing-block template, `blocks/agents-block.md.in`.
593const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
594
595/// The authored glossary template, `blocks/glossary.md.in`.
596const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
597
598/// The routing block's mode line, worktree form.
599const AGENTS_LINE_WORKTREE: &str = "blocks/agents-line-worktree.md.in";
600
601/// The routing block's mode line, branches form.
602const AGENTS_LINE_BRANCHES: &str = "blocks/agents-line-branches.md.in";
603
604/// The authored hook-block template, `blocks/pre-commit-block.yaml.in`.
605const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
606
607/// The worktree mode's guard entry, `blocks/pre-commit-worktree-guard.yaml.in`.
608const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
609
610/// One authored block, read through the seam as text.
611fn block(
612    source: &dyn ReleaseSource,
613    manifest: &ReleaseManifest,
614    path: &str,
615) -> Result<String, RkError> {
616    let bytes = release::read(source, manifest, path)?;
617    String::from_utf8(bytes).map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
618}
619
620/// An authored block without the one final newline the repository's
621/// hooks enforce on every file under `blocks/`; a test in
622/// `src/embedded.rs` holds each file to exactly one.
623fn authored(text: &str) -> &str {
624    text.strip_suffix('\n').unwrap_or(text)
625}
626
627/// The one branch grammar.
628///
629/// The extended regular expression the landed
630/// `rk-branch-name` hook tests, and the same anchored language
631/// `rk worktree add` validates before creating anything. One owner by
632/// token — `concat!` cannot interpolate a const, so [`hooks_block`]
633/// substitutes it for the template's `RK_BRANCH_GRAMMAR` token.
634pub const BRANCH_GRAMMAR: &str = r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)/[A-Za-z0-9._/-]+|([0-9]+|[A-Z][A-Z0-9]+-[0-9]+)-[A-Za-z0-9._-]+|release[-/].+)$";
635
636/// The one commit scope shape.
637///
638/// A bracket expression, lowercase, admitting the digits and `_ . / -`
639/// beside the letters, so `area/subarea` reads as one scope. It holds the
640/// shape of a scope and never its vocabulary: the word itself is the
641/// author's, guided by the routing block and by the repository's own
642/// history. One owner by token — the title checks take it as
643/// `RK_SCOPE_SHAPE` through [`render`], and `rk message --check` reads it
644/// directly, so the desk and the forge judge one language.
645pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
646
647/// Whether one scope matches [`SCOPE_SHAPE`].
648///
649/// The predicate and the pattern are one owner, so the desk's judgment
650/// cannot drift from the forge's: `rk message --check` calls this, the
651/// title checks render the pattern, and a test holds the two equal over
652/// every ASCII character.
653#[must_use]
654pub fn scope_is_shaped(scope: &str) -> bool {
655    !scope.is_empty()
656        && scope.chars().all(|c| {
657            c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
658        })
659}
660
661/// The routing block for one workflow mode: the whole of target-side
662/// governance, authored as `blocks/agents-block.md.in` and never grown
663/// into a method chapter, read from the bundle `source` carries.
664///
665/// Markers included, without a
666/// trailing newline and with its scope token unrendered: the template
667/// with the mode's one orientation line substituted, everything else —
668/// the agent-boundary line included — byte-identical across modes.
669///
670/// # Errors
671///
672/// Returns the source's failures for a bundle that does not carry the
673/// block.
674pub fn routing_block(source: &dyn ReleaseSource, workflow: Workflow) -> Result<String, RkError> {
675    let manifest = source.manifest()?;
676    let line = block(
677        source,
678        &manifest,
679        match workflow {
680            Workflow::Worktree => AGENTS_LINE_WORKTREE,
681            Workflow::Branches => AGENTS_LINE_BRANCHES,
682        },
683    )?;
684    let template = block(source, &manifest, AGENTS_BLOCK)?;
685    Ok(authored(&template).replacen("RK_WORKFLOW_LINE", authored(&line), 1))
686}
687
688/// The glossary block, read from the bundle `source` carries.
689///
690/// Markers included and without a trailing newline, like the routing
691/// block. It carries no token and no mode: every term it names expands
692/// to steps of the one workflow, so the same bytes land in every target.
693///
694/// # Errors
695///
696/// Returns the source's failures for a bundle that does not carry the
697/// block.
698pub fn glossary_block(source: &dyn ReleaseSource) -> Result<String, RkError> {
699    let manifest = source.manifest()?;
700    Ok(authored(&block(source, &manifest, GLOSSARY_BLOCK)?).to_owned())
701}
702
703/// The hook block for one workflow mode, read from the bundle `source`
704/// carries.
705///
706/// Authored as `blocks/pre-commit-block.yaml.in` with the worktree mode's
707/// guard entry beside it in `blocks/pre-commit-worktree-guard.yaml.in`.
708/// Markers included, without a
709/// trailing newline and with its scope token unrendered. What is landed
710/// is what runs: the worktree mode's block carries the location guard and
711/// names the sweep-skip pair, and the branches mode's block carries no
712/// guard entry at all — never an entry that reads local state to decide
713/// whether to enforce. The one branch grammar substitutes here from
714/// [`BRANCH_GRAMMAR`].
715///
716/// # Errors
717///
718/// Returns the source's failures for a bundle that does not carry the
719/// block.
720pub fn hooks_block(source: &dyn ReleaseSource, workflow: Workflow) -> Result<String, RkError> {
721    let manifest = source.manifest()?;
722    let (guard, skip) = match workflow {
723        Workflow::Worktree => (
724            format!(
725                "{}\n",
726                authored(&block(source, &manifest, PRE_COMMIT_WORKTREE_GUARD)?)
727            ),
728            "no-commit-to-branch,rk-worktree-location",
729        ),
730        Workflow::Branches => (String::new(), "no-commit-to-branch"),
731    };
732    let template = block(source, &manifest, PRE_COMMIT_BLOCK)?;
733    Ok(authored(&template)
734        .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
735        .replacen("RK_SWEEP_SKIP", skip, 1)
736        .replacen("RK_WORKTREE_GUARD", &guard, 1))
737}
738
739/// The markers of a block destination, or `None` for a whole-file one.
740#[must_use]
741pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
742    match destination {
743        AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
744        HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
745        _ => None,
746    }
747}
748
749/// The marked block inside a document, markers included, or `None` where
750/// the text carries no complete block.
751#[must_use]
752pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
753    let start = text.find(begin)?;
754    let stop = text[start..].find(end)? + start + end.len();
755    Some(&text[start..stop])
756}
757
758/// The whole document's bytes after splicing a marked block into it.
759///
760/// A fresh file where none exists, the block replaced in place where one
761/// is marked, appended after the target's own content otherwise —
762/// release-kit owns the lines inside the markers, not the document. Both
763/// markdown destinations take this shape, `AGENTS.md` and the glossary.
764#[must_use]
765pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
766    let block = block.as_bytes();
767    let Some(text) = existing else {
768        return [block, b"\n"].concat();
769    };
770    // Bytes, never text: the document belongs to the target and a decode
771    // that replaces one invalid sequence rewrites a byte outside the
772    // markers, which is the one thing a block destination never does.
773    if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
774        && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
775    {
776        let stop = start + offset + BLOCK_END.len();
777        return [&text[..start], block, &text[stop..]].concat();
778    }
779    // Appending keeps every byte the target wrote, trailing blank lines
780    // and an absent final newline included. The only addition is the
781    // separator that opens the block's own line.
782    let separator: &[u8] = if text.ends_with(b"\n") {
783        b"\n"
784    } else {
785        b"\n\n"
786    };
787    [text, separator, block, b"\n"].concat()
788}
789
790/// The whole `.pre-commit-config.yaml` content after splicing the
791/// rendered hook block.
792///
793/// A fresh file carries the hook-types key, the `repos:` key, and the
794/// block; a marked file takes the block in place; an unmarked file takes
795/// it directly under its `repos:` line, above the target's own hooks. An
796/// unmarked file with no `repos:` line is refused by name — the block's
797/// entries are list items and have nowhere honest to go.
798///
799/// # Errors
800///
801/// The reason the block has no place, for the caller's refusal to carry.
802pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
803    let Some(text) = existing else {
804        return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
805    };
806    if let Some(defect) = hooks_marker_defect(text) {
807        return Err(defect);
808    }
809    if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
810        return Ok(text.replacen(found, block, 1));
811    }
812    let mut out = String::with_capacity(text.len() + block.len() + 1);
813    let mut placed = false;
814    for line in text.split_inclusive('\n') {
815        out.push_str(line);
816        if !placed && line.trim_end() == "repos:" {
817            if !out.ends_with('\n') {
818                out.push('\n');
819            }
820            out.push_str(block);
821            out.push('\n');
822            placed = true;
823        }
824    }
825    if placed {
826        Ok(out)
827    } else {
828        Err(format!(
829            "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
830        ))
831    }
832}
833
834/// The one definition of an ill-formed hook file, shared by the splice
835/// and every reader that judges one.
836///
837/// The hooks between the markers execute, so ownership must be
838/// unambiguous: exactly one begin marker paired with exactly one end
839/// marker after it, or none of either. A second begin is a second block
840/// pre-commit would still run, and a marker without its pair — or an end
841/// before its begin — is a block whose extent nothing can state.
842#[must_use]
843pub fn hooks_marker_defect(text: &str) -> Option<String> {
844    let begins = text.matches(HOOKS_BEGIN).count();
845    let ends = text.matches(HOOKS_END).count();
846    if begins > 1 || ends > 1 {
847        return Some(format!(
848            "{HOOKS_DESTINATION} carries more than one release-kit marker pair; release-kit owns exactly one block"
849        ));
850    }
851    match (text.find(HOOKS_BEGIN), text.find(HOOKS_END)) {
852        (Some(begin), Some(end)) if end > begin => None,
853        (None, None) => None,
854        _ => Some(format!(
855            "{HOOKS_DESTINATION} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
856        )),
857    }
858}
859
860/// How a projected artifact occupies its destination.
861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
862pub enum Placement {
863    /// The artifact is the whole file.
864    Whole,
865    /// The artifact is the marked block inside the target's `AGENTS.md`.
866    Block,
867}
868
869/// One artifact of the payload projection: what would land at one
870/// destination, with the payload bytes it was rendered from.
871#[derive(Debug)]
872pub struct Entry {
873    /// The destination, relative to the target root.
874    pub destination: String,
875    /// The declared kind.
876    pub kind: Kind,
877    /// Whole file, or the marked block.
878    pub placement: Placement,
879    /// The payload bytes before substitution — what `baseline_sha256`
880    /// digests.
881    pub baseline: Vec<u8>,
882    /// The bytes a landing writes: substituted for `rendered` files,
883    /// identical to the baseline otherwise.
884    pub rendered: Vec<u8>,
885}
886
887/// The landable files of one `(technology, forge)` pair, as
888/// `(destination, payload bytes)`, read from the bundle `source` carries.
889///
890/// # Errors
891///
892/// Returns [`RkError::Usage`] naming the known bindings for an unknown
893/// technology, and the supported pairs for a pair with no files.
894pub fn pair_files(
895    source: &dyn ReleaseSource,
896    tech: &str,
897    forge: &str,
898) -> Result<Vec<(String, Vec<u8>)>, RkError> {
899    let manifest = source.manifest()?;
900    let techs: Vec<String> = manifest
901        .dirs_under("snippets")
902        .into_iter()
903        .filter(|name| !name.starts_with('_'))
904        .collect();
905    // The shared zone is not a technology: `_shared/<forge>` composes into
906    // every pair and never names one.
907    if tech.starts_with('_') || !techs.iter().any(|known| known == tech) {
908        return Err(RkError::Usage(format!(
909            "unknown tech '{tech}'; the bindings are: {}",
910            techs.join(", ")
911        )));
912    }
913    let pair = format!("snippets/{tech}/{forge}");
914    if manifest.under(&pair).next().is_none() {
915        let known: Vec<String> = techs
916            .iter()
917            .flat_map(|tech| {
918                manifest
919                    .dirs_under(&format!("snippets/{tech}"))
920                    .into_iter()
921                    .map(move |forge| format!("{tech}, {forge}"))
922            })
923            .collect();
924        return Err(RkError::Usage(format!(
925            "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
926            known.join("; ")
927        )));
928    }
929    // Payload paths carry their zone prefix; destinations do not. The
930    // shared zone lands first, and a destination both zones ship is a
931    // payload defect refused by name, never one zone silently winning.
932    let mut files: Vec<(String, Vec<u8>)> = Vec::new();
933    for (rel, artifact) in manifest.under(&format!("snippets/_shared/{forge}")) {
934        files.push((rel.to_owned(), source.blob(&artifact.sha256)?));
935    }
936    for (rel, artifact) in manifest.under(&pair) {
937        if files.iter().any(|(existing, _)| existing == rel) {
938            return Err(anyhow::anyhow!(
939                "the shared zone and the pair ({tech}, {forge}) both ship {rel}; the payload is defective"
940            )
941            .into());
942        }
943        files.push((rel.to_owned(), source.blob(&artifact.sha256)?));
944    }
945    Ok(files)
946}
947
948/// The whole payload projection for one pair, from the bundle `source`
949/// carries.
950///
951/// Under the `repo`, `workflow`,
952/// `style`, and `nix` parameters: every snippet with its kind and
953/// rendered bytes, plus the routing block and the hook block — each a
954/// pure function of the recorded mode — sorted by destination. The Nix
955/// destinations project only where `nix` is on; a pair that ships none of
956/// them honestly projects the smaller product.
957///
958/// # Errors
959///
960/// Returns the [`pair_files`] errors, and [`RkError::Other`] for a
961/// snippet destination the kind table does not classify, which is a
962/// defect in this binary.
963pub fn projection(source: &dyn ReleaseSource, params: &Params) -> Result<Vec<Entry>, RkError> {
964    let mut entries = Vec::new();
965    for (destination, baseline) in pair_files(source, &params.tech, &params.forge)? {
966        if !params.nix && NIX_DESTINATIONS.contains(&destination.as_str()) {
967            continue;
968        }
969        let kind = kind_of(&destination).ok_or_else(|| {
970            anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
971        })?;
972        let rendered = match kind {
973            Kind::Rendered => render(&baseline, params),
974            Kind::Seeded | Kind::State => baseline.clone(),
975        };
976        entries.push(Entry {
977            destination,
978            kind,
979            placement: Placement::Whole,
980            baseline,
981            rendered,
982        });
983    }
984    // A bundle from before the glossary shipped declares no template for
985    // it, and an older release stays selectable: the destination joins the
986    // projection only where the selected bundle carries it.
987    let mut blocks = vec![(AGENTS_DESTINATION, routing_block(source, params.workflow)?)];
988    if source.manifest()?.artifact(GLOSSARY_BLOCK).is_some() {
989        blocks.push((GLOSSARY_DESTINATION, glossary_block(source)?));
990    }
991    blocks.push((HOOKS_DESTINATION, hooks_block(source, params.workflow)?));
992    for (destination, template) in blocks {
993        entries.push(Entry {
994            destination: destination.to_owned(),
995            kind: Kind::Rendered,
996            placement: Placement::Block,
997            baseline: template.as_bytes().to_vec(),
998            rendered: render(template.as_bytes(), params),
999        });
1000    }
1001    entries.sort_by(|a, b| a.destination.cmp(&b.destination));
1002    Ok(entries)
1003}
1004
1005/// Why the whole Nix capability stays out of a landing, or `None` where
1006/// the target's crate shape supports the seed.
1007///
1008/// The gate holds every structural prerequisite the seed relies on, not
1009/// only evaluation: the package expression reads `Cargo.toml` through
1010/// `importTOML` and throws without `../Cargo.lock`, and the seed flake's
1011/// smoke check runs the crate's binary, which only an implicit
1012/// `src/main.rs` or an explicit `[[bin]]` entry produces. A shape
1013/// missing any of these would land files that fail on their first
1014/// evaluation or first check, so the landing reports the smaller product
1015/// with the missing piece named instead.
1016#[must_use]
1017pub fn nix_unsupported_shape(target: &Utf8Path) -> Option<String> {
1018    let Ok(text) = std::fs::read_to_string(target.join("Cargo.toml")) else {
1019        return Some(
1020            "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
1021        );
1022    };
1023    let Ok(table) = text.parse::<toml::Table>() else {
1024        return Some(
1025            "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
1026        );
1027    };
1028    if !table.contains_key("package") {
1029        return Some(
1030            "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
1031        );
1032    }
1033    if !target.join("Cargo.lock").is_file() {
1034        return Some(
1035            "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
1036        );
1037    }
1038    let implicit_bin = target.join("src/main.rs").is_file()
1039        && table
1040            .get("package")
1041            .and_then(toml::Value::as_table)
1042            .and_then(|package| package.get("autobins"))
1043            .and_then(toml::Value::as_bool)
1044            != Some(false);
1045    let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
1046    if explicit_bins.is_none() && !implicit_bin {
1047        return Some(
1048            "the target declares no binary — no effective src/main.rs and no [[bin]] entry — and the seed flake's smoke check runs one; no Nix file lands".to_owned(),
1049        );
1050    }
1051    // The seed's mainProgram is the first [[bin]] entry; one whose
1052    // required-features a default build does not enable produces no
1053    // executable, so the smoke check would fail on a green landing. A
1054    // requirement the default feature set covers builds normally and
1055    // passes.
1056    if let Some(bins) = explicit_bins {
1057        let required = bins
1058            .first()
1059            .and_then(toml::Value::as_table)
1060            .and_then(|bin| bin.get("required-features"))
1061            .and_then(toml::Value::as_array);
1062        if let Some(required) = required {
1063            let enabled = default_features(&table);
1064            let missing = required
1065                .iter()
1066                .filter_map(toml::Value::as_str)
1067                .any(|feature| !enabled.contains(feature));
1068            if missing {
1069                return Some(
1070                    "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
1071                );
1072            }
1073        }
1074    }
1075    None
1076}
1077
1078/// Whether any feature's list carries a `dep:name` edge, which is what
1079/// suppresses the optional dependency's implicit same-named feature.
1080fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
1081    let edge = format!("dep:{name}");
1082    features.values().any(|list| {
1083        list.as_array().is_some_and(|entries| {
1084            entries
1085                .iter()
1086                .filter_map(toml::Value::as_str)
1087                .any(|entry| entry == edge)
1088        })
1089    })
1090}
1091
1092/// Whether `name` is declared an optional dependency, in any of the
1093/// dependency tables a binary's build reads.
1094fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
1095    ["dependencies", "build-dependencies"]
1096        .iter()
1097        .any(|section| {
1098            table
1099                .get(*section)
1100                .and_then(toml::Value::as_table)
1101                .and_then(|dependencies| dependencies.get(name))
1102                .and_then(toml::Value::as_table)
1103                .and_then(|dependency| dependency.get("optional"))
1104                .and_then(toml::Value::as_bool)
1105                == Some(true)
1106        })
1107}
1108
1109/// The features a default build enables: the `default` feature resolved
1110/// through the `[features]` table's own enables — an approximation of
1111/// cargo's default resolution for the documented supported shapes, erring
1112/// toward withholding where the semantics run deeper. Dependency forms —
1113/// `dep:name`, weak `name?/feature` — are not feature names here and are
1114/// skipped; the closure is bounded by the table's size.
1115fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
1116    let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
1117        return std::collections::BTreeSet::new();
1118    };
1119    let mut enabled = std::collections::BTreeSet::new();
1120    let mut queue = vec!["default".to_owned()];
1121    while let Some(name) = queue.pop() {
1122        if !enabled.insert(name.clone()) {
1123            continue;
1124        }
1125        if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
1126            for implied in implies.iter().filter_map(toml::Value::as_str) {
1127                if implied.starts_with("dep:") || implied.contains("?/") {
1128                    // `dep:name` enables the dependency without a feature
1129                    // of this crate; a weak `name?/feature` edge enables
1130                    // nothing by itself.
1131                    continue;
1132                }
1133                if let Some((package, _)) = implied.split_once('/') {
1134                    // A strong `name/feature` edge activates this crate's
1135                    // same-named feature only for an optional dependency,
1136                    // and only where that feature exists: declared
1137                    // explicitly, or implicit and not suppressed by a
1138                    // `dep:` edge anywhere in the table. A non-optional
1139                    // dependency's edge enables a feature of the
1140                    // dependency and nothing of this crate.
1141                    let feature_exists =
1142                        features.contains_key(package) || !dep_edge_suppresses(features, package);
1143                    if is_optional_dependency(table, package) && feature_exists {
1144                        queue.push(package.to_owned());
1145                    }
1146                } else {
1147                    queue.push(implied.to_owned());
1148                }
1149            }
1150        }
1151    }
1152    enabled
1153}
1154
1155/// Why the flake half of the Nix capability stays out of this landing, or
1156/// `None` where the pair lands whole.
1157///
1158/// The pair is all-or-nothing: a target that already carries a
1159/// `flake.nix` or `flake.lock` of its own keeps its pair, because a seed
1160/// lock beside a foreign flake describes the wrong input graph. A pair
1161/// the record names is release-kit's own landing and is never withheld.
1162///
1163/// # Errors
1164///
1165/// Any read failure other than the files being absent.
1166pub fn nix_withheld(
1167    target: &Utf8Path,
1168    recorded: Option<&manifest::Manifest>,
1169) -> std::io::Result<Option<String>> {
1170    if recorded.is_some_and(|record| record.file("flake.nix").is_some()) {
1171        return Ok(None);
1172    }
1173    let mut present = Vec::new();
1174    for name in ["flake.nix", "flake.lock"] {
1175        match std::fs::symlink_metadata(target.join(name).as_std_path()) {
1176            Ok(_) => present.push(name),
1177            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1178            Err(e) => return Err(e),
1179        }
1180    }
1181    if present.is_empty() {
1182        return Ok(None);
1183    }
1184    Ok(Some(format!(
1185        "the target already carries {}; its flake pair stays its own",
1186        present.join(" and ")
1187    )))
1188}
1189
1190/// One destination a landing withholds, with why.
1191#[derive(Debug, Clone, Serialize)]
1192pub struct Withheld {
1193    /// The destination that stays out.
1194    pub path: String,
1195    /// The reason, stated once per destination so a machine reader needs
1196    /// no join.
1197    pub reason: String,
1198}
1199
1200/// The Nix destinations an opted-in landing withholds at this target, with
1201/// the one reason, or `None` where the capability lands whole.
1202///
1203/// The judgment [`withhold_nix`] applies, exposed as a value so a planner
1204/// can read it without an entry list: an unsupported crate shape names
1205/// the whole capability, and a flake pair of the target's own names the
1206/// pair.
1207///
1208/// # Errors
1209///
1210/// Any read failure from the pair check other than absence.
1211pub fn nix_withholding(
1212    target: &Utf8Path,
1213    recorded: Option<&manifest::Manifest>,
1214) -> Result<Option<(&'static [&'static str], String)>, RkError> {
1215    if let Some(reason) = nix_unsupported_shape(target) {
1216        return Ok(Some((&NIX_DESTINATIONS[..], reason)));
1217    }
1218    if let Some(reason) = nix_withheld(target, recorded)? {
1219        return Ok(Some((&NIX_WITHHOLDABLE[..], reason)));
1220    }
1221    Ok(None)
1222}
1223
1224/// Drop the Nix destinations this target cannot take from a projection,
1225/// naming each with its reason.
1226///
1227/// The one judgment every landing verb shares, so a preview, an apply, an
1228/// upgrade, and an adoption all withhold identically: an unsupported
1229/// crate shape withholds the whole capability, and a flake pair of the
1230/// target's own withholds the pair and the workflow while the seeded
1231/// package expression still lands.
1232///
1233/// # Errors
1234///
1235/// Any read failure from the pair check other than absence.
1236pub fn withhold_nix(
1237    target: &Utf8Path,
1238    nix: bool,
1239    recorded: Option<&manifest::Manifest>,
1240    entries: &mut Vec<Entry>,
1241) -> Result<Vec<Withheld>, RkError> {
1242    if !nix {
1243        return Ok(Vec::new());
1244    }
1245    let Some((set, reason)) = nix_withholding(target, recorded)? else {
1246        return Ok(Vec::new());
1247    };
1248    let mut withheld = Vec::new();
1249    entries.retain(|entry| {
1250        if set.contains(&entry.destination.as_str()) {
1251            withheld.push(Withheld {
1252                path: entry.destination.clone(),
1253                reason: reason.clone(),
1254            });
1255            false
1256        } else {
1257            true
1258        }
1259    });
1260    Ok(withheld)
1261}
1262
1263/// The bytes an entry's destination currently holds: the whole file, or
1264/// the marked block extracted from the target's `AGENTS.md`. `None` means
1265/// the file — or the block — is absent.
1266///
1267/// # Errors
1268///
1269/// Any read failure other than the file being absent.
1270pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
1271    read_recorded(target, &entry.destination)
1272}
1273
1274/// The bytes a recorded destination currently holds, by the placement
1275/// its name implies.
1276///
1277/// The marked block for `AGENTS.md` and `.pre-commit-config.yaml`, the
1278/// whole file otherwise. `None` means the file — or the block — is
1279/// absent.
1280///
1281/// # Errors
1282///
1283/// Any read failure other than the file being absent.
1284pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
1285    let path = target.join(destination);
1286    let bytes = match std::fs::read(&path) {
1287        Ok(bytes) => bytes,
1288        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1289        Err(e) => return Err(e),
1290    };
1291    if let Some((begin, end)) = block_markers(destination) {
1292        let text = String::from_utf8_lossy(&bytes);
1293        Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
1294    } else {
1295        Ok(Some(bytes))
1296    }
1297}
1298
1299/// What one detection pass resolved for a target-side verb, with the
1300/// override flags applied.
1301#[derive(Debug)]
1302pub struct Resolved {
1303    /// The forge whose payload applies.
1304    pub forge: String,
1305    /// The project path, where a flag or the remote names one.
1306    pub repo: Option<String>,
1307}
1308
1309/// Resolve forge and repository in one pass: the flags override, the
1310/// `origin` remote answers otherwise.
1311///
1312/// An unrecognized host refuses rather than defaulting — landing one
1313/// forge's files into the other forge's project is a half-configured
1314/// repository that looks done.
1315///
1316/// # Errors
1317///
1318/// Returns [`RkError::Usage`] for an unknown `--forge` value, and a
1319/// refusal naming the override when no forge resolves.
1320pub fn resolve(
1321    target: &Utf8Path,
1322    forge_flag: Option<&str>,
1323    repo_flag: Option<&str>,
1324) -> Result<Resolved, RkError> {
1325    let forge_flag = forge_flag
1326        .map(|name| {
1327            crate::detect::Forge::parse(name).ok_or_else(|| {
1328                RkError::Usage(format!(
1329                    "unknown forge '{name}'; the forges are: github, gitlab"
1330                ))
1331            })
1332        })
1333        .transpose()?;
1334    let detected = crate::detect::detect(target.as_std_path());
1335    let forge = forge_flag
1336        .or(detected.forge)
1337        .map(|forge| forge.as_str().to_owned())
1338        .ok_or_else(|| {
1339            let message = detected.host.map_or_else(
1340                || "no forge detected: the target has no origin remote".to_owned(),
1341                |host| format!("no forge detected: the host {host} is not recognized"),
1342            );
1343            RkError::refusal(
1344                Diagnostic::new(Reason::ForgeUndetected, message)
1345                    .expected("a github.com or gitlab remote, or --forge")
1346                    .action("pass --forge <github|gitlab>"),
1347            )
1348        })?;
1349    Ok(Resolved {
1350        forge,
1351        repo: repo_flag.map(str::to_owned).or(detected.repo),
1352    })
1353}
1354
1355/// The refusal a verb answers when it needs the `repo` parameter and
1356/// neither a flag nor the remote supplies one.
1357#[must_use]
1358pub fn repo_unresolved() -> RkError {
1359    RkError::missing(
1360        Diagnostic::new(
1361            Reason::ForgeUndetected,
1362            "no repository detected: the target has no origin remote",
1363        )
1364        .expected("an origin remote naming the project")
1365        .action("pass --repo <path>"),
1366    )
1367}
1368
1369/// Land one entry: the whole file through the temp-plus-rename writer, or
1370/// the block spliced into its document and the whole document rewritten
1371/// the same way.
1372///
1373/// # Errors
1374///
1375/// Any write failure; the destination then holds what it held. An
1376/// unspliceable hook file surfaces as an error here only as a backstop —
1377/// [`hooks_splice_refusal`] is the check a verb runs before any write.
1378pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
1379    let path = target.join(&entry.destination);
1380    match entry.placement {
1381        Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
1382        Placement::Block => {
1383            let existing = match std::fs::read(&path) {
1384                Ok(bytes) => Some(bytes),
1385                Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
1386                Err(e) => return Err(e),
1387            };
1388            // The block is release-kit's own text; the document is the
1389            // target's bytes and is never decoded.
1390            let block = String::from_utf8_lossy(&entry.rendered).into_owned();
1391            if entry.destination == HOOKS_DESTINATION {
1392                let text = existing.map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
1393                let spliced =
1394                    splice_hooks_block(text.as_deref(), &block).map_err(std::io::Error::other)?;
1395                atomic::write(path.as_std_path(), spliced.as_bytes())
1396            } else {
1397                let spliced = splice_marked_block(existing.as_deref(), &block);
1398                atomic::write(path.as_std_path(), &spliced)
1399            }
1400        }
1401    }
1402}
1403
1404/// The hook file's defect, read from the target: `None` for a missing
1405/// file or one the block can land in.
1406///
1407/// The one judgment every verb shares, covering every splice refusal —
1408/// ill-formed markers, and an unmarked file offering the block no
1409/// `repos:` line. Status reports it as rendered drift, upgrade collects
1410/// it as a conflict in preview and apply alike so no landing dies
1411/// half-written, and adopt lists it with its mismatches.
1412///
1413/// # Errors
1414///
1415/// Any read failure other than the file being absent.
1416pub fn hooks_file_defect(
1417    source: &dyn ReleaseSource,
1418    target: &Utf8Path,
1419) -> Result<Option<String>, RkError> {
1420    let path = target.join(HOOKS_DESTINATION);
1421    match std::fs::read(&path) {
1422        Ok(bytes) => {
1423            let text = String::from_utf8_lossy(&bytes);
1424            let manifest = source.manifest()?;
1425            let template = block(source, &manifest, PRE_COMMIT_BLOCK)?;
1426            Ok(splice_hooks_block(Some(&text), authored(&template)).err())
1427        }
1428        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1429        Err(e) => Err(e.into()),
1430    }
1431}
1432
1433/// The refusal a landing verb answers before writing anything, where
1434/// the target's hook file offers the block no place.
1435///
1436/// Checked ahead of every write so the all-or-nothing property holds and
1437/// no landing dies half-written into `.pre-commit-config.yaml`.
1438///
1439/// # Errors
1440///
1441/// [`RkError::Refusal`] naming the file, and any read failure.
1442pub fn hooks_splice_refusal(source: &dyn ReleaseSource, target: &Utf8Path) -> Result<(), RkError> {
1443    hooks_file_defect(source, target)?.map_or(Ok(()), |reason| {
1444        Err(RkError::refusal(
1445            Diagnostic::new(
1446                Reason::StateDrift,
1447                format!("{reason}, and nothing was written"),
1448            )
1449            .expected("a .pre-commit-config.yaml the block can land in, or none")
1450            .action(format!(
1451                "resolve it in {}, then re-run",
1452                target.join(HOOKS_DESTINATION)
1453            ))
1454            .target_state("unchanged"),
1455        ))
1456    })
1457}
1458
1459#[cfg(test)]
1460mod tests {
1461    use super::{
1462        AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
1463        GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION, HOOKS_END, Kind,
1464        SCOPE_SHAPE, Style, Workflow, extract_block, kind_of, render, splice_hooks_block,
1465        splice_marked_block,
1466    };
1467    use crate::embedded;
1468    use crate::release::EmbeddedReleaseSource;
1469
1470    /// The embedded bundle, which every test here reads through the seam.
1471    const SOURCE: EmbeddedReleaseSource = EmbeddedReleaseSource;
1472
1473    fn pair_files(
1474        tech: &str,
1475        forge: &str,
1476    ) -> Result<Vec<(String, Vec<u8>)>, crate::error::RkError> {
1477        super::pair_files(&SOURCE, tech, forge)
1478    }
1479
1480    fn projection(params: &super::Params) -> Result<Vec<super::Entry>, crate::error::RkError> {
1481        super::projection(&SOURCE, params)
1482    }
1483
1484    fn routing_block(workflow: Workflow) -> String {
1485        super::routing_block(&SOURCE, workflow).expect("the embedded bundle carries the block")
1486    }
1487
1488    fn hooks_block(workflow: Workflow) -> String {
1489        super::hooks_block(&SOURCE, workflow).expect("the embedded bundle carries the block")
1490    }
1491
1492    fn glossary_block() -> String {
1493        super::glossary_block(&SOURCE).expect("the embedded bundle carries the block")
1494    }
1495
1496    /// The splice returns the document's bytes; every assertion below
1497    /// reads them back as text, which every fixture here is.
1498    fn spliced(existing: Option<&str>, block: &str) -> String {
1499        String::from_utf8(splice_marked_block(existing.map(str::as_bytes), block))
1500            .expect("the fixtures are text")
1501    }
1502
1503    #[test]
1504    fn private_reporting_path_tokens_are_reproducible() {
1505        for repo in [
1506            "acme/widget",
1507            "acme/group/widget",
1508            "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
1509        ] {
1510            assert_eq!(
1511                super::render(
1512                    b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
1513                    &super::Params::for_test(repo, Some(super::Style::Trunk))
1514                ),
1515                format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
1516            );
1517        }
1518        assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
1519    }
1520
1521    /// Both forge policies carry exactly one ordered pair of every
1522    /// security marker. The span renderer treats anything else as a
1523    /// payload defect and leaves the bytes alone, so this test is what
1524    /// keeps a defect out of a release rather than out of one landing.
1525    #[test]
1526    fn each_forge_policy_carries_one_ordered_pair_of_every_span() {
1527        for forge in ["github", "gitlab"] {
1528            let bytes = embedded::SNIPPETS
1529                .get_file(format!("_shared/{forge}/SECURITY.md"))
1530                .expect("the policy ships")
1531                .contents();
1532            let text = String::from_utf8_lossy(bytes);
1533            for (begin, end) in super::SECURITY_SPANS {
1534                let begin = String::from_utf8_lossy(begin);
1535                let end = String::from_utf8_lossy(end);
1536                assert_eq!(text.matches(begin.as_ref()).count(), 1, "{forge} {begin}");
1537                assert_eq!(text.matches(end.as_ref()).count(), 1, "{forge} {end}");
1538                assert!(
1539                    text.find(begin.as_ref()) < text.find(end.as_ref()),
1540                    "{forge}: {begin} must precede {end}"
1541                );
1542            }
1543        }
1544    }
1545
1546    /// The default answers reproduce each forge's authored policy exactly,
1547    /// markers removed and each forge's own wording kept; an answered one
1548    /// states it; and a contact spelling a token name lands literally,
1549    /// because the spans resolve after every substitution.
1550    #[test]
1551    fn the_security_spans_render_per_answer() {
1552        for forge in ["github", "gitlab"] {
1553            let bytes = embedded::SNIPPETS
1554                .get_file(format!("_shared/{forge}/SECURITY.md"))
1555                .expect("the policy ships")
1556                .contents();
1557            let authored = String::from_utf8_lossy(bytes);
1558            let stripped = {
1559                let mut text = authored.clone().into_owned();
1560                for (begin, end) in super::SECURITY_SPANS {
1561                    text = text.replace(&String::from_utf8_lossy(begin).into_owned(), "");
1562                    text = text.replace(&String::from_utf8_lossy(end).into_owned(), "");
1563                }
1564                text
1565            };
1566            let default = super::Params {
1567                forge: forge.to_owned(),
1568                ..super::Params::for_test_security("", crate::config::RESPONSE_DEFAULT)
1569            };
1570            let rendered = String::from_utf8(render(bytes, &default)).expect("text");
1571            assert_eq!(
1572                rendered,
1573                stripped.replace("RK_REPO", "acme/widget"),
1574                "{forge}: the default answers must reproduce the authored policy"
1575            );
1576            assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
1577
1578            let answered = super::Params {
1579                forge: forge.to_owned(),
1580                ..super::Params::for_test_security("OWNER RK_REPO <team@acme.example>", "14 days")
1581            };
1582            let rendered = String::from_utf8(render(bytes, &answered)).expect("text");
1583            assert!(
1584                rendered.contains("OWNER RK_REPO <team@acme.example>"),
1585                "{forge}: a contact spelling a token name lands literally: {rendered}"
1586            );
1587            assert!(
1588                rendered.contains("Maintainers acknowledge a report within 14 days."),
1589                "{forge}: {rendered}"
1590            );
1591            assert!(
1592                rendered.contains("This policy commits to no disclosure deadline."),
1593                "{forge}: {rendered}"
1594            );
1595            assert!(
1596                !rendered.contains("best-effort basis"),
1597                "{forge}: a stated window replaces the best-effort sentence: {rendered}"
1598            );
1599            assert!(
1600                !rendered.contains("no response or disclosure deadline"),
1601                "{forge}: a stated window contradicts the response disclaimer: {rendered}"
1602            );
1603        }
1604    }
1605
1606    /// A defective span leaves the bytes alone rather than producing a
1607    /// half-written sentence: the payload test above is what catches one.
1608    #[test]
1609    fn a_defective_span_renders_unchanged() {
1610        let (begin, end) = super::SECURITY_SPANS[0];
1611        let begin = String::from_utf8_lossy(begin).into_owned();
1612        let end = String::from_utf8_lossy(end).into_owned();
1613        let params = super::Params::for_test_security("team@acme.example", "1 day");
1614        for baseline in [
1615            format!("contact {begin}a maintainer\n"),
1616            format!("contact a maintainer{end}\n"),
1617            format!("contact {end}a maintainer{begin}\n"),
1618            "contact a maintainer\n".to_owned(),
1619        ] {
1620            assert_eq!(
1621                render(baseline.as_bytes(), &params),
1622                baseline.as_bytes(),
1623                "{baseline}"
1624            );
1625        }
1626    }
1627
1628    /// Every snippet destination has a declared kind: a new landable file
1629    /// without a classification fails here, not at a landing. The shared
1630    /// zone's files are enumerated the same way.
1631    #[test]
1632    fn the_kind_table_closes_over_every_snippet() {
1633        for tech_dir in embedded::SNIPPETS.dirs() {
1634            for pair_dir in tech_dir.dirs() {
1635                let prefix = format!("{}/", pair_dir.path().to_string_lossy());
1636                for (path, _) in embedded::walk(pair_dir) {
1637                    let destination = path.strip_prefix(&prefix).unwrap_or(&path);
1638                    assert!(
1639                        kind_of(destination).is_some(),
1640                        "{destination}: no declared kind"
1641                    );
1642                }
1643            }
1644        }
1645        for block in BLOCK_DESTINATIONS {
1646            assert_eq!(kind_of(block), Some(Kind::Rendered), "{block}");
1647        }
1648        assert_eq!(kind_of("something-else.txt"), None);
1649    }
1650
1651    /// Substitution is total and derives from the repo parameter's first
1652    /// segment, so a nested GitLab project path still yields its root
1653    /// namespace. The scope shape rests on no parameter, so it renders
1654    /// under every landing.
1655    #[test]
1656    fn rendering_substitutes_every_owner_occurrence() {
1657        let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
1658        let rendered = render(baseline, &super::Params::for_test("acme/sub/widget", None));
1659        let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1660        assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
1661
1662        let baseline = b"match (RK_SCOPE_SHAPE)\n";
1663        let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
1664        let text = String::from_utf8(rendered).expect("rendered bytes stay text");
1665        assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
1666    }
1667
1668    /// The one scope shape is a bracket expression an extended regular
1669    /// expression takes verbatim: lowercase, and with the `-` last, where
1670    /// it stands for itself rather than opening a range.
1671    #[test]
1672    fn the_scope_shape_drops_into_the_title_check() {
1673        assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
1674        assert!(
1675            !SCOPE_SHAPE.contains('\''),
1676            "the title checks single-quote it"
1677        );
1678    }
1679
1680    /// The predicate `rk message --check` calls and the pattern the title
1681    /// checks render admit exactly the same characters. The pattern is
1682    /// expanded here from its own text, so editing one owner without the
1683    /// other fails: the desk and the forge judge one language.
1684    #[test]
1685    fn the_scope_predicate_and_the_rendered_pattern_agree() {
1686        let body = SCOPE_SHAPE
1687            .strip_prefix('[')
1688            .and_then(|rest| rest.strip_suffix("]+"))
1689            .expect("the shape is one bracket expression, repeated");
1690        let chars: Vec<char> = body.chars().collect();
1691        let mut admitted = std::collections::BTreeSet::new();
1692        let mut at = 0;
1693        while at < chars.len() {
1694            // A `-` with a neighbour on each side opens a range; last, it
1695            // stands for itself, which is why the shape ends with it.
1696            if at + 2 < chars.len() && chars[at + 1] == '-' {
1697                for c in chars[at]..=chars[at + 2] {
1698                    admitted.insert(c);
1699                }
1700                at += 3;
1701            } else {
1702                admitted.insert(chars[at]);
1703                at += 1;
1704            }
1705        }
1706        for byte in 0..=127u8 {
1707            let c = char::from(byte);
1708            assert_eq!(
1709                super::scope_is_shaped(&c.to_string()),
1710                admitted.contains(&c),
1711                "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
1712            );
1713        }
1714        assert!(super::scope_is_shaped("guides/release"));
1715        assert!(!super::scope_is_shaped(""), "a scope is never empty");
1716        assert!(!super::scope_is_shaped("Specs Ugly"));
1717    }
1718
1719    /// The shared zone composes into every pair, lands first, and is
1720    /// absent from the technology listing an unknown tech names.
1721    #[test]
1722    fn the_shared_zone_composes_into_the_pair() {
1723        let files = pair_files("rust", "github").expect("the pair lists");
1724        assert!(
1725            files
1726                .iter()
1727                .any(|(dest, _)| dest == ".github/workflows/pr-title.yml"),
1728            "the shared title check lands with the pair"
1729        );
1730        let files = pair_files("rust", "gitlab").expect("the pair lists");
1731        assert!(
1732            files
1733                .iter()
1734                .any(|(dest, _)| dest == ".gitlab/ci/mr-title.yml"),
1735            "the shared title job lands with the pair"
1736        );
1737        let err = pair_files("_shared", "github").expect_err("the shared zone is no tech");
1738        let listing = err.to_string();
1739        let bindings = listing
1740            .split("the bindings are:")
1741            .nth(1)
1742            .expect("the refusal lists the bindings");
1743        assert!(!bindings.contains("_shared"), "{listing}");
1744    }
1745
1746    /// A loaded record reaches the projection unchanged, including old
1747    /// records' absent style and the two workflow modes.
1748    #[test]
1749    fn params_from_a_record_round_trips() {
1750        use super::{Params, manifest};
1751        let dir = tempfile::tempdir().expect("a scratch target exists");
1752        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1753        for tech in ["rust", "bash"] {
1754            for forge in ["github", "gitlab"] {
1755                for workflow in [Workflow::Branches, Workflow::Worktree] {
1756                    for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
1757                        for nix in [false, true] {
1758                            let record = manifest::Manifest {
1759                                schema_version: manifest::SCHEMA_VERSION,
1760                                rk_version: "0.1.0".to_owned(),
1761                                payload_sha256: crate::digest::Digest::of(b""),
1762                                origin: "init".to_owned(),
1763                                tech: tech.to_owned(),
1764                                forge: forge.to_owned(),
1765                                landed_at: "2026-08-29T00:00:00Z".to_owned(),
1766                                parameters: manifest::Parameters {
1767                                    repo: "acme/team/widget".to_owned(),
1768                                    workflow,
1769                                    style,
1770                                    nix,
1771                                    trunk: crate::config::TRUNK_DEFAULT.to_owned(),
1772                                    line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
1773                                    security_contact: String::new(),
1774                                    security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
1775                                },
1776                                files: Vec::new(),
1777                                pins: std::collections::BTreeMap::new(),
1778                            };
1779                            manifest::write(target, &record).expect("the record writes");
1780                            let loaded = manifest::load(target)
1781                                .expect("the record loads")
1782                                .expect("the record exists");
1783                            let params = Params::from_record(&loaded);
1784                            assert_eq!(params.tech, tech);
1785                            assert_eq!(params.forge, forge);
1786                            assert_eq!(params.repo(), "acme/team/widget");
1787                            assert_eq!(params.workflow(), workflow);
1788                            assert_eq!(params.style(), style);
1789                            assert_eq!(params.nix, nix);
1790                            let entries = projection(&params).expect("the record projects");
1791                            let mut expected: Vec<_> = pair_files(tech, forge)
1792                                .expect("the pair lists")
1793                                .into_iter()
1794                                .filter(|(path, _)| {
1795                                    nix || !super::NIX_DESTINATIONS.contains(&path.as_str())
1796                                })
1797                                .collect();
1798                            let routing = routing_block(workflow);
1799                            let hooks = hooks_block(workflow);
1800                            let glossary = glossary_block();
1801                            expected.push((AGENTS_DESTINATION.to_owned(), routing.into_bytes()));
1802                            expected.push((GLOSSARY_DESTINATION.to_owned(), glossary.into_bytes()));
1803                            expected.push((HOOKS_DESTINATION.to_owned(), hooks.into_bytes()));
1804                            expected.sort_by(|a, b| a.0.cmp(&b.0));
1805                            assert_eq!(entries.len(), expected.len());
1806                            for (entry, (destination, baseline)) in entries.iter().zip(expected) {
1807                                assert_eq!(entry.destination, destination);
1808                                assert_eq!(entry.baseline, baseline);
1809                                let rendered = match entry.kind {
1810                                    Kind::Rendered => super::render(
1811                                        &baseline,
1812                                        &super::Params::for_test("acme/team/widget", style),
1813                                    ),
1814                                    Kind::Seeded | Kind::State => baseline.clone(),
1815                                };
1816                                assert_eq!(entry.rendered, rendered, "{destination}");
1817                            }
1818                        }
1819                    }
1820                }
1821            }
1822        }
1823    }
1824
1825    fn resolved_test_params(
1826        tech: &str,
1827        resolved: &super::Resolved,
1828        workflow: Workflow,
1829        style: Option<Style>,
1830        nix: bool,
1831    ) -> Result<super::Params, crate::error::RkError> {
1832        super::Params::resolve(
1833            &SOURCE,
1834            camino::Utf8Path::new("."),
1835            &super::Inputs {
1836                tech: Some(tech),
1837                forge: Some(&resolved.forge),
1838                repo: resolved.repo.as_deref(),
1839                workflow: Some(workflow),
1840                style,
1841                nix: Some(nix),
1842            },
1843            None,
1844            None,
1845            super::Purpose::Init,
1846        )
1847    }
1848
1849    /// A rendered projection carries no unsubstituted token and no
1850    /// mechanical sentinel; the one judgment sentinel stays in its seeded
1851    /// file.
1852    #[test]
1853    fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
1854        let entries = projection(
1855            &resolved_test_params(
1856                "rust",
1857                &super::Resolved {
1858                    forge: "github".to_owned(),
1859                    repo: Some("acme/widget".to_owned()),
1860                },
1861                Workflow::Branches,
1862                Some(Style::Trunk),
1863                false,
1864            )
1865            .expect("the parameters resolve"),
1866        )
1867        .expect("the pair projects");
1868        let workflow = entries
1869            .iter()
1870            .find(|entry| entry.destination.ends_with("release-plz.yml"))
1871            .expect("the workflow projects");
1872        assert_eq!(workflow.kind, Kind::Rendered);
1873        let text = String::from_utf8_lossy(&workflow.rendered);
1874        assert!(!text.contains("OWNER"), "an owner token survived rendering");
1875        assert!(text.contains("'acme'"));
1876        assert!(!text.contains("TODO(release-kit)"));
1877        let title = entries
1878            .iter()
1879            .find(|entry| entry.destination.ends_with("pr-title.yml"))
1880            .expect("the title check projects");
1881        let text = String::from_utf8_lossy(&title.rendered);
1882        assert!(text.contains(SCOPE_SHAPE), "{text}");
1883        assert!(
1884            !text.contains("RK_SCOPE_SHAPE"),
1885            "a scope token survived: {text}"
1886        );
1887        let seeded = entries
1888            .iter()
1889            .find(|entry| entry.destination == "release-plz.toml")
1890            .expect("the seeded file projects");
1891        assert_eq!(seeded.kind, Kind::Seeded);
1892        assert_eq!(seeded.rendered, seeded.baseline);
1893        assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
1894        for block in BLOCK_DESTINATIONS {
1895            let entry = entries
1896                .iter()
1897                .find(|entry| entry.destination == block)
1898                .expect("every block is part of the projection");
1899            let text = String::from_utf8_lossy(&entry.rendered);
1900            assert!(
1901                !text.contains("RK_SCOPE_SHAPE"),
1902                "{block} kept a token: {text}"
1903            );
1904        }
1905    }
1906
1907    /// The Nix destinations project only under the opt-in: off, none of
1908    /// them appears; on, the rust pairs carry them — the gitlab pair too,
1909    /// minus the workflow, which is a forge file the gitlab payload does
1910    /// not ship — and a pair without them projects the smaller product.
1911    #[test]
1912    fn the_nix_destinations_project_only_under_the_opt_in() {
1913        use super::NIX_DESTINATIONS;
1914        let paths = |nix: bool, forge: &str| -> Vec<String> {
1915            projection(
1916                &resolved_test_params(
1917                    "rust",
1918                    &super::Resolved {
1919                        forge: forge.to_owned(),
1920                        repo: Some("acme/widget".to_owned()),
1921                    },
1922                    Workflow::Worktree,
1923                    Some(Style::Trunk),
1924                    nix,
1925                )
1926                .expect("the parameters resolve"),
1927            )
1928            .expect("the pair projects")
1929            .into_iter()
1930            .map(|entry| entry.destination)
1931            .collect()
1932        };
1933        let off = paths(false, "github");
1934        for destination in NIX_DESTINATIONS {
1935            assert!(!off.contains(&destination.to_owned()), "{destination}");
1936        }
1937        let on = paths(true, "github");
1938        for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
1939            assert!(on.contains(&destination.to_owned()), "{destination}");
1940        }
1941        // The capability lands no workflow, so both forges land the same
1942        // set: a job proving the build holds a merge only inside the
1943        // workflow the required check needs, and that one is the
1944        // target's own.
1945        let gitlab = paths(true, "gitlab");
1946        assert!(gitlab.contains(&"nix/package.nix".to_owned()));
1947        assert!(
1948            !on.iter()
1949                .chain(gitlab.iter())
1950                .any(|destination| destination.contains("nix.yml"))
1951        );
1952        let bash = projection(
1953            &resolved_test_params(
1954                "bash",
1955                &super::Resolved {
1956                    forge: "github".to_owned(),
1957                    repo: Some("acme/widget".to_owned()),
1958                },
1959                Workflow::Worktree,
1960                Some(Style::Trunk),
1961                true,
1962            )
1963            .expect("the parameters resolve"),
1964        )
1965        .expect("an out-of-matrix pair projects the smaller product");
1966        assert!(
1967            bash.iter()
1968                .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
1969        );
1970    }
1971
1972    /// The github and gitlab copies of the forge-independent Nix payload
1973    /// stay byte-identical: the loader composes exactly two layers and has
1974    /// no technology-wide zone, so the duplication is deliberate and this
1975    /// parity test is what keeps it honest.
1976    #[test]
1977    fn the_nix_seeds_are_identical_across_forge_pairs() {
1978        for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
1979            let github = embedded::SNIPPETS
1980                .get_file(format!("rust/github/{name}"))
1981                .expect("the github copy ships")
1982                .contents();
1983            let gitlab = embedded::SNIPPETS
1984                .get_file(format!("rust/gitlab/{name}"))
1985                .expect("the gitlab copy ships")
1986                .contents();
1987            assert_eq!(github, gitlab, "{name} diverged between the pairs");
1988        }
1989    }
1990
1991    /// The withhold judgment: a flake pair of the target's own withholds
1992    /// the pair and the workflow while the package expression lands, a
1993    /// crate shape the seed does not support withholds everything, and a
1994    /// clean single-crate target withholds nothing.
1995    #[test]
1996    fn the_nix_withhold_judgment_covers_the_three_shapes() {
1997        use super::{NIX_DESTINATIONS, withhold_nix};
1998        let dir = tempfile::tempdir().expect("a scratch target exists");
1999        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
2000        let entries = || {
2001            projection(
2002                &resolved_test_params(
2003                    "rust",
2004                    &super::Resolved {
2005                        forge: "github".to_owned(),
2006                        repo: Some("acme/widget".to_owned()),
2007                    },
2008                    Workflow::Worktree,
2009                    Some(Style::Trunk),
2010                    true,
2011                )
2012                .expect("the parameters resolve"),
2013            )
2014            .expect("the pair projects")
2015        };
2016
2017        // No Cargo.toml: the whole capability is withheld by name.
2018        let mut all = entries();
2019        let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
2020        let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
2021        assert_eq!(paths, ["flake.lock", "flake.nix", "nix/package.nix"]);
2022        assert!(
2023            all.iter()
2024                .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
2025        );
2026
2027        // A single crate with its own flake: the seed pair is withheld,
2028        // and the package expression still lands.
2029        std::fs::write(
2030            target.join("Cargo.toml"),
2031            "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
2032        )
2033        .expect("the crate manifest writes");
2034        std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
2035        std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
2036        std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
2037        std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
2038        let mut all = entries();
2039        let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
2040        let paths: Vec<&str> = withheld.iter().map(|w| w.path.as_str()).collect();
2041        assert_eq!(paths, ["flake.lock", "flake.nix"]);
2042        assert!(
2043            all.iter()
2044                .any(|entry| entry.destination == "nix/package.nix")
2045        );
2046
2047        // A clean single crate: nothing is withheld.
2048        std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
2049        let mut all = entries();
2050        let withheld = withhold_nix(target, true, None, &mut all).expect("the judgment runs");
2051        assert!(withheld.is_empty());
2052        assert!(all.iter().any(|entry| entry.destination == "flake.nix"));
2053
2054        // Off, the judgment does not even look.
2055        let mut all = entries();
2056        let withheld = withhold_nix(target, false, None, &mut all).expect("the judgment runs");
2057        assert!(withheld.is_empty());
2058    }
2059
2060    /// The glossary takes the same three shapes the routing block does,
2061    /// and the marker pair it shares with `AGENTS.md` is what makes one
2062    /// splice serve both.
2063    #[test]
2064    fn the_glossary_splices_into_every_shape() {
2065        let owned = glossary_block();
2066        let block = owned.as_str();
2067
2068        let fresh = spliced(None, block);
2069        assert_eq!(fresh, format!("{block}\n"));
2070        assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
2071
2072        let own = "# Glossary\n\n- `spike` — a throwaway branch.\n";
2073        let appended = spliced(Some(own), block);
2074        assert!(appended.starts_with(own));
2075        assert_eq!(
2076            extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
2077            Some(block)
2078        );
2079
2080        let stale = appended.replace("full-implement", "do-everything");
2081        let refreshed = spliced(Some(&stale), block);
2082        assert_eq!(
2083            extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
2084            Some(block)
2085        );
2086        assert_eq!(
2087            refreshed.matches("BEGIN release-kit").count(),
2088            1,
2089            "a re-splice must replace, not accumulate"
2090        );
2091    }
2092
2093    /// Every line the target wrote below the end marker survives a
2094    /// re-splice byte for byte: the block owns its marked lines and the
2095    /// document belongs to the target.
2096    #[test]
2097    fn the_glossary_leaves_the_targets_region_alone() {
2098        let owned = glossary_block();
2099        let block = owned.as_str();
2100        let below = "\n## Our own terms\n\n- `spike` — a throwaway branch, never merged.\n";
2101        let landed = format!("{block}\n{below}");
2102
2103        let refreshed = spliced(Some(&landed), block);
2104        assert!(
2105            refreshed.ends_with(below),
2106            "the target's own region changed: {refreshed}"
2107        );
2108        assert_eq!(
2109            extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
2110            Some(block)
2111        );
2112    }
2113
2114    /// Appending keeps the document whole: trailing spaces, blank lines,
2115    /// and a missing final newline are the target's bytes, and a block
2116    /// that owns its marked lines alone rewrites none of them.
2117    #[test]
2118    fn an_append_rewrites_no_byte_the_target_wrote() {
2119        let owned = glossary_block();
2120        let block = owned.as_str();
2121        for own in [
2122            "# Glossary\n\n- `spike` — throwaway.   \n\n\n",
2123            "# Glossary\n\n- `spike` — throwaway.",
2124            "# Glossary\r\n\r\n- `spike` — throwaway.\r\n",
2125        ] {
2126            let appended = spliced(Some(own), block);
2127            assert!(
2128                appended.starts_with(own),
2129                "the target's bytes changed: {appended:?}"
2130            );
2131            assert_eq!(
2132                extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
2133                Some(block),
2134                "{appended:?}"
2135            );
2136            let marker = appended.find(BLOCK_BEGIN).expect("the block landed");
2137            assert!(
2138                appended[..marker].ends_with('\n'),
2139                "the block must open its own line: {appended:?}"
2140            );
2141        }
2142    }
2143
2144    /// A document the target wrote is bytes, not text. A splice that
2145    /// decoded it would replace an invalid sequence with U+FFFD and
2146    /// rewrite a byte outside the markers, which the rule forbids.
2147    #[test]
2148    fn a_splice_decodes_no_byte_the_target_wrote() {
2149        let owned = glossary_block();
2150        let block = owned.as_str();
2151
2152        // Appending: the invalid byte sits in the target's own document.
2153        let own = b"# Glossary\n\ncaf\xe9\n";
2154        let appended = splice_marked_block(Some(own), block);
2155        assert!(
2156            appended.starts_with(own),
2157            "the target's bytes changed: {appended:?}"
2158        );
2159        assert!(!appended.contains(&0xEF), "a replacement character landed");
2160
2161        // Replacing: the invalid byte sits below the end marker.
2162        let mut landed = Vec::new();
2163        landed.extend_from_slice(block.replace("full-implement", "do-everything").as_bytes());
2164        landed.extend_from_slice(b"\n\ncaf\xe9\n");
2165        let refreshed = splice_marked_block(Some(&landed), block);
2166        assert!(
2167            refreshed.ends_with(b"\n\ncaf\xe9\n"),
2168            "the target's region below the markers changed: {refreshed:?}"
2169        );
2170        assert!(refreshed.starts_with(block.as_bytes()), "{refreshed:?}");
2171    }
2172
2173    /// The glossary carries no parameter, so the same bytes land in
2174    /// every target: no token survives it and no mode changes it.
2175    #[test]
2176    fn the_glossary_block_carries_no_parameter() {
2177        let block = glossary_block();
2178        assert!(block.starts_with(BLOCK_BEGIN), "{block}");
2179        assert!(block.ends_with(BLOCK_END), "{block}");
2180        assert!(!block.contains("RK_"), "a token survived: {block}");
2181        assert!(!block.contains("OWNER"), "an owner token survived: {block}");
2182        for term in [
2183            "implement-and-request",
2184            "implement-and-merge",
2185            "full-implement",
2186        ] {
2187            assert!(block.contains(term), "{term} is missing from {block}");
2188        }
2189        assert!(
2190            routing_block(Workflow::Worktree).contains(GLOSSARY_DESTINATION),
2191            "the routing block must name the destination it indexes"
2192        );
2193    }
2194
2195    #[test]
2196    fn the_block_splices_into_every_agents_shape() {
2197        let owned = routing_block(Workflow::Branches);
2198        let block = owned.as_str();
2199        let fresh = spliced(None, block);
2200        assert_eq!(fresh, format!("{block}\n"));
2201        assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
2202
2203        let appended = spliced(Some("# My project\n\nOwn rules.\n"), block);
2204        assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
2205        assert_eq!(
2206            extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
2207            Some(block)
2208        );
2209
2210        let stale = appended.replace("Never author a tag", "Do author a tag");
2211        let refreshed = spliced(Some(&stale), block);
2212        assert_eq!(
2213            extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
2214            Some(block)
2215        );
2216        assert!(refreshed.starts_with("# My project"));
2217        assert_eq!(
2218            refreshed.matches("BEGIN release-kit").count(),
2219            1,
2220            "a re-splice must replace, not accumulate"
2221        );
2222    }
2223
2224    /// The hook block lands under `repos:` in every honest shape and
2225    /// refuses the one dishonest shape by name.
2226    #[test]
2227    fn the_hook_block_splices_under_repos() {
2228        let owned = hooks_block(Workflow::Branches);
2229        let block = owned.as_str();
2230        let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
2231        assert!(fresh.starts_with(HOOK_TYPES_LINE));
2232        assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
2233        assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
2234
2235        let own =
2236            "repos:\n  - repo: https://example.com/own\n    rev: v1\n    hooks:\n      - id: own\n";
2237        let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
2238        assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
2239        assert!(spliced.contains("- id: own"), "the target's hooks survive");
2240        assert!(
2241            !spliced.contains(HOOK_TYPES_LINE),
2242            "an existing file's top level is the skills' duty, not the splice's"
2243        );
2244
2245        let stale = spliced.replace("--force-scope", "--no-scope");
2246        let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
2247        assert_eq!(
2248            extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
2249            Some(block)
2250        );
2251        assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
2252
2253        let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
2254            .expect_err("no repos: line refuses");
2255        assert!(err.contains("repos:"), "{err}");
2256
2257        // The hooks between the markers execute, so ownership is exactly
2258        // one well-formed block: a duplicate or an unmatched marker
2259        // refuses rather than leaving a stale block active.
2260        let doubled = format!("repos:\n{block}\n{block}\n");
2261        let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
2262        assert!(err.contains("one block"), "{err}");
2263        let unmatched = "repos:\n# BEGIN release-kit\n  - repo: local\n";
2264        let err =
2265            splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
2266        assert!(err.contains("unmatched"), "{err}");
2267    }
2268
2269    /// Both modes of both blocks: the guard entry and the skip pair exist
2270    /// exactly in the worktree mode, one orientation line differs in the
2271    /// routing block, the rest is byte-identical, no mode token survives
2272    /// substitution, and the rendered grammar is [`BRANCH_GRAMMAR`], the
2273    /// one owner.
2274    #[test]
2275    fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
2276        let worktree_hooks = hooks_block(Workflow::Worktree);
2277        let branches_hooks = hooks_block(Workflow::Branches);
2278        assert!(worktree_hooks.contains("- id: rk-worktree-location"));
2279        assert!(
2280            worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
2281            "{worktree_hooks}"
2282        );
2283        assert!(!branches_hooks.contains("rk-worktree-location"));
2284        assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
2285        for block in [&worktree_hooks, &branches_hooks] {
2286            assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
2287            for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
2288                assert!(!block.contains(token), "{token} survived: {block}");
2289            }
2290        }
2291        // A hook entry renders as a YAML plain scalar, where a colon
2292        // followed by a space ends the scalar and breaks the whole file
2293        // — the defect dogfood caught in the guard's refusal messages —
2294        // so no entry value may carry one.
2295        for block in [&worktree_hooks, &branches_hooks] {
2296            for line in block.lines() {
2297                if let Some(value) = line.trim_start().strip_prefix("entry: ") {
2298                    assert!(
2299                        !value.contains(": "),
2300                        "an entry value breaks the YAML plain scalar: {line}"
2301                    );
2302                }
2303            }
2304        }
2305        let guard_line = worktree_hooks
2306            .lines()
2307            .position(|line| line.contains("id: rk-worktree-location"))
2308            .expect("the guard entry exists");
2309        let name_line = worktree_hooks
2310            .lines()
2311            .position(|line| line.contains("id: rk-branch-name"))
2312            .expect("the name hook exists");
2313        assert!(
2314            guard_line > name_line,
2315            "the guard lands directly after rk-branch-name"
2316        );
2317
2318        let worktree_routing = routing_block(Workflow::Worktree);
2319        let branches_routing = routing_block(Workflow::Branches);
2320        assert!(worktree_routing.contains("This project works in worktrees"));
2321        assert!(branches_routing.contains("Branches are worked in the main checkout"));
2322        for block in [&worktree_routing, &branches_routing] {
2323            assert!(block.contains("Create or remove a worktree"));
2324            assert!(block.contains("`rk worktree add <branch>`"));
2325            assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
2326        }
2327        let differing: Vec<(&str, &str)> = worktree_routing
2328            .lines()
2329            .zip(branches_routing.lines())
2330            .filter(|(a, b)| a != b)
2331            .collect();
2332        assert_eq!(
2333            differing.len(),
2334            1,
2335            "exactly one routing line differs per mode: {differing:?}"
2336        );
2337    }
2338
2339    /// One definition of an ill-formed hook file, for every reader: the
2340    /// well-formed shapes pass and each ambiguous shape names a defect.
2341    #[test]
2342    fn the_hook_marker_defects_are_named() {
2343        use super::hooks_marker_defect;
2344        let owned = hooks_block(Workflow::Branches);
2345        let block = owned.as_str();
2346        assert_eq!(hooks_marker_defect(""), None);
2347        assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
2348        for (case, text) in [
2349            (
2350                "a second begin",
2351                format!("repos:\n{block}\n# BEGIN release-kit\n"),
2352            ),
2353            (
2354                "a second end",
2355                format!("repos:\n{block}\n# END release-kit\n"),
2356            ),
2357            (
2358                "an unpaired begin",
2359                "repos:\n# BEGIN release-kit\n".to_owned(),
2360            ),
2361            ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
2362            (
2363                "an end before its begin",
2364                "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
2365            ),
2366        ] {
2367            assert!(
2368                hooks_marker_defect(&text).is_some(),
2369                "{case} must be a defect"
2370            );
2371        }
2372    }
2373}