mermaid_cli/domain/ids.rs
1//! Typed identifiers used throughout the reducer.
2//!
3//! These are nonces, not indices — the reducer uses them to drop stale
4//! effect results that arrive after the turn they belong to has been
5//! superseded. `TurnId` is the most important: every `Msg` that carries
6//! effect output tags itself with the `TurnId` of the turn that produced
7//! it; the reducer compares against `state.turn.id()` and ignores any
8//! mismatch. That turns the whole "stale stream event fires after the
9//! user cancelled" class of bugs into a type-level non-issue.
10//!
11//! None of these types do anything clever. They wrap `u64` so they're
12//! Copy + Ord + serializable (useful for `--record` / `--replay`), and
13//! they're newtypes so the type system catches accidental swaps
14//! (a `ToolCallId` can't be used where a `TurnId` is expected).
15
16use serde::{Deserialize, Serialize};
17use std::fmt;
18
19/// One "turn" = one user prompt + the entire model+tools cascade that
20/// follows, ending when the reducer returns to `TurnState::Idle`. A
21/// `CancelTurn` ends the current turn immediately (after cleanup
22/// effects dispatch); the next prompt starts a fresh `TurnId`.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
24pub struct TurnId(pub u64);
25
26impl TurnId {
27 pub const ZERO: Self = Self(0);
28}
29
30impl fmt::Display for TurnId {
31 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32 write!(f, "turn#{}", self.0)
33 }
34}
35
36/// Stable identifier for a single tool call inside a turn. The reducer
37/// uses this to match `ToolFinished` results back to the slot in
38/// `TurnState::ExecutingTools::outcomes` so results can land out of
39/// order without ambiguity.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
41pub struct ToolCallId(pub u64);
42
43impl fmt::Display for ToolCallId {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 write!(f, "tool#{}", self.0)
46 }
47}
48
49/// Monotonic ID allocator. `State` owns one of these per "kind" (turn,
50/// tool call) and hands out fresh IDs by incrementing. Reset happens
51/// only on a full session replay.
52#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
53pub struct IdAllocator {
54 next: u64,
55}
56
57impl Default for IdAllocator {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63impl IdAllocator {
64 /// Start at 1 so `TurnId::ZERO` / `ToolCallId(0)` stay reserved as
65 /// sentinel values — no real allocation ever collides with them.
66 pub const fn new() -> Self {
67 Self { next: 1 }
68 }
69
70 /// Hand out the next ID.
71 #[allow(clippy::should_implement_trait)]
72 pub fn next(&mut self) -> u64 {
73 let id = self.next;
74 self.next = self.next.saturating_add(1);
75 id
76 }
77
78 /// Reset to 1. Used by `--replay` when loading a fresh log.
79 pub fn reset(&mut self) {
80 self.next = 1;
81 }
82
83 /// Peek without advancing.
84 pub fn peek(&self) -> u64 {
85 self.next
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn id_allocator_hands_out_monotonic_ids() {
95 let mut alloc = IdAllocator::new();
96 assert_eq!(alloc.next(), 1);
97 assert_eq!(alloc.next(), 2);
98 assert_eq!(alloc.next(), 3);
99 }
100
101 #[test]
102 fn id_allocator_reset_starts_from_one() {
103 let mut alloc = IdAllocator::new();
104 alloc.next();
105 alloc.next();
106 alloc.reset();
107 assert_eq!(alloc.next(), 1);
108 }
109
110 #[test]
111 fn id_types_are_distinct_at_the_type_level() {
112 // Compile-time check: can't accidentally pass a ToolCallId where
113 // a TurnId is expected. No `From` impl between them.
114 fn only_turn(_: TurnId) {}
115 only_turn(TurnId(1));
116 // only_turn(ToolCallId(1)); // would fail to compile — correct
117 }
118
119 #[test]
120 fn turn_id_display_format() {
121 assert_eq!(format!("{}", TurnId(42)), "turn#42");
122 assert_eq!(format!("{}", ToolCallId(7)), "tool#7");
123 }
124
125 #[test]
126 fn ids_serialize_as_bare_numbers() {
127 // Wrapped u64 means JSON is just the number — keeps the replay
128 // log readable and small.
129 let json = serde_json::to_string(&TurnId(7)).unwrap();
130 assert_eq!(json, "7");
131 }
132}