use serde::{Deserialize, Serialize};
pub const LEDGER_PATH: &str = "rk/integrations.json";
const LEDGER_SCHEMA: &str = "rk.integrations/1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Entry {
pub branch: String,
pub branch_tip: String,
pub trunk_commit: String,
pub at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ledger {
#[serde(default = "ledger_schema")]
pub schema: String,
#[serde(default)]
pub entries: Vec<Entry>,
}
fn ledger_schema() -> String {
LEDGER_SCHEMA.to_owned()
}
impl Default for Ledger {
fn default() -> Self {
Self {
schema: ledger_schema(),
entries: Vec::new(),
}
}
}
impl Ledger {
pub fn parse(text: &str) -> Result<Self, String> {
if text.trim().is_empty() {
return Ok(Self::default());
}
let ledger: Self = serde_json::from_str(text)
.map_err(|source| format!("the integration ledger is not readable: {source}"))?;
if ledger.schema != LEDGER_SCHEMA {
return Err(format!(
"the integration ledger declares schema {}, and this binary knows {LEDGER_SCHEMA}",
ledger.schema
));
}
Ok(ledger)
}
pub fn render(&self) -> Result<String, String> {
let mut text = serde_json::to_string_pretty(self)
.map_err(|source| format!("the integration ledger does not serialize: {source}"))?;
text.push('\n');
Ok(text)
}
pub fn record(&mut self, entry: Entry) {
self.entries.retain(|held| held.branch != entry.branch);
self.entries.push(entry);
}
#[must_use]
pub fn proof(&self, branch: &str, tip: &str) -> Option<&Entry> {
self.entries
.iter()
.find(|entry| entry.branch == branch && entry.branch_tip == tip)
}
#[must_use]
pub fn names(&self, branch: &str) -> bool {
self.entries.iter().any(|entry| entry.branch == branch)
}
pub fn forget(&mut self, branch: &str) {
self.entries.retain(|entry| entry.branch != branch);
}
}
#[must_use]
pub fn refuse_branch_name(branch: &str, trunk: &str) -> Option<String> {
if branch == trunk {
return Some(format!(
"{branch} is the trunk; integration moves a short-lived branch onto it"
));
}
if !crate::worktree::matches_grammar(branch) {
return Some(format!(
"{branch} is neither <type>/<slug> nor <issue-id>-<slug>, so no landed hook would admit its commits"
));
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrunkState {
Level,
Behind,
Ahead,
Diverged,
}
#[must_use]
pub const fn trunk_state(
level: bool,
local_reaches_remote: bool,
remote_reaches_local: bool,
) -> TrunkState {
if level {
TrunkState::Level
} else if local_reaches_remote {
TrunkState::Behind
} else if remote_reaches_local {
TrunkState::Ahead
} else {
TrunkState::Diverged
}
}
#[must_use]
pub fn refuse_trunk_state(state: TrunkState) -> Option<String> {
matches!(state, TrunkState::Diverged).then(|| {
"the local trunk and its remote diverged; neither reaches the other, so this command \
refuses rather than merging them"
.to_owned()
})
}
#[must_use]
pub fn refuse_moved_trunk(before: &str, now: &str) -> Option<String> {
(before != now).then(|| {
format!(
"the trunk moved from {} to {} while the gate ran, so the gate judged a trunk that is gone",
short(before),
short(now)
)
})
}
#[must_use]
pub fn short(oid: &str) -> String {
oid.chars().take(7).collect()
}
#[cfg(test)]
mod tests {
use super::{Entry, Ledger, refuse_branch_name, refuse_moved_trunk, refuse_trunk_state};
fn entry(branch: &str, tip: &str) -> Entry {
Entry {
branch: branch.to_owned(),
branch_tip: tip.to_owned(),
trunk_commit: "c".repeat(40),
at: "2026-09-15T00:00:00Z".to_owned(),
}
}
#[test]
fn an_absent_ledger_reads_as_empty_and_proves_nothing() {
let ledger = Ledger::parse("").expect("absence is empty");
assert!(ledger.entries.is_empty());
assert_eq!(ledger.proof("feat/x", &"a".repeat(40)), None);
assert!(!ledger.names("feat/x"));
}
#[test]
fn a_ledger_round_trips_and_refuses_an_unknown_schema() {
let mut ledger = Ledger::default();
ledger.record(entry("feat/x", &"a".repeat(40)));
let text = ledger.render().expect("it serializes");
assert_eq!(Ledger::parse(&text).expect("it reads back"), ledger);
let error = Ledger::parse(r#"{"schema":"rk.integrations/99","entries":[]}"#)
.expect_err("a newer schema refuses");
assert!(error.contains("rk.integrations/1"), "{error}");
let error = Ledger::parse("{").expect_err("malformed content refuses");
assert!(error.contains("not readable"), "{error}");
}
#[test]
fn a_proof_needs_the_tip_the_integration_recorded() {
let mut ledger = Ledger::default();
let tip = "a".repeat(40);
ledger.record(entry("feat/x", &tip));
assert!(ledger.proof("feat/x", &tip).is_some());
assert_eq!(ledger.proof("feat/x", &"b".repeat(40)), None);
assert!(ledger.names("feat/x"));
assert_eq!(ledger.proof("feat/y", &tip), None);
}
#[test]
fn a_re_integration_replaces_its_predecessor() {
let mut ledger = Ledger::default();
ledger.record(entry("feat/x", &"a".repeat(40)));
ledger.record(entry("feat/x", &"b".repeat(40)));
assert_eq!(ledger.entries.len(), 1);
assert!(ledger.proof("feat/x", &"b".repeat(40)).is_some());
ledger.forget("feat/x");
assert!(ledger.entries.is_empty());
}
#[test]
fn the_trunk_and_a_misshapen_branch_each_refuse_by_name() {
assert!(
refuse_branch_name("master", "master")
.expect("the trunk refuses")
.contains("trunk")
);
let error = refuse_branch_name("wip", "master").expect("the grammar refuses");
assert!(error.contains("<type>/<slug>"), "{error}");
assert_eq!(refuse_branch_name("feat/x", "master"), None);
assert_eq!(refuse_branch_name("123-slug", "master"), None);
}
#[test]
fn only_a_diverged_trunk_refuses() {
use super::{TrunkState, trunk_state};
assert_eq!(trunk_state(true, false, false), TrunkState::Level);
assert_eq!(trunk_state(false, true, false), TrunkState::Behind);
assert_eq!(trunk_state(false, false, true), TrunkState::Ahead);
assert_eq!(trunk_state(false, false, false), TrunkState::Diverged);
for state in [TrunkState::Level, TrunkState::Behind, TrunkState::Ahead] {
assert_eq!(refuse_trunk_state(state), None, "{state:?}");
}
let error = refuse_trunk_state(TrunkState::Diverged).expect("divergence refuses");
assert!(error.contains("refuses rather than merging"), "{error}");
}
#[test]
fn a_trunk_that_moved_under_the_gate_refuses() {
assert_eq!(refuse_moved_trunk("a", "a"), None);
let error =
refuse_moved_trunk(&"a".repeat(40), &"b".repeat(40)).expect("a moved trunk refuses");
assert!(error.contains("aaaaaaa"), "{error}");
assert!(error.contains("bbbbbbb"), "{error}");
}
}