pub mod manifest;
use camino::Utf8Path;
use serde::{Deserialize, Serialize};
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
use crate::{atomic, embedded};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Rendered,
Seeded,
State,
}
impl Kind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Rendered => "rendered",
Self::Seeded => "seeded",
Self::State => "state",
}
}
}
const KINDS: [(&str, Kind); 10] = [
(".github/workflows/release-plz.yml", Kind::Rendered),
(".github/workflows/release-please.yml", Kind::Rendered),
(".github/workflows/release.yml", Kind::Rendered),
(".gitlab-ci.yml", Kind::Rendered),
("release-plz.toml", Kind::Seeded),
("dist-workspace.toml", Kind::Seeded),
("release-please-config.json", Kind::Seeded),
("cliff.toml", Kind::Seeded),
(".release-please-manifest.json", Kind::State),
("VERSION", Kind::State),
];
#[must_use]
pub fn kind_of(destination: &str) -> Option<Kind> {
if destination == AGENTS_DESTINATION {
return Some(Kind::Rendered);
}
KINDS
.iter()
.find(|(name, _)| *name == destination)
.map(|(_, kind)| *kind)
}
pub const OWNER_TOKEN: &[u8] = b"OWNER";
#[must_use]
pub fn render(baseline: &[u8], repo: &str) -> Vec<u8> {
let owner = repo.split('/').next().unwrap_or(repo).as_bytes();
let mut out = Vec::with_capacity(baseline.len());
let mut rest = baseline;
while let Some(at) = find(rest, OWNER_TOKEN) {
out.extend_from_slice(&rest[..at]);
out.extend_from_slice(owner);
rest = &rest[at + OWNER_TOKEN.len()..];
}
out.extend_from_slice(rest);
out
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
pub const AGENTS_DESTINATION: &str = "AGENTS.md";
pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
pub const BLOCK_END: &str = "<!-- END release-kit -->";
const ROUTING_BLOCK: &str = "<!-- BEGIN release-kit -->
## Releases
- This repository runs the release-kit convention; `rk method invariants` states what must stay true.
- Never author a tag, and never hand-edit a generated artifact workflow.
- Run `rk status` before changing anything under `.github/workflows/` or `.gitlab-ci.yml`, or any file `.release-kit/manifest.json` names.
- The full method is `rk method --list`; the recovery paths are `rk method recovery`.
<!-- END release-kit -->";
#[must_use]
pub const fn routing_block() -> &'static str {
ROUTING_BLOCK
}
#[must_use]
pub fn extract_block(text: &str) -> Option<&str> {
let start = text.find(BLOCK_BEGIN)?;
let end = text[start..].find(BLOCK_END)? + start + BLOCK_END.len();
Some(&text[start..end])
}
#[must_use]
pub fn splice_block(existing: Option<&str>) -> String {
existing.map_or_else(
|| format!("{ROUTING_BLOCK}\n"),
|text| {
extract_block(text).map_or_else(
|| format!("{}\n\n{ROUTING_BLOCK}\n", text.trim_end()),
|found| text.replacen(found, ROUTING_BLOCK, 1),
)
},
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Placement {
Whole,
Block,
}
#[derive(Debug)]
pub struct Entry {
pub destination: String,
pub kind: Kind,
pub placement: Placement,
pub baseline: Vec<u8>,
pub rendered: Vec<u8>,
}
pub fn pair_files(tech: &str, forge: &str) -> Result<Vec<(String, &'static [u8])>, RkError> {
embedded::SNIPPETS.get_dir(tech).ok_or_else(|| {
let known: Vec<String> = embedded::SNIPPETS
.dirs()
.map(|dir| dir.path().to_string_lossy().into_owned())
.collect();
RkError::Usage(format!(
"unknown tech '{tech}'; the bindings are: {}",
known.join(", ")
))
})?;
let pair = format!("{tech}/{forge}");
let pair_dir = embedded::SNIPPETS.get_dir(&pair).ok_or_else(|| {
let known: Vec<String> = embedded::SNIPPETS
.dirs()
.flat_map(include_dir::Dir::dirs)
.map(|dir| dir.path().to_string_lossy().replace('/', ", "))
.collect();
RkError::Usage(format!(
"the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
known.join("; ")
))
})?;
Ok(embedded::walk(pair_dir)
.into_iter()
.map(|(path, contents)| {
let rel = path
.strip_prefix(&format!("{pair}/"))
.map_or(path.as_str(), |rel| rel)
.to_owned();
(rel, contents)
})
.collect())
}
pub fn projection(tech: &str, forge: &str, repo: &str) -> Result<Vec<Entry>, RkError> {
let mut entries = Vec::new();
for (destination, baseline) in pair_files(tech, forge)? {
let kind = kind_of(&destination).ok_or_else(|| {
anyhow::anyhow!("the payload does not classify {destination}; the kind table is stale")
})?;
let rendered = match kind {
Kind::Rendered => render(baseline, repo),
Kind::Seeded | Kind::State => baseline.to_vec(),
};
entries.push(Entry {
destination,
kind,
placement: Placement::Whole,
baseline: baseline.to_vec(),
rendered,
});
}
entries.push(Entry {
destination: AGENTS_DESTINATION.to_owned(),
kind: Kind::Rendered,
placement: Placement::Block,
baseline: ROUTING_BLOCK.as_bytes().to_vec(),
rendered: ROUTING_BLOCK.as_bytes().to_vec(),
});
entries.sort_by(|a, b| a.destination.cmp(&b.destination));
Ok(entries)
}
pub fn read_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<Option<Vec<u8>>> {
read_recorded(target, &entry.destination)
}
pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
let path = target.join(destination);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
if destination == AGENTS_DESTINATION {
let text = String::from_utf8_lossy(&bytes);
Ok(extract_block(&text).map(|block| block.as_bytes().to_vec()))
} else {
Ok(Some(bytes))
}
}
#[derive(Debug)]
pub struct Resolved {
pub forge: String,
pub repo: Option<String>,
}
pub fn resolve(
target: &Utf8Path,
forge_flag: Option<&str>,
repo_flag: Option<&str>,
) -> Result<Resolved, RkError> {
let forge_flag = forge_flag
.map(|name| {
crate::detect::Forge::parse(name).ok_or_else(|| {
RkError::Usage(format!(
"unknown forge '{name}'; the forges are: github, gitlab"
))
})
})
.transpose()?;
let detected = crate::detect::detect(target.as_std_path());
let forge = forge_flag
.or(detected.forge)
.map(|forge| forge.as_str().to_owned())
.ok_or_else(|| {
let message = detected.host.map_or_else(
|| "no forge detected: the target has no origin remote".to_owned(),
|host| format!("no forge detected: the host {host} is not recognized"),
);
RkError::refusal(
Diagnostic::new(Reason::ForgeUndetected, message)
.expected("a github.com or gitlab remote, or --forge")
.action("pass --forge <github|gitlab>"),
)
})?;
Ok(Resolved {
forge,
repo: repo_flag.map(str::to_owned).or(detected.repo),
})
}
#[must_use]
pub fn repo_unresolved() -> RkError {
RkError::missing(
Diagnostic::new(
Reason::ForgeUndetected,
"no repository detected: the target has no origin remote",
)
.expected("an origin remote naming the project")
.action("pass --repo <path>"),
)
}
pub fn write_destination(target: &Utf8Path, entry: &Entry) -> std::io::Result<()> {
let path = target.join(&entry.destination);
match entry.placement {
Placement::Whole => atomic::write(path.as_std_path(), &entry.rendered),
Placement::Block => {
let existing = match std::fs::read(&path) {
Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
let spliced = splice_block(existing.as_deref());
atomic::write(path.as_std_path(), spliced.as_bytes())
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::{
AGENTS_DESTINATION, Kind, extract_block, kind_of, projection, render, routing_block,
splice_block,
};
use crate::embedded;
#[test]
fn the_kind_table_closes_over_every_snippet() {
for tech_dir in embedded::SNIPPETS.dirs() {
for pair_dir in tech_dir.dirs() {
let prefix = format!("{}/", pair_dir.path().to_string_lossy());
for (path, _) in embedded::walk(pair_dir) {
let destination = path.strip_prefix(&prefix).unwrap_or(&path);
assert!(
kind_of(destination).is_some(),
"{destination}: no declared kind"
);
}
}
}
assert_eq!(kind_of(AGENTS_DESTINATION), Some(Kind::Rendered));
assert_eq!(kind_of("something-else.txt"), None);
}
#[test]
fn rendering_substitutes_every_owner_occurrence() {
let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
let rendered = render(baseline, "acme/sub/widget");
let text = String::from_utf8(rendered).expect("rendered bytes stay text");
assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
}
#[test]
fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
let entries = projection("rust", "github", "acme/widget").expect("the pair projects");
let workflow = entries
.iter()
.find(|entry| entry.destination.ends_with("release-plz.yml"))
.expect("the workflow projects");
assert_eq!(workflow.kind, Kind::Rendered);
let text = String::from_utf8_lossy(&workflow.rendered);
assert!(!text.contains("OWNER"), "an owner token survived rendering");
assert!(text.contains("'acme'"));
assert!(!text.contains("TODO(release-kit)"));
let seeded = entries
.iter()
.find(|entry| entry.destination == "release-plz.toml")
.expect("the seeded file projects");
assert_eq!(seeded.kind, Kind::Seeded);
assert_eq!(seeded.rendered, seeded.baseline);
assert!(String::from_utf8_lossy(&seeded.rendered).contains("TODO(release-kit)"));
assert!(
entries
.iter()
.any(|entry| entry.destination == AGENTS_DESTINATION),
"the routing block is part of the projection"
);
}
#[test]
fn the_block_splices_into_every_agents_shape() {
let fresh = splice_block(None);
assert_eq!(fresh, format!("{}\n", routing_block()));
assert_eq!(extract_block(&fresh), Some(routing_block()));
let appended = splice_block(Some("# My project\n\nOwn rules.\n"));
assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
assert_eq!(extract_block(&appended), Some(routing_block()));
let stale = appended.replace("Never author a tag", "Do author a tag");
let refreshed = splice_block(Some(&stale));
assert_eq!(extract_block(&refreshed), Some(routing_block()));
assert!(refreshed.starts_with("# My project"));
assert_eq!(
refreshed.matches("BEGIN release-kit").count(),
1,
"a re-splice must replace, not accumulate"
);
}
}