pub mod apply;
pub mod invariants;
pub mod lock;
pub mod manifest;
use camino::Utf8Path;
pub use crate::projection::{
AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
CODE_SCANNING_DESTINATIONS, CODE_SCANNING_TECHS, GLOSSARY_DESTINATION, HOOK_TYPES_LINE,
HOOKS_BEGIN, HOOKS_DESTINATION, HOOKS_END, Kind, LINE_PREFIX_RE_TOKEN, LINE_PREFIX_TOKEN,
NIX_DESTINATIONS, NIX_WITHHOLDABLE, OWNER_TOKEN, REPO_PLACEHOLDER, REPO_TOKEN, SCOPE_SHAPE,
SCOPE_SHAPE_TOKEN, SCORECARD_DESTINATIONS, SECURITY_SPANS, STYLE_TOKEN, TRUNK_BRANCH_TOKEN,
authored, block_markers, destinations, extract_block, hooks_marker_defect, kind_of,
marker_defect, render, scope_is_shaped, splice_hooks_block, splice_marked_block, substitute,
};
pub use manifest::{Provider, Style, Workflow};
use serde::Serialize;
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Params {
tech: String,
forge: String,
repo: String,
workflow: Workflow,
style: Option<Style>,
nix: bool,
scorecard: bool,
code_scanning: Option<Provider>,
trunk: String,
line_prefix: String,
security_contact: String,
security_response: String,
}
#[derive(Default)]
pub struct Inputs<'a> {
pub tech: Option<&'a str>,
pub forge: Option<&'a str>,
pub repo: Option<&'a str>,
pub workflow: Option<Workflow>,
pub style: Option<Style>,
pub nix: Option<bool>,
pub scorecard: Option<bool>,
pub code_scanning: Option<Option<Provider>>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Purpose {
Init,
Preview,
Upgrade,
Adopt,
}
impl Params {
#[must_use]
pub fn from_record(record: &manifest::Manifest) -> Self {
Self {
tech: record.tech.clone(),
forge: record.forge.clone(),
repo: record.parameters.repo.clone(),
workflow: record.parameters.workflow,
style: record.parameters.style,
nix: record.parameters.nix,
scorecard: record.parameters.scorecard,
code_scanning: record.parameters.code_scanning,
trunk: record.parameters.trunk.clone(),
line_prefix: record.parameters.line_prefix.clone(),
security_contact: record.parameters.security_contact.clone(),
security_response: record.parameters.security_response.clone(),
}
}
pub fn resolve(
target: &Utf8Path,
flags: &Inputs<'_>,
config: Option<&crate::config::Config>,
record: Option<&manifest::Manifest>,
purpose: Purpose,
) -> Result<Self, RkError> {
let answer = |flag: Option<&str>, configured: Option<&str>, recorded: Option<&str>| {
flag.or_else(|| configured.filter(|value| !value.is_empty()))
.or(recorded)
.map(str::to_owned)
};
let forge = answer(
flags.forge,
config.map(|c| c.project.forge.as_str()),
record.map(|r| r.forge.as_str()),
);
let repo = answer(
flags.repo,
config.map(|c| c.project.repo.as_str()),
record.map(|r| r.parameters.repo.as_str()),
);
let resolved = resolve(target, forge.as_deref(), repo.as_deref())?;
let tech = answer(
flags.tech,
config.map(|c| c.project.tech.as_str()),
record.map(|r| r.tech.as_str()),
)
.or_else(|| crate::detect::tech_of(target.as_std_path()).map(str::to_owned))
.ok_or_else(|| {
RkError::missing(
Diagnostic::new(
Reason::TargetNotFound,
"no technology detected: the target has no version file",
)
.action("pass --tech <rust|python|bash>"),
)
})?;
crate::projection::check_pair(&tech, &resolved.forge)?;
let workflow = flags
.workflow
.or_else(|| config.and_then(|c| c.landing.workflow))
.or_else(|| record.map(|r| r.parameters.workflow))
.unwrap_or(if purpose == Purpose::Adopt {
Workflow::Branches
} else {
Workflow::Worktree
});
let style = flags
.style
.or_else(|| config.and_then(|c| c.landing.style))
.or_else(|| record.and_then(|r| r.parameters.style));
let style = match (style, purpose) {
(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())),
(value, _) => Some(value.unwrap_or(Style::Trunk)),
};
let repo = resolved
.repo
.or_else(|| (purpose == Purpose::Preview).then(|| REPO_PLACEHOLDER.to_owned()))
.ok_or_else(repo_unresolved)?;
let trunk = config
.and_then(|c| c.project.trunk.clone())
.or_else(|| record.map(|r| r.parameters.trunk.clone()))
.unwrap_or_else(|| crate::config::TRUNK_DEFAULT.to_owned());
let line_prefix = config
.and_then(|c| c.setup.line_prefix.clone())
.or_else(|| record.map(|r| r.parameters.line_prefix.clone()))
.unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned());
let security_contact = config
.and_then(|c| c.security.contact.clone())
.or_else(|| record.map(|r| r.parameters.security_contact.clone()))
.unwrap_or_default();
let security_contact =
crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
let security_response = config
.and_then(|c| c.security.response.clone())
.or_else(|| record.map(|r| r.parameters.security_response.clone()))
.unwrap_or_else(|| crate::config::RESPONSE_DEFAULT.to_owned());
let security_response = crate::config::canonical_response(&security_response)
.map_err(crate::config::invalid)?;
let code_scanning = resolve_code_scanning(flags, config, record, &tech, &resolved.forge)?;
Ok(Self {
tech,
forge: resolved.forge,
repo,
workflow,
style,
nix: flags
.nix
.or_else(|| config.and_then(|c| c.landing.nix))
.or_else(|| record.map(|r| r.parameters.nix))
.unwrap_or(false),
scorecard: flags
.scorecard
.or_else(|| config.and_then(|c| c.landing.scorecard))
.or_else(|| record.map(|r| r.parameters.scorecard))
.unwrap_or(false),
code_scanning,
trunk,
line_prefix,
security_contact,
security_response,
})
}
#[must_use]
pub fn tech(&self) -> &str {
&self.tech
}
#[must_use]
pub fn forge(&self) -> &str {
&self.forge
}
#[must_use]
pub const fn nix(&self) -> bool {
self.nix
}
#[must_use]
pub const fn scorecard(&self) -> bool {
self.scorecard
}
#[must_use]
pub const fn code_scanning(&self) -> Option<Provider> {
self.code_scanning
}
#[must_use]
pub fn capability_flags(&self) -> String {
let mut out = String::new();
if self.nix {
out.push_str(" --nix");
}
if self.scorecard {
out.push_str(" --scorecard");
}
out.push_str(" --code-scanning ");
out.push_str(self.code_scanning.map_or("off", Provider::as_str));
out
}
#[must_use]
pub fn capability_toggles(&self) -> String {
let word = |on: bool| if on { "on" } else { "off" };
format!(
" --nix {} --scorecard {} --code-scanning {}",
word(self.nix),
word(self.scorecard),
self.code_scanning.map_or("off", Provider::as_str)
)
}
#[must_use]
pub fn repo(&self) -> &str {
&self.repo
}
#[must_use]
pub const fn workflow(&self) -> Workflow {
self.workflow
}
#[must_use]
pub const fn style(&self) -> Option<Style> {
self.style
}
#[must_use]
pub fn trunk(&self) -> &str {
&self.trunk
}
#[must_use]
pub fn line_prefix(&self) -> &str {
&self.line_prefix
}
#[must_use]
pub fn security_contact(&self) -> &str {
&self.security_contact
}
#[must_use]
pub fn security_response(&self) -> &str {
&self.security_response
}
}
#[cfg(test)]
impl Params {
pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
Self {
tech: "rust".to_owned(),
forge: "github".to_owned(),
repo: repo.to_owned(),
workflow: Workflow::Worktree,
style,
nix: false,
scorecard: false,
code_scanning: None,
trunk: crate::config::TRUNK_DEFAULT.to_owned(),
line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
security_contact: String::new(),
security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
}
}
pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
Self {
security_contact: contact.to_owned(),
security_response: response.to_owned(),
..Self::for_test("acme/widget", Some(Style::Trunk))
}
}
pub(crate) fn set_nix_for_test(&mut self, nix: bool) {
self.nix = nix;
}
pub(crate) fn set_scorecard_for_test(&mut self, scorecard: bool) {
self.scorecard = scorecard;
}
pub(crate) fn set_code_scanning_for_test(&mut self, provider: Option<Provider>) {
self.code_scanning = provider;
}
}
fn resolve_code_scanning(
flags: &Inputs<'_>,
config: Option<&crate::config::Config>,
record: Option<&manifest::Manifest>,
tech: &str,
forge: &str,
) -> Result<Option<Provider>, RkError> {
let configured = config
.and_then(|c| c.landing.code_scanning.as_deref())
.map(Provider::parse)
.transpose()?;
let provider = flags
.code_scanning
.or(configured)
.or_else(|| record.map(|r| r.parameters.code_scanning))
.unwrap_or(None);
if let Some(reason) = crate::projection::code_scanning_incompatibility(provider, tech, forge) {
return Err(RkError::Usage(reason));
}
Ok(provider)
}
#[derive(Debug, Clone, Serialize)]
pub struct Withheld {
pub path: String,
pub reason: String,
}
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 let Some((begin, end)) = block_markers(destination) {
let text = String::from_utf8_lossy(&bytes);
Ok(extract_block(&text, begin, end).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>"),
)
}
#[cfg(test)]
mod tests {
use super::{
AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION, HOOKS_END, Kind,
Provider, SCOPE_SHAPE, Style, Workflow, extract_block, kind_of, render, splice_hooks_block,
splice_marked_block,
};
use crate::embedded;
use crate::projection::{self, Projection, ProjectionInput, TargetEvidence};
fn destinations(params: &super::Params) -> Vec<String> {
Projection::compute(&ProjectionInput {
params: params.clone(),
evidence: TargetEvidence {
crate_shape: projection::CrateShape {
cargo_toml: Some(
"[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned(),
),
cargo_lock: true,
main_rs: true,
},
..TargetEvidence::default()
},
})
.expect("the pair projects")
.candidates
.into_iter()
.map(|candidate| candidate.destination)
.collect()
}
fn routing_block(workflow: Workflow) -> String {
projection::routing_block(workflow).expect("the binary embeds the block")
}
fn hooks_block(workflow: Workflow) -> String {
projection::hooks_block(workflow).expect("the binary embeds the block")
}
fn glossary_block() -> String {
projection::glossary_block().expect("the binary embeds the block")
}
fn spliced(existing: Option<&str>, block: &str) -> String {
String::from_utf8(splice_marked_block(existing.map(str::as_bytes), block))
.expect("the fixtures are text")
}
#[test]
fn private_reporting_path_tokens_are_reproducible() {
for repo in [
"acme/widget",
"acme/group/widget",
"acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
] {
assert_eq!(
super::render(
b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
&super::Params::for_test(repo, Some(super::Style::Trunk))
),
format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
);
}
assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
}
#[test]
fn each_forge_policy_carries_one_ordered_pair_of_every_span() {
for forge in ["github", "gitlab"] {
let bytes = embedded::SNIPPETS
.get_file(format!("_shared/{forge}/SECURITY.md"))
.expect("the policy ships")
.contents();
let text = String::from_utf8_lossy(bytes);
for (begin, end) in super::SECURITY_SPANS {
let begin = String::from_utf8_lossy(begin);
let end = String::from_utf8_lossy(end);
assert_eq!(text.matches(begin.as_ref()).count(), 1, "{forge} {begin}");
assert_eq!(text.matches(end.as_ref()).count(), 1, "{forge} {end}");
assert!(
text.find(begin.as_ref()) < text.find(end.as_ref()),
"{forge}: {begin} must precede {end}"
);
}
}
}
#[test]
fn the_security_spans_render_per_answer() {
for forge in ["github", "gitlab"] {
let bytes = embedded::SNIPPETS
.get_file(format!("_shared/{forge}/SECURITY.md"))
.expect("the policy ships")
.contents();
let authored = String::from_utf8_lossy(bytes);
let stripped = {
let mut text = authored.clone().into_owned();
for (begin, end) in super::SECURITY_SPANS {
text = text.replace(&String::from_utf8_lossy(begin).into_owned(), "");
text = text.replace(&String::from_utf8_lossy(end).into_owned(), "");
}
text
};
let default = super::Params {
forge: forge.to_owned(),
..super::Params::for_test_security("", crate::config::RESPONSE_DEFAULT)
};
let rendered = String::from_utf8(render(bytes, &default)).expect("text");
assert_eq!(
rendered,
stripped.replace("RK_REPO", "acme/widget"),
"{forge}: the default answers must reproduce the authored policy"
);
assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
let answered = super::Params {
forge: forge.to_owned(),
..super::Params::for_test_security("OWNER RK_REPO <team@acme.example>", "14 days")
};
let rendered = String::from_utf8(render(bytes, &answered)).expect("text");
assert!(
rendered.contains("OWNER RK_REPO <team@acme.example>"),
"{forge}: a contact spelling a token name lands literally: {rendered}"
);
assert!(
rendered.contains("Maintainers acknowledge a report within 14 days."),
"{forge}: {rendered}"
);
assert!(
rendered.contains("This policy commits to no disclosure deadline."),
"{forge}: {rendered}"
);
assert!(
!rendered.contains("best-effort basis"),
"{forge}: a stated window replaces the best-effort sentence: {rendered}"
);
assert!(
!rendered.contains("no response or disclosure deadline"),
"{forge}: a stated window contradicts the response disclaimer: {rendered}"
);
}
}
#[test]
fn a_defective_span_renders_unchanged() {
let (begin, end) = super::SECURITY_SPANS[0];
let begin = String::from_utf8_lossy(begin).into_owned();
let end = String::from_utf8_lossy(end).into_owned();
let params = super::Params::for_test_security("team@acme.example", "1 day");
for baseline in [
format!("contact {begin}a maintainer\n"),
format!("contact a maintainer{end}\n"),
format!("contact {end}a maintainer{begin}\n"),
"contact a maintainer\n".to_owned(),
] {
assert_eq!(
render(baseline.as_bytes(), ¶ms),
baseline.as_bytes(),
"{baseline}"
);
}
}
#[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"
);
}
}
}
for block in BLOCK_DESTINATIONS {
assert_eq!(kind_of(block), Some(Kind::Rendered), "{block}");
}
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, &super::Params::for_test("acme/sub/widget", None));
let text = String::from_utf8(rendered).expect("rendered bytes stay text");
assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
let baseline = b"match (RK_SCOPE_SHAPE)\n";
let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
let text = String::from_utf8(rendered).expect("rendered bytes stay text");
assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
}
#[test]
fn the_scope_shape_drops_into_the_title_check() {
assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
assert!(
!SCOPE_SHAPE.contains('\''),
"the title checks single-quote it"
);
}
#[test]
fn the_scope_predicate_and_the_rendered_pattern_agree() {
let body = SCOPE_SHAPE
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix("]+"))
.expect("the shape is one bracket expression, repeated");
let chars: Vec<char> = body.chars().collect();
let mut admitted = std::collections::BTreeSet::new();
let mut at = 0;
while at < chars.len() {
if at + 2 < chars.len() && chars[at + 1] == '-' {
for c in chars[at]..=chars[at + 2] {
admitted.insert(c);
}
at += 3;
} else {
admitted.insert(chars[at]);
at += 1;
}
}
for byte in 0..=127u8 {
let c = char::from(byte);
assert_eq!(
super::scope_is_shaped(&c.to_string()),
admitted.contains(&c),
"the predicate and {SCOPE_SHAPE} disagree on {c:?}"
);
}
assert!(super::scope_is_shaped("guides/release"));
assert!(!super::scope_is_shaped(""), "a scope is never empty");
assert!(!super::scope_is_shaped("Specs Ugly"));
}
#[test]
fn the_shared_zone_composes_into_the_pair() {
let github = destinations(&super::Params {
forge: "github".to_owned(),
..super::Params::for_test("acme/widget", Some(Style::Trunk))
});
assert!(
github.contains(&".github/workflows/pr-title.yml".to_owned()),
"the shared title check lands with the pair"
);
let gitlab = destinations(&super::Params {
forge: "gitlab".to_owned(),
..super::Params::for_test("acme/widget", Some(Style::Trunk))
});
assert!(
gitlab.contains(&".gitlab/ci/mr-title.yml".to_owned()),
"the shared title job lands with the pair"
);
let err =
projection::check_pair("_shared", "github").expect_err("the shared zone is no tech");
let listing = err.to_string();
let bindings = listing
.split("the bindings are:")
.nth(1)
.expect("the refusal lists the bindings");
assert!(!bindings.contains("_shared"), "{listing}");
}
#[test]
fn params_from_a_record_round_trips() {
use super::{Params, manifest};
let dir = tempfile::tempdir().expect("a scratch target exists");
let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
for tech in ["rust", "bash"] {
for forge in ["github", "gitlab"] {
for workflow in [Workflow::Branches, Workflow::Worktree] {
for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
for ((nix, scorecard), code_scanning) in [
((false, false), None),
((false, true), Some(Provider::Semgrep)),
((true, false), Some(Provider::CodeQl)),
((true, true), None),
] {
let record = manifest::Manifest {
schema_version: manifest::SCHEMA_VERSION,
rk_version: "0.1.0".to_owned(),
origin: "init".to_owned(),
tech: tech.to_owned(),
forge: forge.to_owned(),
landed_at: "2026-08-29T00:00:00Z".to_owned(),
parameters: manifest::Parameters {
repo: "acme/team/widget".to_owned(),
workflow,
style,
nix,
scorecard,
code_scanning,
trunk: crate::config::TRUNK_DEFAULT.to_owned(),
line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
security_contact: String::new(),
security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
},
files: Vec::new(),
pins: std::collections::BTreeMap::new(),
};
manifest::write(target, &record).expect("the record writes");
let loaded = manifest::load(target)
.expect("the record loads")
.expect("the record exists");
let params = Params::from_record(&loaded);
assert_eq!(params.tech, tech);
assert_eq!(params.forge, forge);
assert_eq!(params.repo(), "acme/team/widget");
assert_eq!(params.workflow(), workflow);
assert_eq!(params.style(), style);
assert_eq!(params.nix, nix);
assert_eq!(params.scorecard, scorecard);
assert_eq!(params.code_scanning, code_scanning);
let mut direct = super::Params::for_test("acme/team/widget", style);
direct.tech = tech.to_owned();
direct.forge = forge.to_owned();
direct.workflow = workflow;
direct.nix = nix;
direct.scorecard = scorecard;
direct.code_scanning = code_scanning;
assert_eq!(params, direct);
let projected = destinations(¶ms);
for block in
[AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION]
{
assert!(projected.contains(&block.to_owned()), "{block}");
}
for destination in super::NIX_DESTINATIONS {
assert_eq!(
projected.contains(&destination.to_owned()),
nix && tech == "rust",
"{tech} {forge} nix={nix}: {destination}"
);
}
for destination in super::SCORECARD_DESTINATIONS {
assert_eq!(
projected.contains(&destination.to_owned()),
scorecard && forge == "github",
"{tech} {forge} scorecard={scorecard}: {destination}"
);
}
}
}
}
}
}
}
fn resolved_test_params(
tech: &str,
resolved: &super::Resolved,
workflow: Workflow,
style: Option<Style>,
nix: bool,
scorecard: bool,
code_scanning: Option<Provider>,
) -> Result<super::Params, crate::error::RkError> {
super::Params::resolve(
camino::Utf8Path::new("."),
&super::Inputs {
tech: Some(tech),
forge: Some(&resolved.forge),
repo: resolved.repo.as_deref(),
workflow: Some(workflow),
style,
nix: Some(nix),
scorecard: Some(scorecard),
code_scanning: Some(code_scanning),
},
None,
None,
super::Purpose::Init,
)
}
#[test]
fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
let params = resolved_test_params(
"rust",
&super::Resolved {
forge: "github".to_owned(),
repo: Some("acme/widget".to_owned()),
},
Workflow::Branches,
Some(Style::Trunk),
false,
false,
None,
)
.expect("the parameters resolve");
let entries = Projection::compute(&ProjectionInput {
params,
evidence: TargetEvidence::default(),
})
.expect("the pair projects")
.candidates;
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.bytes);
assert!(!text.contains("OWNER"), "an owner token survived rendering");
assert!(text.contains("'acme'"));
assert!(!text.contains("TODO(release-kit)"));
let title = entries
.iter()
.find(|entry| entry.destination.ends_with("pr-title.yml"))
.expect("the title check projects");
let text = String::from_utf8_lossy(&title.bytes);
assert!(text.contains(SCOPE_SHAPE), "{text}");
assert!(
!text.contains("RK_SCOPE_SHAPE"),
"a scope token survived: {text}"
);
let seeded = entries
.iter()
.find(|entry| entry.destination == "release-plz.toml")
.expect("the seeded file projects");
assert_eq!(seeded.kind, Kind::Seeded);
let authored = embedded::SNIPPETS
.get_file("rust/github/release-plz.toml")
.expect("the seed ships")
.contents();
assert_eq!(seeded.bytes, authored, "a seeded file lands as authored");
assert!(String::from_utf8_lossy(&seeded.bytes).contains("TODO(release-kit)"));
for block in BLOCK_DESTINATIONS {
let entry = entries
.iter()
.find(|entry| entry.destination == block)
.expect("every block is part of the projection");
let text = String::from_utf8_lossy(&entry.bytes);
assert!(
!text.contains("RK_SCOPE_SHAPE"),
"{block} kept a token: {text}"
);
}
}
#[test]
fn the_nix_destinations_project_only_under_the_opt_in() {
use super::NIX_DESTINATIONS;
let paths = |nix: bool, forge: &str| -> Vec<String> {
destinations(
&resolved_test_params(
"rust",
&super::Resolved {
forge: forge.to_owned(),
repo: Some("acme/widget".to_owned()),
},
Workflow::Worktree,
Some(Style::Trunk),
nix,
false,
None,
)
.expect("the parameters resolve"),
)
};
let off = paths(false, "github");
for destination in NIX_DESTINATIONS {
assert!(!off.contains(&destination.to_owned()), "{destination}");
}
let on = paths(true, "github");
for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
assert!(on.contains(&destination.to_owned()), "{destination}");
}
let gitlab = paths(true, "gitlab");
assert!(gitlab.contains(&"nix/package.nix".to_owned()));
assert!(
!on.iter()
.chain(gitlab.iter())
.any(|destination| destination.contains("nix.yml"))
);
let bash = destinations(
&resolved_test_params(
"bash",
&super::Resolved {
forge: "github".to_owned(),
repo: Some("acme/widget".to_owned()),
},
Workflow::Worktree,
Some(Style::Trunk),
true,
false,
None,
)
.expect("the parameters resolve"),
);
assert!(
bash.iter()
.all(|destination| !NIX_DESTINATIONS.contains(&destination.as_str()))
);
}
#[test]
fn the_nix_seeds_are_identical_across_forge_pairs() {
for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
let github = embedded::SNIPPETS
.get_file(format!("rust/github/{name}"))
.expect("the github copy ships")
.contents();
let gitlab = embedded::SNIPPETS
.get_file(format!("rust/gitlab/{name}"))
.expect("the gitlab copy ships")
.contents();
assert_eq!(github, gitlab, "{name} diverged between the pairs");
}
}
#[test]
fn the_nix_withhold_judgment_covers_the_three_shapes() {
use super::NIX_DESTINATIONS;
let dir = tempfile::tempdir().expect("a scratch target exists");
let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
let project = |nix: bool| {
let params = resolved_test_params(
"rust",
&super::Resolved {
forge: "github".to_owned(),
repo: Some("acme/widget".to_owned()),
},
Workflow::Worktree,
Some(Style::Trunk),
nix,
false,
None,
)
.expect("the parameters resolve");
let evidence = TargetEvidence::gather(target, None).expect("the evidence reads");
Projection::compute(&ProjectionInput { params, evidence }).expect("the pair projects")
};
let withheld = |projection: &Projection| -> Vec<String> {
projection
.omissions
.iter()
.map(|omission| omission.destination.clone())
.collect()
};
let landed = |projection: &Projection, destination: &str| {
projection
.candidates
.iter()
.any(|candidate| candidate.destination == destination)
};
let all = project(true);
assert_eq!(
withheld(&all),
["flake.lock", "flake.nix", "nix/package.nix"]
);
assert!(
all.candidates
.iter()
.all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
);
std::fs::write(
target.join("Cargo.toml"),
"[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
)
.expect("the crate manifest writes");
std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
let all = project(true);
assert_eq!(withheld(&all), ["flake.lock", "flake.nix"]);
assert!(landed(&all, "nix/package.nix"));
std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
let all = project(true);
assert!(all.omissions.is_empty());
assert!(landed(&all, "flake.nix"));
let all = project(false);
assert!(all.omissions.is_empty());
assert!(!landed(&all, "flake.nix"));
}
#[test]
fn the_glossary_splices_into_every_shape() {
let owned = glossary_block();
let block = owned.as_str();
let fresh = spliced(None, block);
assert_eq!(fresh, format!("{block}\n"));
assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
let own = "# Glossary\n\n- `spike` — a throwaway branch.\n";
let appended = spliced(Some(own), block);
assert!(appended.starts_with(own));
assert_eq!(
extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
Some(block)
);
let stale = appended.replace("full-implement", "do-everything");
let refreshed = spliced(Some(&stale), block);
assert_eq!(
extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
Some(block)
);
assert_eq!(
refreshed.matches("BEGIN release-kit").count(),
1,
"a re-splice must replace, not accumulate"
);
}
#[test]
fn the_glossary_leaves_the_targets_region_alone() {
let owned = glossary_block();
let block = owned.as_str();
let below = "\n## Our own terms\n\n- `spike` — a throwaway branch, never merged.\n";
let landed = format!("{block}\n{below}");
let refreshed = spliced(Some(&landed), block);
assert!(
refreshed.ends_with(below),
"the target's own region changed: {refreshed}"
);
assert_eq!(
extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
Some(block)
);
}
#[test]
fn an_append_rewrites_no_byte_the_target_wrote() {
let owned = glossary_block();
let block = owned.as_str();
for own in [
"# Glossary\n\n- `spike` — throwaway. \n\n\n",
"# Glossary\n\n- `spike` — throwaway.",
"# Glossary\r\n\r\n- `spike` — throwaway.\r\n",
] {
let appended = spliced(Some(own), block);
assert!(
appended.starts_with(own),
"the target's bytes changed: {appended:?}"
);
assert_eq!(
extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
Some(block),
"{appended:?}"
);
let marker = appended.find(BLOCK_BEGIN).expect("the block landed");
assert!(
appended[..marker].ends_with('\n'),
"the block must open its own line: {appended:?}"
);
}
}
#[test]
fn a_splice_decodes_no_byte_the_target_wrote() {
let owned = glossary_block();
let block = owned.as_str();
let own = b"# Glossary\n\ncaf\xe9\n";
let appended = splice_marked_block(Some(own), block);
assert!(
appended.starts_with(own),
"the target's bytes changed: {appended:?}"
);
assert!(!appended.contains(&0xEF), "a replacement character landed");
let mut landed = Vec::new();
landed.extend_from_slice(block.replace("full-implement", "do-everything").as_bytes());
landed.extend_from_slice(b"\n\ncaf\xe9\n");
let refreshed = splice_marked_block(Some(&landed), block);
assert!(
refreshed.ends_with(b"\n\ncaf\xe9\n"),
"the target's region below the markers changed: {refreshed:?}"
);
assert!(refreshed.starts_with(block.as_bytes()), "{refreshed:?}");
}
#[test]
fn the_glossary_block_carries_no_parameter() {
let block = glossary_block();
assert!(block.starts_with(BLOCK_BEGIN), "{block}");
assert!(block.ends_with(BLOCK_END), "{block}");
assert!(!block.contains("RK_"), "a token survived: {block}");
assert!(!block.contains("OWNER"), "an owner token survived: {block}");
for term in [
"implement-and-request",
"implement-and-merge",
"full-implement",
] {
assert!(block.contains(term), "{term} is missing from {block}");
}
assert!(
routing_block(Workflow::Worktree).contains(GLOSSARY_DESTINATION),
"the routing block must name the destination it indexes"
);
}
#[test]
fn the_block_splices_into_every_agents_shape() {
let owned = routing_block(Workflow::Branches);
let block = owned.as_str();
let fresh = spliced(None, block);
assert_eq!(fresh, format!("{block}\n"));
assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
let appended = spliced(Some("# My project\n\nOwn rules.\n"), block);
assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
assert_eq!(
extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
Some(block)
);
let stale = appended.replace("Never author a tag", "Do author a tag");
let refreshed = spliced(Some(&stale), block);
assert_eq!(
extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
Some(block)
);
assert!(refreshed.starts_with("# My project"));
assert_eq!(
refreshed.matches("BEGIN release-kit").count(),
1,
"a re-splice must replace, not accumulate"
);
}
#[test]
fn the_hook_block_splices_under_repos() {
let owned = hooks_block(Workflow::Branches);
let block = owned.as_str();
let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
assert!(fresh.starts_with(HOOK_TYPES_LINE));
assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
let own =
"repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
assert!(spliced.contains("- id: own"), "the target's hooks survive");
assert!(
!spliced.contains(HOOK_TYPES_LINE),
"an existing file's top level is the skills' duty, not the splice's"
);
let stale = spliced.replace("--force-scope", "--no-scope");
let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
assert_eq!(
extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
Some(block)
);
assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
.expect_err("no repos: line refuses");
assert!(err.contains("repos:"), "{err}");
let doubled = format!("repos:\n{block}\n{block}\n");
let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
assert!(err.contains("one block"), "{err}");
let unmatched = "repos:\n# BEGIN release-kit\n - repo: local\n";
let err =
splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
assert!(err.contains("unmatched"), "{err}");
}
#[test]
fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
let worktree_hooks = hooks_block(Workflow::Worktree);
let branches_hooks = hooks_block(Workflow::Branches);
assert!(worktree_hooks.contains("- id: rk-worktree-location"));
assert!(
worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
"{worktree_hooks}"
);
assert!(!branches_hooks.contains("rk-worktree-location"));
assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
for block in [&worktree_hooks, &branches_hooks] {
assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
for token in ["RK_BRANCH_GRAMMAR", "RK_SWEEP_SKIP", "RK_WORKTREE_GUARD"] {
assert!(!block.contains(token), "{token} survived: {block}");
}
}
for block in [&worktree_hooks, &branches_hooks] {
for line in block.lines() {
if let Some(value) = line.trim_start().strip_prefix("entry: ") {
assert!(
!value.contains(": "),
"an entry value breaks the YAML plain scalar: {line}"
);
}
}
}
let guard_line = worktree_hooks
.lines()
.position(|line| line.contains("id: rk-worktree-location"))
.expect("the guard entry exists");
let name_line = worktree_hooks
.lines()
.position(|line| line.contains("id: rk-branch-name"))
.expect("the name hook exists");
assert!(
guard_line > name_line,
"the guard lands directly after rk-branch-name"
);
let worktree_routing = routing_block(Workflow::Worktree);
let branches_routing = routing_block(Workflow::Branches);
assert!(worktree_routing.contains("This project works in worktrees"));
assert!(branches_routing.contains("Branches are worked in the main checkout"));
for block in [&worktree_routing, &branches_routing] {
assert!(block.contains("Create or remove a worktree"));
assert!(block.contains("`rk worktree add <branch>`"));
assert!(!block.contains("RK_WORKFLOW_LINE"), "{block}");
}
let differing: Vec<(&str, &str)> = worktree_routing
.lines()
.zip(branches_routing.lines())
.filter(|(a, b)| a != b)
.collect();
assert_eq!(
differing.len(),
1,
"exactly one routing line differs per mode: {differing:?}"
);
}
#[test]
fn the_hook_marker_defects_are_named() {
use super::hooks_marker_defect;
let owned = hooks_block(Workflow::Branches);
let block = owned.as_str();
assert_eq!(hooks_marker_defect(""), None);
assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
for (case, text) in [
(
"a second begin",
format!("repos:\n{block}\n# BEGIN release-kit\n"),
),
(
"a second end",
format!("repos:\n{block}\n# END release-kit\n"),
),
(
"an unpaired begin",
"repos:\n# BEGIN release-kit\n".to_owned(),
),
("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
(
"an end before its begin",
"repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
),
] {
assert!(
hooks_marker_defect(&text).is_some(),
"{case} must be a defect"
);
}
}
}