use std::collections::BTreeMap;
use camino::Utf8Path;
use serde::{Deserialize, Serialize};
use super::context::Ctx;
use super::report::{Observed, Report, stance};
use super::steps::STEPS;
use crate::digest::Digest;
pub const PROOF_PATH: &str = ".release-kit/setup-proof.json";
pub const SCHEMA: &str = "rk.setup-proof/1";
const SCHEMA_FAMILY: &str = "rk.setup-proof/";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Subject {
pub target: BTreeMap<String, String>,
pub steps: Vec<SubjectStep>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SubjectStep {
pub name: String,
pub proves: String,
pub prerequisites: Vec<String>,
pub stance: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProvenStep {
pub name: String,
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limitation: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Proof {
pub schema: String,
pub rk_version: String,
pub verified_at: String,
pub subject: Subject,
pub subject_digest: String,
pub steps: Vec<ProvenStep>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Standing {
Absent,
Compatible(Proof),
Stale {
proof: Proof,
differences: Vec<String>,
},
Invalid {
reason: &'static str,
detail: String,
},
}
impl Standing {
#[must_use]
pub const fn word(&self) -> &'static str {
match self {
Self::Absent => "absent",
Self::Compatible(_) => "compatible",
Self::Stale { .. } => "stale",
Self::Invalid { .. } => "invalid",
}
}
}
#[must_use]
pub fn subject(ctx: &Ctx) -> Subject {
let steps = STEPS
.iter()
.map(|step| {
let stance = stance(ctx, step);
SubjectStep {
name: step.name.to_owned(),
proves: step.proves.to_owned(),
prerequisites: step.prereqs.iter().map(|&name| name.to_owned()).collect(),
stance: stance.word().to_owned(),
reason: stance.detail(),
}
})
.collect();
Subject {
target: ctx.proof_fields(),
steps,
}
}
#[must_use]
pub fn digest(subject: &Subject) -> String {
let bytes = serde_json::to_vec(subject).unwrap_or_default();
Digest::of(&bytes).to_string()
}
#[must_use]
pub fn of(ctx: &Ctx, report: &Report, verified_at: String) -> Option<Proof> {
if !report.checkpointable() {
return None;
}
let steps = report
.rows
.iter()
.map(|row| {
let (state, limitation) = match &row.observed {
None => (row.stance.word().to_owned(), None),
Some(Observed::Satisfied { limitation }) => (
Observed::Satisfied {
limitation: limitation.clone(),
}
.wire()
.to_owned(),
limitation.clone(),
),
Some(other) => (other.wire().to_owned(), None),
};
ProvenStep {
name: row.name.to_owned(),
state,
limitation,
}
})
.collect();
let subject = subject(ctx);
Some(Proof {
schema: SCHEMA.to_owned(),
rk_version: env!("CARGO_PKG_VERSION").to_owned(),
verified_at,
subject_digest: digest(&subject),
subject,
steps,
})
}
#[must_use]
pub fn render(proof: &Proof) -> String {
let mut text = serde_json::to_string_pretty(proof).unwrap_or_default();
text.push('\n');
text
}
pub fn write(target: &Utf8Path, proof: &Proof) -> std::io::Result<()> {
let path = target.join(PROOF_PATH);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
crate::atomic::write(path.as_std_path(), render(proof).as_bytes())
}
#[must_use]
pub fn judge(target: &Utf8Path, current: &Subject) -> Standing {
let path = target.join(PROOF_PATH);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Standing::Absent,
Err(err) => {
return Standing::Invalid {
reason: "unreadable",
detail: format!("{PROOF_PATH} is unreadable: {err}"),
};
}
};
let proof = match parse(&text) {
Ok(proof) => proof,
Err((reason, detail)) => return Standing::Invalid { reason, detail },
};
let differences = differences(&proof.subject, current);
if differences.is_empty() {
Standing::Compatible(proof)
} else {
Standing::Stale { proof, differences }
}
}
fn parse(text: &str) -> Result<Proof, (&'static str, String)> {
#[derive(Deserialize)]
struct Envelope {
schema: String,
}
let malformed = |err: serde_json::Error| {
(
"malformed",
format!("{PROOF_PATH} is not a setup proof: {err}"),
)
};
let envelope: Envelope = serde_json::from_str(text).map_err(malformed)?;
if envelope.schema != SCHEMA {
let newer = envelope
.schema
.strip_prefix(SCHEMA_FAMILY)
.and_then(|version| version.parse::<u32>().ok())
.is_some_and(|version| version > 1);
return Err(if newer {
(
"future-schema",
format!(
"{PROOF_PATH} is {}, newer than the {SCHEMA} this binary reads; a newer rk reads it",
envelope.schema
),
)
} else {
(
"unsupported-schema",
format!(
"{PROOF_PATH} declares {}, and this binary reads {SCHEMA}",
envelope.schema
),
)
});
}
let proof: Proof = serde_json::from_str(text).map_err(malformed)?;
if digest(&proof.subject) != proof.subject_digest {
return Err((
"digest-mismatch",
format!(
"{PROOF_PATH} names a subject digest its own subject does not produce, so the record was edited after it was written"
),
));
}
Ok(proof)
}
#[must_use]
pub fn differences(recorded: &Subject, current: &Subject) -> Vec<String> {
let mut found = Vec::new();
let keys: std::collections::BTreeSet<&String> = recorded
.target
.keys()
.chain(current.target.keys())
.collect();
for key in keys {
if recorded.target.get(key) != current.target.get(key) {
found.push(format!("target.{key}"));
}
}
for step in &recorded.steps {
match current.steps.iter().find(|now| now.name == step.name) {
None => found.push(format!("step {} is no longer in the step table", step.name)),
Some(now) => {
for (part, changed) in [
("proves", step.proves != now.proves),
("prerequisites", step.prerequisites != now.prerequisites),
(
"stance",
step.stance != now.stance || step.reason != now.reason,
),
] {
if changed {
found.push(format!("step {} {part}", step.name));
}
}
}
}
}
for now in ¤t.steps {
if !recorded.steps.iter().any(|step| step.name == now.name) {
found.push(format!("step {} is new", now.name));
}
}
found
}
#[derive(Debug, Serialize)]
pub struct StatusDocument {
pub schema: &'static str,
pub state: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub checkpoint: Option<Checkpoint>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub differences: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub steps: Vec<ProvenStep>,
#[serde(skip_serializing_if = "Option::is_none")]
pub invalid: Option<InvalidProof>,
}
#[derive(Debug, Serialize)]
pub struct Checkpoint {
pub rk_version: String,
pub verified_at: String,
pub subject_digest: String,
}
#[derive(Debug, Serialize)]
pub struct InvalidProof {
pub reason: &'static str,
pub detail: String,
}
impl StatusDocument {
#[must_use]
pub fn of(standing: &Standing) -> Self {
let mut document = Self {
schema: "rk.setup-status/1",
state: standing.word(),
checkpoint: None,
differences: Vec::new(),
steps: Vec::new(),
invalid: None,
};
let proof = match standing {
Standing::Absent => None,
Standing::Compatible(proof) => Some(proof),
Standing::Stale { proof, differences } => {
document.differences.clone_from(differences);
Some(proof)
}
Standing::Invalid { reason, detail } => {
document.invalid = Some(InvalidProof {
reason,
detail: detail.clone(),
});
None
}
};
if let Some(proof) = proof {
document.checkpoint = Some(Checkpoint {
rk_version: proof.rk_version.clone(),
verified_at: proof.verified_at.clone(),
subject_digest: proof.subject_digest.clone(),
});
document.steps.clone_from(&proof.steps);
}
document
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_subject() -> Subject {
Subject {
target: BTreeMap::from([
("forge".to_owned(), "github".to_owned()),
("repo".to_owned(), "acme/widget".to_owned()),
]),
steps: vec![SubjectStep {
name: "default-branch".to_owned(),
proves: "the trunk is the default branch".to_owned(),
prerequisites: Vec::new(),
stance: "applicable".to_owned(),
reason: String::new(),
}],
}
}
fn sample() -> Proof {
let subject = sample_subject();
Proof {
schema: SCHEMA.to_owned(),
rk_version: "0.0.0".to_owned(),
verified_at: "2026-09-27T00:00:00Z".to_owned(),
subject_digest: digest(&subject),
subject,
steps: vec![
ProvenStep {
name: "default-branch".to_owned(),
state: "satisfied".to_owned(),
limitation: None,
},
ProvenStep {
name: "auto-merge".to_owned(),
state: "satisfied-with-limitation".to_owned(),
limitation: Some("weaker".to_owned()),
},
],
}
}
#[test]
fn the_setup_proof_shape_is_held() {
let text = serde_json::to_string(&sample()).expect("serializes");
assert_eq!(
text,
format!(
r#"{{"schema":"rk.setup-proof/1","rk_version":"0.0.0","verified_at":"2026-09-27T00:00:00Z","subject":{{"target":{{"forge":"github","repo":"acme/widget"}},"steps":[{{"name":"default-branch","proves":"the trunk is the default branch","prerequisites":[],"stance":"applicable"}}]}},"subject_digest":"{}","steps":[{{"name":"default-branch","state":"satisfied"}},{{"name":"auto-merge","state":"satisfied-with-limitation","limitation":"weaker"}}]}}"#,
digest(&sample_subject())
)
);
}
#[test]
fn the_setup_status_shape_is_held() {
let absent = serde_json::to_string(&StatusDocument::of(&Standing::Absent)).expect("ok");
assert_eq!(absent, r#"{"schema":"rk.setup-status/1","state":"absent"}"#);
let stale = serde_json::to_string(&StatusDocument::of(&Standing::Stale {
proof: sample(),
differences: vec!["target.repo".to_owned()],
}))
.expect("ok");
assert_eq!(
stale,
format!(
r#"{{"schema":"rk.setup-status/1","state":"stale","checkpoint":{{"rk_version":"0.0.0","verified_at":"2026-09-27T00:00:00Z","subject_digest":"{}"}},"differences":["target.repo"],"steps":[{{"name":"default-branch","state":"satisfied"}},{{"name":"auto-merge","state":"satisfied-with-limitation","limitation":"weaker"}}]}}"#,
digest(&sample_subject())
)
);
let invalid = serde_json::to_string(&StatusDocument::of(&Standing::Invalid {
reason: "malformed",
detail: "not json".to_owned(),
}))
.expect("ok");
assert_eq!(
invalid,
r#"{"schema":"rk.setup-status/1","state":"invalid","invalid":{"reason":"malformed","detail":"not json"}}"#
);
}
#[test]
fn each_unreadable_proof_names_its_own_reason() {
let reason = |text: &str| match parse(text) {
Err((reason, _)) => reason,
Ok(proof) => panic!("{proof:?}"),
};
assert_eq!(reason("not json"), "malformed");
assert_eq!(reason(r#"{"schema":"rk.setup-proof/1"}"#), "malformed");
assert_eq!(reason(r#"{"schema":"rk.setup-proof/2"}"#), "future-schema");
assert_eq!(reason(r#"{"schema":"rk.status/12"}"#), "unsupported-schema");
let mut edited = sample();
edited
.subject
.target
.insert("repo".into(), "acme/other".into());
assert_eq!(reason(&render(&edited)), "digest-mismatch");
assert_eq!(parse(&render(&sample())), Ok(sample()));
}
#[test]
fn a_changed_contract_names_each_field() {
let recorded = sample_subject();
assert!(differences(&recorded, &recorded).is_empty());
let mut current = recorded.clone();
current.target.insert("repo".into(), "acme/other".into());
current.steps[0].proves = "something else".into();
current.steps[0].stance = "excluded".into();
assert_eq!(
differences(&recorded, ¤t),
[
"target.repo",
"step default-branch proves",
"step default-branch stance"
]
);
current.steps.clear();
assert!(
differences(&recorded, ¤t)
.contains(&"step default-branch is no longer in the step table".to_owned())
);
}
}