tear_types/id.rs
1//! Typed identifiers — opaque, stable, BLAKE3-derived.
2//!
3//! Every entity in the tear universe (session, window, pane) carries
4//! a 64-bit identifier whose bytes are the first 8 of a BLAKE3 hash
5//! of a deterministic seed (creation timestamp + parent ID + monotonic
6//! counter). Two collisions per session are astronomically unlikely;
7//! across the fleet they're effectively impossible.
8//!
9//! Identifiers are `Copy`, `Eq`, `Hash`, `Ord` — usable as map keys
10//! and Vec indexes without trait gymnastics. `Display` produces a
11//! 16-character lowercase hex string (a la `git` short SHAs); `FromStr`
12//! parses it back. The same wire format crosses the daemon-RPC boundary
13//! so a `tear list` from one host produces IDs the next host can paste.
14
15use core::fmt;
16use core::str::FromStr;
17
18use serde::{Deserialize, Serialize};
19
20macro_rules! impl_typed_id {
21 ($(#[$meta:meta])* $name:ident) => {
22 $(#[$meta])*
23 #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
24 #[serde(transparent)]
25 pub struct $name(pub u64);
26
27 impl $name {
28 /// Mint a fresh ID from a seed string (commonly the creation
29 /// timestamp + parent + counter). Truncates the BLAKE3 hash
30 /// to 8 bytes — collision risk is negligible for the
31 /// session-scale population.
32 #[must_use]
33 pub fn from_seed(seed: &str) -> Self {
34 let h = blake3::hash(seed.as_bytes());
35 let bytes = h.as_bytes();
36 let id = u64::from_le_bytes([
37 bytes[0], bytes[1], bytes[2], bytes[3],
38 bytes[4], bytes[5], bytes[6], bytes[7],
39 ]);
40 Self(id)
41 }
42
43 /// The reserved "null" id — `0`. Distinct from any
44 /// from_seed() output because BLAKE3 never produces an
45 /// all-zero hash on a non-empty input.
46 pub const NULL: Self = Self(0);
47 }
48
49 impl fmt::Debug for $name {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 write!(f, concat!(stringify!($name), "({:016x})"), self.0)
52 }
53 }
54
55 impl fmt::Display for $name {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 write!(f, "{:016x}", self.0)
58 }
59 }
60
61 impl FromStr for $name {
62 type Err = anyhow::Error;
63 fn from_str(s: &str) -> Result<Self, Self::Err> {
64 let n = u64::from_str_radix(s, 16)
65 .map_err(|e| anyhow::anyhow!(concat!("invalid ", stringify!($name), ": {}"), e))?;
66 Ok(Self(n))
67 }
68 }
69 };
70}
71
72impl_typed_id!(
73 /// Identifier for a [`crate::TearSession`]. Stable across the
74 /// daemon's lifetime; round-trips through the wire format.
75 SessionId
76);
77impl_typed_id!(
78 /// Identifier for a [`crate::TearWindow`]. Window IDs are unique
79 /// within a session; the daemon namespaces them so two sessions
80 /// can hold windows with the same id without collision.
81 WindowId
82);
83impl_typed_id!(
84 /// Identifier for a [`crate::TearPane`]. Pane IDs are unique
85 /// within a window. mado renders panes addressing them by this
86 /// id; the multiplexer drives PTYs keyed by it.
87 PaneId
88);
89impl_typed_id!(
90 /// Stable identity of a session *definition* — the durable,
91 /// project-stable identity of a latent shape.
92 ///
93 /// Unlike [`InstanceId`] (a spawn-unique daemon handle that changes
94 /// every restart), a `DefinitionId` is the SAME across daemon
95 /// restarts and across hosts: its inner `u64` IS the
96 /// `ishou_tokens` `stable_seed` of the project root — the very seed
97 /// praça's name derivation uses — so a definition keeps its
98 /// identity, and therefore its display name, forever.
99 ///
100 /// The whole point of the type is that a `fn` taking a
101 /// `DefinitionId` cannot be handed an [`InstanceId`]: confusing the
102 /// durable identity of a *definition* with the ephemeral handle of a
103 /// *running instance* is a compile error (E0308), not a runtime mix-up.
104 DefinitionId
105);
106
107impl DefinitionId {
108 /// The definition id for a project root. Equal to the project's
109 /// `name_seed` by construction — `inner == stable_seed(root)`, the
110 /// exact derivation praça's [`SessionRecord`](../../praca) uses — so
111 /// a definition's identity and its display name share one seed and
112 /// migration from the old records is lossless.
113 #[must_use]
114 pub fn from_project(root: &std::path::Path) -> Self {
115 Self(ishou_tokens::fleet_session_names::stable_seed(
116 root.to_string_lossy().as_bytes(),
117 ))
118 }
119}
120
121/// The ephemeral, spawn-unique daemon handle for a *live* session
122/// incarnation — exactly today's [`SessionId`] (`BLAKE3(name + now +
123/// counter)`, non-stable across restart). Kept as a transparent alias so
124/// the wire format and every existing call site stay byte-identical; the
125/// name documents that this is the LIVE handle, distinct from a
126/// [`DefinitionId`]. A daemon restart mints a *new* `InstanceId` for the
127/// same definition — the handle is the incarnation, not the identity.
128pub type InstanceId = SessionId;
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn from_seed_is_deterministic() {
136 assert_eq!(PaneId::from_seed("foo"), PaneId::from_seed("foo"));
137 assert_ne!(PaneId::from_seed("foo"), PaneId::from_seed("bar"));
138 }
139
140 #[test]
141 fn display_round_trips_through_from_str() {
142 let id = SessionId::from_seed("session-7");
143 let s = id.to_string();
144 assert_eq!(s.len(), 16);
145 let parsed: SessionId = s.parse().unwrap();
146 assert_eq!(id, parsed);
147 }
148
149 #[test]
150 fn null_is_distinct_from_any_seeded_id() {
151 let seeded = WindowId::from_seed("test");
152 assert_ne!(WindowId::NULL, seeded);
153 assert_eq!(WindowId::NULL.0, 0);
154 }
155
156 #[test]
157 fn definition_id_inner_is_the_project_name_seed() {
158 // Migration losslessness: DefinitionId(root).0 is BYTE-EXACTLY the
159 // `name_seed` praça's SessionRecord stores
160 // (stable_seed(root.to_string_lossy())). So an old record upgrades
161 // to a definition with its identity == its existing name_seed —
162 // the name never changes across the migration.
163 let root = std::path::Path::new("/code/pleme-io/mado");
164 let expected = ishou_tokens::fleet_session_names::stable_seed(
165 root.to_string_lossy().as_bytes(),
166 );
167 assert_eq!(DefinitionId::from_project(root).0, expected);
168 }
169
170 #[test]
171 fn definition_id_is_restart_and_call_stable() {
172 // Unlike a spawn-minted InstanceId, the same project root always
173 // yields the same DefinitionId — that's the whole point of the
174 // durable-vs-ephemeral split.
175 let root = std::path::Path::new("/x/y/z");
176 assert_eq!(DefinitionId::from_project(root), DefinitionId::from_project(root));
177 }
178
179 #[test]
180 fn instance_id_is_session_id_alias() {
181 // InstanceId is the SAME type as SessionId (zero wire churn) — they
182 // unify, so a SessionId flows wherever an InstanceId is expected.
183 let s: SessionId = SessionId(7);
184 let i: InstanceId = s;
185 assert_eq!(i.0, 7);
186 }
187}