Skip to main content

tatara_ui/
event.rs

1//! `UiEvent` — every runtime event that the renderer may paint.
2//!
3//! Events serialize to tatara-lisp S-expressions, so a stream of events is
4//! itself content-addressable: BLAKE3 over the canonical JSON of the stream
5//! gives you a **run-identity hash** that `tatara replay <hash>` can use to
6//! reproduce the exact Nord output of a past invocation.
7
8use serde::{Deserialize, Serialize};
9
10use crate::palette::Role;
11
12/// 7-character BLAKE3 prefix, rendered in dim next to every artifact line.
13#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct ShortHash(pub String);
15
16impl ShortHash {
17    pub fn from_blake3_hex(full: &str) -> Self {
18        Self(full.chars().take(7).collect())
19    }
20}
21
22impl std::fmt::Display for ShortHash {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        f.write_str(&self.0)
25    }
26}
27
28/// Every paintable thing the toolchain does. Variants are deliberately
29/// small — the renderer owns the prose so themes control every word.
30#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(tag = "kind", rename_all = "kebab-case")]
32pub enum UiEvent {
33    /// The hero banner. `❄ tatara — <title>` …
34    Banner {
35        title: String,
36        subtitle: Option<String>,
37    },
38    /// Section divider. `⟡ <title>` with an underline of dim glyphs.
39    Section { title: String },
40    /// Tagged log line. `level` picks the sigil + color.
41    Log { level: LogLevel, message: String },
42    /// Phase start — begins a timed scope. `(realize/begin …)` in the stream.
43    PhaseBegin { phase: String },
44    /// Phase finish. `elapsed_ms` is what we paint next to the sigil.
45    PhaseEnd { phase: String, elapsed_ms: u64 },
46    /// An artifact line — `❄ name  [blake3:xxxxxxx]  <state>`.
47    Artifact {
48        name: String,
49        hash: ShortHash,
50        state: ArtifactState,
51    },
52    /// Summary / content-root banner at the end of a run.
53    Summary {
54        root_hash: ShortHash,
55        total: usize,
56        built: usize,
57        cached: usize,
58        failed: usize,
59    },
60    /// Free-form key/value table row — for `tatara cache show` and friends.
61    Row { cells: Vec<Cell> },
62}
63
64#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "kebab-case")]
66pub enum LogLevel {
67    Info,
68    Success,
69    Warn,
70    Error,
71    Dim,
72}
73
74impl LogLevel {
75    pub fn role(self) -> Role {
76        match self {
77            Self::Info => Role::Info,
78            Self::Success => Role::Success,
79            Self::Warn => Role::Warn,
80            Self::Error => Role::Error,
81            Self::Dim => Role::Dim,
82        }
83    }
84}
85
86/// Cache-aware artifact state — the "fun" in "cachable declarative systems".
87#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(tag = "state", rename_all = "kebab-case")]
89pub enum ArtifactState {
90    /// Freshly built — prints elapsed time in a Clock sigil.
91    Built { elapsed_ms: u64 },
92    /// Cache hit — prints a Lightning sigil and no elapsed time.
93    Cached,
94    /// Queued but not yet started — prints a hollow dot.
95    Pending,
96    /// Build failure — prints a Cross sigil.
97    Failed { reason: String },
98}
99
100impl ArtifactState {
101    pub fn label(&self) -> &'static str {
102        match self {
103            Self::Built { .. } => "built",
104            Self::Cached => "cached",
105            Self::Pending => "pending",
106            Self::Failed { .. } => "failed",
107        }
108    }
109}
110
111#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
112pub struct Cell {
113    pub text: String,
114    #[serde(default)]
115    pub role: Option<Role>,
116}
117
118impl Cell {
119    pub fn plain(text: impl Into<String>) -> Self {
120        Self {
121            text: text.into(),
122            role: None,
123        }
124    }
125
126    pub fn with_role(text: impl Into<String>, role: Role) -> Self {
127        Self {
128            text: text.into(),
129            role: Some(role),
130        }
131    }
132}
133
134/// The ordered log a runner accumulates while working. Serializable for
135/// `tatara replay <hash>` — the whole stream is content-addressable.
136#[derive(Clone, Debug, Default, Serialize, Deserialize)]
137pub struct EventStream {
138    pub events: Vec<UiEvent>,
139}
140
141impl EventStream {
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    pub fn push(&mut self, e: UiEvent) {
147        self.events.push(e);
148    }
149
150    /// BLAKE3 of the canonical JSON — the run-identity hash.
151    pub fn run_hash(&self) -> String {
152        let bytes = serde_json::to_vec(self).unwrap_or_default();
153        hex::encode(blake3::hash(&bytes).as_bytes())
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn short_hash_is_seven_chars() {
163        let sh = ShortHash::from_blake3_hex("cxx3i50lvlprhlqclm1mxmnp77bawjbx-fake-ignored");
164        assert_eq!(sh.0.len(), 7);
165        assert_eq!(sh.to_string(), "cxx3i50");
166    }
167
168    #[test]
169    fn artifact_state_labels() {
170        assert_eq!(ArtifactState::Cached.label(), "cached");
171        assert_eq!(ArtifactState::Built { elapsed_ms: 100 }.label(), "built");
172    }
173
174    #[test]
175    fn stream_run_hash_is_deterministic() {
176        let mut s = EventStream::new();
177        s.push(UiEvent::Section {
178            title: "boot".into(),
179        });
180        s.push(UiEvent::Log {
181            level: LogLevel::Info,
182            message: "hello".into(),
183        });
184        let h1 = s.run_hash();
185        let h2 = s.run_hash();
186        assert_eq!(h1, h2);
187        assert_eq!(h1.len(), 64);
188    }
189}