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    /// 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}