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#[must_use]
236pub fn release_intent(message: &str) -> Option<&'static str> {
237 let mut lines = message.lines();
238 let subject = lines.next()?.trim();
239 let (head, _) = subject.split_once(':')?;
240 let footer = lines
241 .any(|line| line.starts_with("BREAKING CHANGE:") || line.starts_with("BREAKING-CHANGE:"));
242 if head.ends_with('!') || footer {
243 return Some("a breaking change");
244 }
245 let kind = head.split_once('(').map_or(head, |(kind, _)| kind);
246 match kind {
247 "feat" => Some("a feat type"),
248 "fix" => Some("a fix type"),
249 _ => None,
250 }
251}
252
253#[must_use]
259pub fn uncounted_release(intent: &str, previewed: bool) -> String {
260 let changes = if previewed {
261 "the squash would change"
262 } else {
263 "the squash changes"
264 };
265 format!(
266 "the message states {intent} and release-plz drives this target's release, but {changes} no file cargo package --list prints for the crate, so release-plz will neither list the commit in the changelog nor count it toward a release: change a file the crate ships, or retype the message with a type that states no release intent, such as docs, chore, or ci"
267 )
268}
269
270#[cfg(test)]
271mod tests {
272 use super::{
273 Entry, Ledger, refuse_branch_name, refuse_moved_trunk, refuse_trunk_state, release_intent,
274 uncounted_release,
275 };
276
277 #[test]
278 fn release_intent_reads_the_type_the_bang_and_the_footer() {
279 assert_eq!(release_intent("feat(cli): add it"), Some("a feat type"));
280 assert_eq!(release_intent("fix(cli): mend it"), Some("a fix type"));
281 assert_eq!(
282 release_intent("docs(cli)!: rename it"),
283 Some("a breaking change")
284 );
285 assert_eq!(
286 release_intent("chore(cli): move it\n\nBREAKING CHANGE: the flag is gone"),
287 Some("a breaking change")
288 );
289 assert_eq!(
290 release_intent("chore(cli): move it\n\nBREAKING-CHANGE: the flag is gone"),
291 Some("a breaking change")
292 );
293 assert_eq!(release_intent("docs(cli): explain it"), None);
294 assert_eq!(release_intent("refactor(cli): tidy it"), None);
295 assert_eq!(release_intent("no shape at all"), None);
296 }
297
298 #[test]
299 fn the_uncounted_release_warning_names_both_fixes() {
300 let preview = uncounted_release("a feat type", true);
301 assert!(preview.contains("the squash would change"), "{preview}");
302 let applied = uncounted_release("a fix type", false);
303 assert!(applied.contains("the squash changes no file"), "{applied}");
304 for needle in [
305 "neither list the commit in the changelog nor count it toward a release",
306 "change a file the crate ships",
307 "retype the message",
308 ] {
309 assert!(applied.contains(needle), "{applied}");
310 }
311 }
312
313 fn entry(branch: &str, tip: &str) -> Entry {
314 Entry {
315 branch: branch.to_owned(),
316 branch_tip: tip.to_owned(),
317 trunk_commit: "c".repeat(40),
318 at: "2026-09-15T00:00:00Z".to_owned(),
319 }
320 }
321
322 #[test]
323 fn an_absent_ledger_reads_as_empty_and_proves_nothing() {
324 let ledger = Ledger::parse("").expect("absence is empty");
325 assert!(ledger.entries.is_empty());
326 assert_eq!(ledger.proof("feat/x", &"a".repeat(40)), None);
327 assert!(!ledger.names("feat/x"));
328 }
329
330 #[test]
331 fn a_ledger_round_trips_and_refuses_an_unknown_schema() {
332 let mut ledger = Ledger::default();
333 ledger.record(entry("feat/x", &"a".repeat(40)));
334 let text = ledger.render().expect("it serializes");
335 assert_eq!(Ledger::parse(&text).expect("it reads back"), ledger);
336 let error = Ledger::parse(r#"{"schema":"rk.integrations/99","entries":[]}"#)
337 .expect_err("a newer schema refuses");
338 assert!(error.contains("rk.integrations/1"), "{error}");
339 let error = Ledger::parse("{").expect_err("malformed content refuses");
340 assert!(error.contains("not readable"), "{error}");
341 }
342
343 #[test]
344 fn a_proof_needs_the_tip_the_integration_recorded() {
345 let mut ledger = Ledger::default();
346 let tip = "a".repeat(40);
347 ledger.record(entry("feat/x", &tip));
348 assert!(ledger.proof("feat/x", &tip).is_some());
349 assert_eq!(ledger.proof("feat/x", &"b".repeat(40)), None);
352 assert!(ledger.names("feat/x"));
353 assert_eq!(ledger.proof("feat/y", &tip), None);
355 }
356
357 #[test]
358 fn a_re_integration_replaces_its_predecessor() {
359 let mut ledger = Ledger::default();
360 ledger.record(entry("feat/x", &"a".repeat(40)));
361 ledger.record(entry("feat/x", &"b".repeat(40)));
362 assert_eq!(ledger.entries.len(), 1);
363 assert!(ledger.proof("feat/x", &"b".repeat(40)).is_some());
364 ledger.forget("feat/x");
365 assert!(ledger.entries.is_empty());
366 }
367
368 #[test]
369 fn the_trunk_and_a_misshapen_branch_each_refuse_by_name() {
370 assert!(
371 refuse_branch_name("master", "master")
372 .expect("the trunk refuses")
373 .contains("trunk")
374 );
375 let error = refuse_branch_name("wip", "master").expect("the grammar refuses");
376 assert!(error.contains("<type>/<slug>"), "{error}");
377 assert_eq!(refuse_branch_name("feat/x", "master"), None);
378 assert_eq!(refuse_branch_name("123-slug", "master"), None);
379 }
380
381 #[test]
382 fn only_a_diverged_trunk_refuses() {
383 use super::{TrunkState, trunk_state};
384 assert_eq!(trunk_state(true, false, false), TrunkState::Level);
387 assert_eq!(trunk_state(false, true, false), TrunkState::Behind);
388 assert_eq!(trunk_state(false, false, true), TrunkState::Ahead);
389 assert_eq!(trunk_state(false, false, false), TrunkState::Diverged);
390 for state in [TrunkState::Level, TrunkState::Behind, TrunkState::Ahead] {
391 assert_eq!(refuse_trunk_state(state), None, "{state:?}");
392 }
393 let error = refuse_trunk_state(TrunkState::Diverged).expect("divergence refuses");
394 assert!(error.contains("refuses rather than merging"), "{error}");
395 }
396
397 #[test]
398 fn a_trunk_that_moved_under_the_gate_refuses() {
399 assert_eq!(refuse_moved_trunk("a", "a"), None);
400 let error =
401 refuse_moved_trunk(&"a".repeat(40), &"b".repeat(40)).expect("a moved trunk refuses");
402 assert!(error.contains("aaaaaaa"), "{error}");
403 assert!(error.contains("bbbbbbb"), "{error}");
404 }
405}