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 manifest;
13
14use camino::Utf8Path;
15use serde::{Deserialize, Serialize};
16
17use crate::diagnostic::{Diagnostic, Reason};
18use crate::error::RkError;
19use crate::{atomic, embedded};
20
21/// Who owns a landed file's bytes after landing.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum Kind {
25    /// release-kit owns it: a newer payload re-renders it, and a target
26    /// edit is a conflict.
27    Rendered,
28    /// The target owns it: a starting point the project tunes, reported
29    /// and never rewritten.
30    Seeded,
31    /// The release automation owns it: never written after the first
32    /// landing, never compared.
33    State,
34}
35
36impl Kind {
37    /// The wire and report form.
38    #[must_use]
39    pub const fn as_str(self) -> &'static str {
40        match self {
41            Self::Rendered => "rendered",
42            Self::Seeded => "seeded",
43            Self::State => "state",
44        }
45    }
46}
47
48/// The declared classification: every landable destination and its kind.
49/// The workflow and pipeline files carry the release automation and the
50/// OIDC permission, so release-kit owns them; the tool configurations are
51/// per-project judgment; the two state files are rewritten by the release
52/// automation itself.
53const KINDS: [(&str, Kind); 10] = [
54    (".github/workflows/release-plz.yml", Kind::Rendered),
55    (".github/workflows/release-please.yml", Kind::Rendered),
56    (".github/workflows/release.yml", Kind::Rendered),
57    (".gitlab-ci.yml", Kind::Rendered),
58    ("release-plz.toml", Kind::Seeded),
59    ("dist-workspace.toml", Kind::Seeded),
60    ("release-please-config.json", Kind::Seeded),
61    ("cliff.toml", Kind::Seeded),
62    (".release-please-manifest.json", Kind::State),
63    ("VERSION", Kind::State),
64];
65
66/// The declared kind of a destination, or `None` for a file the payload
67/// does not classify.
68#[must_use]
69pub fn kind_of(destination: &str) -> Option<Kind> {
70    if destination == AGENTS_DESTINATION {
71        return Some(Kind::Rendered);
72    }
73    KINDS
74        .iter()
75        .find(|(name, _)| *name == destination)
76        .map(|(_, kind)| *kind)
77}
78
79/// The mechanical substitution site in `rendered` files.
80///
81/// One known value, substituted identically everywhere it appears. The
82/// owner is derived from the landing's `repo` parameter, so the landed
83/// bytes stay a deterministic function of payload plus parameters.
84pub const OWNER_TOKEN: &[u8] = b"OWNER";
85
86/// Substitute the landing parameters into a `rendered` file's bytes: the
87/// repository's owner — the project path's first segment — replaces every
88/// `OWNER` occurrence.
89#[must_use]
90pub fn render(baseline: &[u8], repo: &str) -> Vec<u8> {
91    let owner = repo.split('/').next().unwrap_or(repo).as_bytes();
92    let mut out = Vec::with_capacity(baseline.len());
93    let mut rest = baseline;
94    while let Some(at) = find(rest, OWNER_TOKEN) {
95        out.extend_from_slice(&rest[..at]);
96        out.extend_from_slice(owner);
97        rest = &rest[at + OWNER_TOKEN.len()..];
98    }
99    out.extend_from_slice(rest);
100    out
101}
102
103/// First occurrence of `needle` in `haystack`.
104fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
105    haystack
106        .windows(needle.len())
107        .position(|window| window == needle)
108}
109
110/// The destination the routing block splices into.
111pub const AGENTS_DESTINATION: &str = "AGENTS.md";
112
113/// The block's opening marker.
114pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
115
116/// The block's closing marker.
117pub const BLOCK_END: &str = "<!-- END release-kit -->";
118
119/// The routing block: the whole of target-side governance. Four lines of
120/// operational discovery — the files are owned, a convention governs
121/// them, and where the convention lives — spliced into the target's
122/// `AGENTS.md` and never grown into a method chapter.
123const ROUTING_BLOCK: &str = "<!-- BEGIN release-kit -->
124
125## Releases
126
127- This repository runs the release-kit convention; `rk method invariants` states what must stay true.
128- Never author a tag, and never hand-edit a generated artifact workflow.
129- Run `rk status` before changing anything under `.github/workflows/` or `.gitlab-ci.yml`, or any file `.release-kit/manifest.json` names.
130- The full method is `rk method --list`; the recovery paths are `rk method recovery`.
131
132<!-- END release-kit -->";
133
134/// The routing block, markers included, without a trailing newline.
135#[must_use]
136pub const fn routing_block() -> &'static str {
137    ROUTING_BLOCK
138}
139
140/// The marked block inside a target's `AGENTS.md`, markers included, or
141/// `None` where the file carries no complete block.
142#[must_use]
143pub fn extract_block(text: &str) -> Option<&str> {
144    let start = text.find(BLOCK_BEGIN)?;
145    let end = text[start..].find(BLOCK_END)? + start + BLOCK_END.len();
146    Some(&text[start..end])
147}
148
149/// The whole `AGENTS.md` content after splicing the block.
150///
151/// A fresh file where none exists, the block replaced in place where one
152/// is marked, appended after the target's own content otherwise —
153/// release-kit owns the lines inside the markers, not the document.
154#[must_use]
155pub fn splice_block(existing: Option<&str>) -> String {
156    existing.map_or_else(
157        || format!("{ROUTING_BLOCK}\n"),
158        |text| {
159            extract_block(text).map_or_else(
160                || format!("{}\n\n{ROUTING_BLOCK}\n", text.trim_end()),
161                |found| text.replacen(found, ROUTING_BLOCK, 1),
162            )
163        },
164    )
165}
166
167/// How a projected artifact occupies its destination.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum Placement {
170    /// The artifact is the whole file.
171    Whole,
172    /// The artifact is the marked block inside the target's `AGENTS.md`.
173    Block,
174}
175
176/// One artifact of the payload projection: what would land at one
177/// destination, with the payload bytes it was rendered from.
178#[derive(Debug)]
179pub struct Entry {
180    /// The destination, relative to the target root.
181    pub destination: String,
182    /// The declared kind.
183    pub kind: Kind,
184    /// Whole file, or the marked block.
185    pub placement: Placement,
186    /// The payload bytes before substitution — what `baseline_sha256`
187    /// digests.
188    pub baseline: Vec<u8>,
189    /// The bytes a landing writes: substituted for `rendered` files,
190    /// identical to the baseline otherwise.
191    pub rendered: Vec<u8>,
192}
193
194/// The landable files of one `(technology, forge)` pair, as
195/// `(destination, payload bytes)`.
196///
197/// # Errors
198///
199/// Returns [`RkError::Usage`] naming the known bindings for an unknown
200/// technology, and the supported pairs for a pair with no files.
201pub fn pair_files(tech: &str, forge: &str) -> Result<Vec<(String, &'static [u8])>, RkError> {
202    embedded::SNIPPETS.get_dir(tech).ok_or_else(|| {
203        let known: Vec<String> = embedded::SNIPPETS
204            .dirs()
205            .map(|dir| dir.path().to_string_lossy().into_owned())
206            .collect();
207        RkError::Usage(format!(
208            "unknown tech '{tech}'; the bindings are: {}",
209            known.join(", ")
210        ))
211    })?;
212    let pair = format!("{tech}/{forge}");
213    let pair_dir = embedded::SNIPPETS.get_dir(&pair).ok_or_else(|| {
214        let known: Vec<String> = embedded::SNIPPETS
215            .dirs()
216            .flat_map(include_dir::Dir::dirs)
217            .map(|dir| dir.path().to_string_lossy().replace('/', ", "))
218            .collect();
219        RkError::Usage(format!(
220            "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
221            known.join("; ")
222        ))
223    })?;
224    // Payload paths carry the `<tech>/<forge>/` prefix; destinations do not.
225    Ok(embedded::walk(pair_dir)
226        .into_iter()
227        .map(|(path, contents)| {
228            let rel = path
229                .strip_prefix(&format!("{pair}/"))
230                .map_or(path.as_str(), |rel| rel)
231                .to_owned();
232            (rel, contents)
233        })
234        .collect())
235}
236
237/// The whole payload projection for one pair under one `repo` parameter:
238/// every snippet with its kind and rendered bytes, plus the routing
239/// block, sorted by destination.
240///
241/// # Errors
242///
243/// Returns the [`pair_files`] errors, and [`RkError::Other`] for a
244/// snippet destination the kind table does not classify, which is a
245/// defect in this binary.
246pub fn projection(tech: &str, forge: &str, repo: &str) -> Result<Vec<Entry>, RkError> {
247    let mut entries = Vec::new();
248    for (destination, baseline) in pair_files(tech, forge)? {
249        let kind = kind_of(&destination).ok_or_else(|| {
250            anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
251        })?;
252        let rendered = match kind {
253            Kind::Rendered => render(baseline, repo),
254            Kind::Seeded | Kind::State => baseline.to_vec(),
255        };
256        entries.push(Entry {
257            destination,
258            kind,
259            placement: Placement::Whole,
260            baseline: baseline.to_vec(),
261            rendered,
262        });
263    }
264    entries.push(Entry {
265        destination: AGENTS_DESTINATION.to_owned(),
266        kind: Kind::Rendered,
267        placement: Placement::Block,
268        baseline: ROUTING_BLOCK.as_bytes().to_vec(),
269        rendered: ROUTING_BLOCK.as_bytes().to_vec(),
270    });
271    entries.sort_by(|a, b| a.destination.cmp(&b.destination));
272    Ok(entries)
273}
274
275/// The bytes an entry's destination currently holds: the whole file, or
276/// the marked block extracted from the target's `AGENTS.md`. `None` means
277/// the file — or the block — is absent.
278///
279/// # Errors
280///
281/// Any read failure other than the file being absent.
282pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
283    read_recorded(target, &entry.destination)
284}
285
286/// The bytes a recorded destination currently holds, by the placement its
287/// name implies: the marked block for `AGENTS.md`, the whole file
288/// otherwise. `None` means the file — or the block — is absent.
289///
290/// # Errors
291///
292/// Any read failure other than the file being absent.
293pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
294    let path = target.join(destination);
295    let bytes = match std::fs::read(&path) {
296        Ok(bytes) => bytes,
297        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
298        Err(e) => return Err(e),
299    };
300    if destination == AGENTS_DESTINATION {
301        let text = String::from_utf8_lossy(&bytes);
302        Ok(extract_block(&text).map(|block| block.as_bytes().to_vec()))
303    } else {
304        Ok(Some(bytes))
305    }
306}
307
308/// What one detection pass resolved for a target-side verb, with the
309/// override flags applied.
310#[derive(Debug)]
311pub struct Resolved {
312    /// The forge whose payload applies.
313    pub forge: String,
314    /// The project path, where a flag or the remote names one.
315    pub repo: Option<String>,
316}
317
318/// Resolve forge and repository in one pass: the flags override, the
319/// `origin` remote answers otherwise.
320///
321/// An unrecognized host refuses rather than defaulting — landing one
322/// forge's files into the other forge's project is a half-configured
323/// repository that looks done.
324///
325/// # Errors
326///
327/// Returns [`RkError::Usage`] for an unknown `--forge` value, and a
328/// refusal naming the override when no forge resolves.
329pub fn resolve(
330    target: &Utf8Path,
331    forge_flag: Option<&str>,
332    repo_flag: Option<&str>,
333) -> Result<Resolved, RkError> {
334    let forge_flag = forge_flag
335        .map(|name| {
336            crate::detect::Forge::parse(name).ok_or_else(|| {
337                RkError::Usage(format!(
338                    "unknown forge '{name}'; the forges are: github, gitlab"
339                ))
340            })
341        })
342        .transpose()?;
343    let detected = crate::detect::detect(target.as_std_path());
344    let forge = forge_flag
345        .or(detected.forge)
346        .map(|forge| forge.as_str().to_owned())
347        .ok_or_else(|| {
348            let message = detected.host.map_or_else(
349                || "no forge detected: the target has no origin remote".to_owned(),
350                |host| format!("no forge detected: the host {host} is not recognized"),
351            );
352            RkError::refusal(
353                Diagnostic::new(Reason::ForgeUndetected, message)
354                    .expected("a github.com or gitlab remote, or --forge")
355                    .action("pass --forge <github|gitlab>"),
356            )
357        })?;
358    Ok(Resolved {
359        forge,
360        repo: repo_flag.map(str::to_owned).or(detected.repo),
361    })
362}
363
364/// The refusal a verb answers when it needs the `repo` parameter and
365/// neither a flag nor the remote supplies one.
366#[must_use]
367pub fn repo_unresolved() -> RkError {
368    RkError::missing(
369        Diagnostic::new(
370            Reason::ForgeUndetected,
371            "no repository detected: the target has no origin remote",
372        )
373        .expected("an origin remote naming the project")
374        .action("pass --repo <path>"),
375    )
376}
377
378/// Land one entry: the whole file through the temp-plus-rename writer, or
379/// the block spliced into `AGENTS.md` and the whole document rewritten the
380/// same way.
381///
382/// # Errors
383///
384/// Any write failure; the destination then holds what it held.
385pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
386    let path = target.join(&entry.destination);
387    match entry.placement {
388        Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
389        Placement::Block => {
390            let existing = match std::fs::read(&path) {
391                Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
392                Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
393                Err(e) => return Err(e),
394            };
395            let spliced = splice_block(existing.as_deref());
396            atomic::write(path.as_std_path(), spliced.as_bytes())
397        }
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    #![allow(clippy::expect_used)]
404
405    use super::{
406        AGENTS_DESTINATION, Kind, extract_block, kind_of, projection, render, routing_block,
407        splice_block,
408    };
409    use crate::embedded;
410
411    /// Every snippet destination has a declared kind: a new landable file
412    /// without a classification fails here, not at a landing.
413    #[test]
414    fn the_kind_table_closes_over_every_snippet() {
415        for tech_dir in embedded::SNIPPETS.dirs() {
416            for pair_dir in tech_dir.dirs() {
417                let prefix = format!("{}/", pair_dir.path().to_string_lossy());
418                for (path, _) in embedded::walk(pair_dir) {
419                    let destination = path.strip_prefix(&prefix).unwrap_or(&path);
420                    assert!(
421                        kind_of(destination).is_some(),
422                        "{destination}: no declared kind"
423                    );
424                }
425            }
426        }
427        assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
428        assert_eq!(kind_of("something-else.txt"), None);
429    }
430
431    /// Substitution is total and derives from the repo parameter's first
432    /// segment, so a nested GitLab project path still yields its root
433    /// namespace.
434    #[test]
435    fn rendering_substitutes_every_owner_occurrence() {
436        let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
437        let rendered = render(baseline, "acme/sub/widget");
438        let text = String::from_utf8(rendered).expect("rendered bytes stay text");
439        assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
440    }
441
442    /// A rendered projection carries no unsubstituted token and no
443    /// mechanical sentinel; the one judgment sentinel stays in its seeded
444    /// file.
445    #[test]
446    fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
447        let entries = projection("rust", "github", "acme/widget").expect("the pair projects");
448        let workflow = entries
449            .iter()
450            .find(|entry| entry.destination.ends_with("release-plz.yml"))
451            .expect("the workflow projects");
452        assert_eq!(workflow.kind, Kind::Rendered);
453        let text = String::from_utf8_lossy(&workflow.rendered);
454        assert!(!text.contains("OWNER"), "an owner token survived rendering");
455        assert!(text.contains("'acme'"));
456        assert!(!text.contains("TODO(release-kit)"));
457        let seeded = entries
458            .iter()
459            .find(|entry| entry.destination == "release-plz.toml")
460            .expect("the seeded file projects");
461        assert_eq!(seeded.kind, Kind::Seeded);
462        assert_eq!(seeded.rendered, seeded.baseline);
463        assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
464        assert!(
465            entries
466                .iter()
467                .any(|entry| entry.destination == AGENTS_DESTINATION),
468            "the routing block is part of the projection"
469        );
470    }
471
472    #[test]
473    fn the_block_splices_into_every_agents_shape() {
474        let fresh = splice_block(None);
475        assert_eq!(fresh, format!("{}\n", routing_block()));
476        assert_eq!(extract_block(&fresh), Some(routing_block()));
477
478        let appended = splice_block(Some("# My project\n\nOwn rules.\n"));
479        assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
480        assert_eq!(extract_block(&appended), Some(routing_block()));
481
482        let stale = appended.replace("Never author a tag", "Do author a tag");
483        let refreshed = splice_block(Some(&stale));
484        assert_eq!(extract_block(&refreshed), Some(routing_block()));
485        assert!(refreshed.starts_with("# My project"));
486        assert_eq!(
487            refreshed.matches("BEGIN release-kit").count(),
488            1,
489            "a re-splice must replace, not accumulate"
490        );
491    }
492}