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