1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum Kind {
25 Rendered,
28 Seeded,
31 State,
34}
35
36impl Kind {
37 #[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
48const 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#[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
79pub const OWNER_TOKEN: &[u8] = b"OWNER";
85
86#[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
103fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
105 haystack
106 .windows(needle.len())
107 .position(|window| window == needle)
108}
109
110pub const AGENTS_DESTINATION: &str = "AGENTS.md";
112
113pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
115
116pub const BLOCK_END: &str = "<!-- END release-kit -->";
118
119const 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#[must_use]
136pub const fn routing_block() -> &'static str {
137 ROUTING_BLOCK
138}
139
140#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum Placement {
170 Whole,
172 Block,
174}
175
176#[derive(Debug)]
179pub struct Entry {
180 pub destination: String,
182 pub kind: Kind,
184 pub placement: Placement,
186 pub baseline: Vec<u8>,
189 pub rendered: Vec<u8>,
192}
193
194pub 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 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
237pub 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
275pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
283 read_recorded(target, &entry.destination)
284}
285
286pub 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#[derive(Debug)]
311pub struct Resolved {
312 pub forge: String,
314 pub repo: Option<String>,
316}
317
318pub 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#[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
378pub 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 #[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 #[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 #[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}