1use serde::{Deserialize, Serialize};
19
20pub const LEDGER_PATH: &str = "rk/integrations.json";
22
23const LEDGER_SCHEMA: &str = "rk.integrations/1";
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Entry {
29 pub branch: String,
31 pub branch_tip: String,
35 pub trunk_commit: String,
37 pub at: String,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct Ledger {
44 #[serde(default = "ledger_schema")]
46 pub schema: String,
47 #[serde(default)]
49 pub entries: Vec<Entry>,
50}
51
52fn ledger_schema() -> String {
53 LEDGER_SCHEMA.to_owned()
54}
55
56impl Default for Ledger {
57 fn default() -> Self {
58 Self {
59 schema: ledger_schema(),
60 entries: Vec::new(),
61 }
62 }
63}
64
65impl Ledger {
66 pub fn parse(text: &str) -> Result<Self, String> {
79 if text.trim().is_empty() {
80 return Ok(Self::default());
81 }
82 let ledger: Self = serde_json::from_str(text)
83 .map_err(|source| format!("the integration ledger is not readable: {source}"))?;
84 if ledger.schema != LEDGER_SCHEMA {
85 return Err(format!(
86 "the integration ledger declares schema {}, and this binary knows {LEDGER_SCHEMA}",
87 ledger.schema
88 ));
89 }
90 Ok(ledger)
91 }
92
93 pub fn render(&self) -> Result<String, String> {
99 let mut text = serde_json::to_string_pretty(self)
100 .map_err(|source| format!("the integration ledger does not serialize: {source}"))?;
101 text.push('\n');
102 Ok(text)
103 }
104
105 pub fn record(&mut self, entry: Entry) {
109 self.entries.retain(|held| held.branch != entry.branch);
110 self.entries.push(entry);
111 }
112
113 #[must_use]
119 pub fn proof(&self, branch: &str, tip: &str) -> Option<&Entry> {
120 self.entries
121 .iter()
122 .find(|entry| entry.branch == branch && entry.branch_tip == tip)
123 }
124
125 #[must_use]
129 pub fn names(&self, branch: &str) -> bool {
130 self.entries.iter().any(|entry| entry.branch == branch)
131 }
132
133 pub fn forget(&mut self, branch: &str) {
135 self.entries.retain(|entry| entry.branch != branch);
136 }
137}
138
139#[must_use]
148pub fn refuse_branch_name(branch: &str, trunk: &str) -> Option<String> {
149 if branch == trunk {
150 return Some(format!(
151 "{branch} is the trunk; integration moves a short-lived branch onto it"
152 ));
153 }
154 if !crate::worktree::matches_grammar(branch) {
155 return Some(format!(
156 "{branch} is neither <type>/<slug> nor <issue-id>-<slug>, so no landed hook would admit its commits"
157 ));
158 }
159 None
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum TrunkState {
165 Level,
167 Behind,
169 Ahead,
172 Diverged,
174}
175
176#[must_use]
178pub const fn trunk_state(
179 level: bool,
180 local_reaches_remote: bool,
181 remote_reaches_local: bool,
182) -> TrunkState {
183 if level {
184 TrunkState::Level
185 } else if local_reaches_remote {
186 TrunkState::Behind
187 } else if remote_reaches_local {
188 TrunkState::Ahead
189 } else {
190 TrunkState::Diverged
191 }
192}
193
194#[must_use]
203pub fn refuse_trunk_state(state: TrunkState) -> Option<String> {
204 matches!(state, TrunkState::Diverged).then(|| {
205 "the local trunk and its remote diverged; neither reaches the other, so this command \
206 refuses rather than merging them"
207 .to_owned()
208 })
209}
210
211#[must_use]
213pub fn refuse_moved_trunk(before: &str, now: &str) -> Option<String> {
214 (before != now).then(|| {
215 format!(
216 "the trunk moved from {} to {} while the gate ran, so the gate judged a trunk that is gone",
217 short(before),
218 short(now)
219 )
220 })
221}
222
223#[must_use]
225pub fn short(oid: &str) -> String {
226 oid.chars().take(7).collect()
227}
228
229#[cfg(test)]
230mod tests {
231 use super::{Entry, Ledger, refuse_branch_name, refuse_moved_trunk, refuse_trunk_state};
232
233 fn entry(branch: &str, tip: &str) -> Entry {
234 Entry {
235 branch: branch.to_owned(),
236 branch_tip: tip.to_owned(),
237 trunk_commit: "c".repeat(40),
238 at: "2026-09-15T00:00:00Z".to_owned(),
239 }
240 }
241
242 #[test]
243 fn an_absent_ledger_reads_as_empty_and_proves_nothing() {
244 let ledger = Ledger::parse("").expect("absence is empty");
245 assert!(ledger.entries.is_empty());
246 assert_eq!(ledger.proof("feat/x", &"a".repeat(40)), None);
247 assert!(!ledger.names("feat/x"));
248 }
249
250 #[test]
251 fn a_ledger_round_trips_and_refuses_an_unknown_schema() {
252 let mut ledger = Ledger::default();
253 ledger.record(entry("feat/x", &"a".repeat(40)));
254 let text = ledger.render().expect("it serializes");
255 assert_eq!(Ledger::parse(&text).expect("it reads back"), ledger);
256 let error = Ledger::parse(r#"{"schema":"rk.integrations/99","entries":[]}"#)
257 .expect_err("a newer schema refuses");
258 assert!(error.contains("rk.integrations/1"), "{error}");
259 let error = Ledger::parse("{").expect_err("malformed content refuses");
260 assert!(error.contains("not readable"), "{error}");
261 }
262
263 #[test]
264 fn a_proof_needs_the_tip_the_integration_recorded() {
265 let mut ledger = Ledger::default();
266 let tip = "a".repeat(40);
267 ledger.record(entry("feat/x", &tip));
268 assert!(ledger.proof("feat/x", &tip).is_some());
269 assert_eq!(ledger.proof("feat/x", &"b".repeat(40)), None);
272 assert!(ledger.names("feat/x"));
273 assert_eq!(ledger.proof("feat/y", &tip), None);
275 }
276
277 #[test]
278 fn a_re_integration_replaces_its_predecessor() {
279 let mut ledger = Ledger::default();
280 ledger.record(entry("feat/x", &"a".repeat(40)));
281 ledger.record(entry("feat/x", &"b".repeat(40)));
282 assert_eq!(ledger.entries.len(), 1);
283 assert!(ledger.proof("feat/x", &"b".repeat(40)).is_some());
284 ledger.forget("feat/x");
285 assert!(ledger.entries.is_empty());
286 }
287
288 #[test]
289 fn the_trunk_and_a_misshapen_branch_each_refuse_by_name() {
290 assert!(
291 refuse_branch_name("master", "master")
292 .expect("the trunk refuses")
293 .contains("trunk")
294 );
295 let error = refuse_branch_name("wip", "master").expect("the grammar refuses");
296 assert!(error.contains("<type>/<slug>"), "{error}");
297 assert_eq!(refuse_branch_name("feat/x", "master"), None);
298 assert_eq!(refuse_branch_name("123-slug", "master"), None);
299 }
300
301 #[test]
302 fn only_a_diverged_trunk_refuses() {
303 use super::{TrunkState, trunk_state};
304 assert_eq!(trunk_state(true, false, false), TrunkState::Level);
307 assert_eq!(trunk_state(false, true, false), TrunkState::Behind);
308 assert_eq!(trunk_state(false, false, true), TrunkState::Ahead);
309 assert_eq!(trunk_state(false, false, false), TrunkState::Diverged);
310 for state in [TrunkState::Level, TrunkState::Behind, TrunkState::Ahead] {
311 assert_eq!(refuse_trunk_state(state), None, "{state:?}");
312 }
313 let error = refuse_trunk_state(TrunkState::Diverged).expect("divergence refuses");
314 assert!(error.contains("refuses rather than merging"), "{error}");
315 }
316
317 #[test]
318 fn a_trunk_that_moved_under_the_gate_refuses() {
319 assert_eq!(refuse_moved_trunk("a", "a"), None);
320 let error =
321 refuse_moved_trunk(&"a".repeat(40), &"b".repeat(40)).expect("a moved trunk refuses");
322 assert!(error.contains("aaaaaaa"), "{error}");
323 assert!(error.contains("bbbbbbb"), "{error}");
324 }
325}