Skip to main content

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    /// Start handing out IDs from `next`. Used when resuming a conversation to
71    /// continue a global counter past the highest value already persisted (e.g.
72    /// image numbers), so a resumed session never re-issues a number that an
73    /// earlier message already used.
74    pub const fn starting_at(next: u64) -> Self {
75        Self { next }
76    }
77
78    /// Hand out the next ID.
79    #[allow(clippy::should_implement_trait)]
80    pub fn next(&mut self) -> u64 {
81        let id = self.next;
82        self.next = self.next.saturating_add(1);
83        id
84    }
85
86    /// Reset to 1. Used by `--replay` when loading a fresh log.
87    pub fn reset(&mut self) {
88        self.next = 1;
89    }
90
91    /// Peek without advancing.
92    pub fn peek(&self) -> u64 {
93        self.next
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn id_allocator_hands_out_monotonic_ids() {
103        let mut alloc = IdAllocator::new();
104        assert_eq!(alloc.next(), 1);
105        assert_eq!(alloc.next(), 2);
106        assert_eq!(alloc.next(), 3);
107    }
108
109    #[test]
110    fn id_allocator_reset_starts_from_one() {
111        let mut alloc = IdAllocator::new();
112        alloc.next();
113        alloc.next();
114        alloc.reset();
115        assert_eq!(alloc.next(), 1);
116    }
117
118    #[test]
119    fn id_types_are_distinct_at_the_type_level() {
120        // Compile-time check: can't accidentally pass a ToolCallId where
121        // a TurnId is expected. No `From` impl between them.
122        fn only_turn(_: TurnId) {}
123        only_turn(TurnId(1));
124        // only_turn(ToolCallId(1));  // would fail to compile — correct
125    }
126
127    #[test]
128    fn turn_id_display_format() {
129        assert_eq!(format!("{}", TurnId(42)), "turn#42");
130        assert_eq!(format!("{}", ToolCallId(7)), "tool#7");
131    }
132
133    #[test]
134    fn ids_serialize_as_bare_numbers() {
135        // Wrapped u64 means JSON is just the number — keeps the replay
136        // log readable and small.
137        let json = serde_json::to_string(&TurnId(7)).unwrap();
138        assert_eq!(json, "7");
139    }
140}