Skip to main content

release_kit/
landing.rs

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