Skip to main content

rite_runtime/
display.rs

1//! Line-oriented rendering of protocol events for live frontends.
2//!
3//! The console driver and the TUI both surface the same `StepFact` and
4//! `UiSignal` stream; this module is the single source of truth for the
5//! short one-line summary each variant produces. Frontends pick how to
6//! style and place the line (stdout vs ratatui log feed), but the icon
7//! and text are aligned.
8//!
9//! Returns `None` for variants intentionally suppressed from the live
10//! feed (currently `StepFact::PromptAnswered`, since the prompt itself
11//! was already surfaced by `AwaitPrompt` and the operator typed the
12//! answer in front of them).
13
14use rite_model::{ErrorClass, StepFact};
15
16use crate::protocol::{Icon, UiSignal};
17
18/// What a fact with no summary of its own falls back to.
19const UNSUMMARISED: &str = "unknown fact variant";
20
21/// One-line summary of a [`StepFact`] for live-frontend display.
22///
23/// Returns `None` for facts the live UI shouldn't surface:
24/// - `PromptAnswered`: the operator just typed it.
25/// - `BackendOperation` / `AttestationRecorded` / `MachineInfoRecorded`: the
26///   surrounding action handler already calls `Reporter::log` with its own
27///   narrative line.
28/// - `CeremonyCompleted`: the frontend renders a dedicated completion
29///   screen with the fingerprint.
30#[must_use]
31pub fn fact_summary(fact: &StepFact) -> Option<(Icon, String)> {
32    match fact {
33        StepFact::CeremonyStarted { name, .. } => Some((Icon::Info, format!("Ceremony: {name}"))),
34        StepFact::ActStarted { label, .. } => Some((Icon::Info, format!("Act: {label}"))),
35        StepFact::StepStarted { id, label, role } => {
36            Some((Icon::Info, format!("Step {label} ({id}), role: {role}")))
37        }
38        StepFact::PromptAnswered { .. }
39        | StepFact::BackendOperation { .. }
40        | StepFact::AttestationRecorded { .. }
41        | StepFact::MachineInfoRecorded { .. }
42        // Roles show by name on each step, where a frontend that keeps the
43        // declarations resolves them.
44        | StepFact::RoleDeclared { .. }
45        | StepFact::CeremonyCompleted { .. } => None,
46        StepFact::RoleAssigned { role, person } => {
47            Some((Icon::Info, format!("Role {role}: {person}")))
48        }
49        StepFact::ParameterBound { name, value } => {
50            Some((Icon::Info, format!("Parameter {name} = {value}")))
51        }
52        StepFact::MaterialLoaded { name, identifier } => Some((
53            Icon::Checkmark,
54            match identifier {
55                Some(identifier) => format!("Material loaded: {name} ({identifier})"),
56                None => format!("Material loaded: {name}"),
57            },
58        )),
59        StepFact::MaterialDigest { name, digest } => {
60            Some((Icon::Info, format!("Material {name}: {digest}")))
61        }
62        StepFact::BackendBound { name, identity, .. } => {
63            Some((Icon::Info, format!("Backend {name}: {identity}")))
64        }
65        StepFact::ArtifactWritten { file, .. } => Some((
66            Icon::Checkmark,
67            format!("Artifact written: artifacts/{file}"),
68        )),
69        StepFact::DeviationRecorded { text, .. } => {
70            Some((Icon::Warning, format!("Deviation: {text}")))
71        }
72        StepFact::StepCompleted { outcome, .. } => match outcome {
73            rite_model::StepOutcome::Completed { message } => {
74                Some((Icon::Checkmark, message.clone()))
75            }
76            _ => Some((Icon::Checkmark, "Step completed".to_string())),
77        },
78        StepFact::StepAttemptFailed { attempt, error, .. } => Some((
79            Icon::Cross,
80            format!("Attempt {attempt} failed: {}", error.message),
81        )),
82        StepFact::CeremonyFailed { error, .. } => match error.class {
83            // An abort is a deliberate operator decision, not a failure: show it
84            // neutrally rather than with the failure cross.
85            ErrorClass::Abort => Some((Icon::Info, error.message.clone())),
86            _ => Some((Icon::Cross, format!("Ceremony failed: {}", error.message))),
87        },
88        StepFact::EntropySeeded { source, .. } => {
89            Some((Icon::Info, format!("Entropy source seeded ({source})")))
90        }
91        StepFact::EntropyContributed { epoch, .. } => Some((
92            Icon::Info,
93            format!("Entropy contribution folded (epoch {epoch})"),
94        )),
95        StepFact::EntropyDrawn { path, .. } => {
96            Some((Icon::Info, format!("Random value drawn: {path}")))
97        }
98        // Who the wrap is addressed to is the operator's last chance to stop a
99        // wrap they cannot undo, so the fingerprint is shown, not summarised
100        // away.
101        StepFact::WrapRecipientRecorded {
102            source,
103            fingerprint,
104            declared,
105            ..
106        } => Some((
107            Icon::Info,
108            if *declared {
109                format!("Recipient {source} matches the declared {fingerprint}")
110            } else {
111                format!("Recipient {source} is {fingerprint}, undeclared")
112            },
113        )),
114        // `StepFact` is `#[non_exhaustive]`, so this arm is unavoidable in a
115        // crate downstream of the one defining it. A variant reaching it is a
116        // variant added without a line of its own, which an operator sees
117        // mid-ceremony; give every new variant an arm above.
118        _ => Some((Icon::Info, UNSUMMARISED.to_string())),
119    }
120}
121
122/// One-line summary of a [`UiSignal`] for live-frontend display.
123///
124/// Returns `None` for the structured signals that frontends handle
125/// out-of-band ([`UiSignal::CeremonyOverview`], [`UiSignal::SystemInfo`],
126/// [`UiSignal::Environment`]), which populate structured fields rather than a
127/// single narration line.
128#[must_use]
129pub fn signal_summary(signal: &UiSignal) -> Option<(Icon, String)> {
130    match signal {
131        UiSignal::LogLine { icon, text, .. } => Some((*icon, text.clone())),
132        UiSignal::Progress {
133            phase, fraction, ..
134        } => {
135            let text = match fraction {
136                Some(f) => format!("{phase}: {:>5.1}%", f * 100.0),
137                None => format!("{phase}…"),
138            };
139            Some((Icon::Spinner, text))
140        }
141        UiSignal::CeremonyOverview { .. } | UiSignal::SystemInfo(_) | UiSignal::Environment(_) => {
142            None
143        }
144    }
145}
146
147/// Truncate a string to at most `max_chars` Unicode scalar values, appending
148/// `"..."` when truncation occurs.
149///
150/// Safe on multibyte input: never slices inside a UTF-8 code point.
151#[must_use]
152pub fn truncate_for_display(s: &str, max_chars: usize) -> String {
153    match s.char_indices().nth(max_chars) {
154        Some((boundary, _)) => format!("{}...", &s[..boundary]),
155        None => s.to_string(),
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::{UNSUMMARISED, fact_summary, truncate_for_display};
162    use rite_model::{StepFact, StepId};
163
164    /// A fact with no arm of its own reaches the operator as a placeholder in
165    /// the middle of a ceremony, which is worse than saying nothing.
166    #[test]
167    fn the_wrap_recipient_carries_its_fingerprint() {
168        let (_, line) = fact_summary(&StepFact::WrapRecipientRecorded {
169            step: StepId::new("wrap"),
170            source: "escrow_pubkey".to_string(),
171            fingerprint: "sha256:abcd".to_string(),
172            declared: false,
173        })
174        .expect("the live feed surfaces the recipient");
175        assert_ne!(line, UNSUMMARISED);
176        assert!(line.contains("escrow_pubkey"), "{line}");
177        assert!(line.contains("sha256:abcd"), "{line}");
178    }
179
180    #[test]
181    fn truncate_passes_through_short_input() {
182        assert_eq!(truncate_for_display("short", 10), "short");
183    }
184
185    #[test]
186    fn truncate_adds_ellipsis_when_too_long() {
187        assert_eq!(
188            truncate_for_display("this is a long string", 10),
189            "this is a ..."
190        );
191    }
192
193    #[test]
194    fn truncate_respects_utf8_code_point_boundaries() {
195        // Each emoji is a 4-byte UTF-8 sequence; naive byte slicing panics.
196        let s = "🦀🦀🦀🦀🦀";
197        assert_eq!(truncate_for_display(s, 2), "🦀🦀...");
198        assert_eq!(truncate_for_display(s, 5), "🦀🦀🦀🦀🦀");
199    }
200}