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`: the surrounding action
26///   handler already calls `Reporter::log` with its own narrative line.
27/// - `CeremonyCompleted`: the frontend renders a dedicated completion
28///   screen with the fingerprint.
29#[must_use]
30pub fn fact_summary(fact: &StepFact) -> Option<(Icon, String)> {
31    match fact {
32        StepFact::CeremonyStarted { name, .. } => Some((Icon::Info, format!("Ceremony: {name}"))),
33        StepFact::ActStarted { label, .. } => Some((Icon::Info, format!("Act: {label}"))),
34        StepFact::StepStarted {
35            id,
36            label,
37            role_name,
38            ..
39        } => Some((
40            Icon::Info,
41            format!("Step {label} ({id}), role: {role_name}"),
42        )),
43        StepFact::PromptAnswered { .. }
44        | StepFact::BackendOperation { .. }
45        | StepFact::AttestationRecorded { .. }
46        | StepFact::CeremonyCompleted { .. } => None,
47        StepFact::ArtifactWritten { path, .. } => Some((
48            Icon::Checkmark,
49            format!("Artifact written: {}", path.display()),
50        )),
51        StepFact::DeviationRecorded { text, .. } => {
52            Some((Icon::Warning, format!("Deviation: {text}")))
53        }
54        StepFact::StepCompleted { outcome, .. } => match outcome {
55            rite_model::StepOutcome::Completed { message } => {
56                Some((Icon::Checkmark, message.clone()))
57            }
58            _ => Some((Icon::Checkmark, "Step completed".to_string())),
59        },
60        StepFact::StepAttemptFailed { attempt, error, .. } => Some((
61            Icon::Cross,
62            format!("Attempt {attempt} failed: {}", error.message),
63        )),
64        StepFact::CeremonyFailed { error, .. } => match error.class {
65            // An abort is a deliberate operator decision, not a failure: show it
66            // neutrally rather than with the failure cross.
67            ErrorClass::Abort => Some((Icon::Info, error.message.clone())),
68            _ => Some((Icon::Cross, format!("Ceremony failed: {}", error.message))),
69        },
70        StepFact::EntropySeeded { source, .. } => {
71            Some((Icon::Info, format!("Entropy source seeded ({source})")))
72        }
73        StepFact::EntropyContributed { epoch, .. } => Some((
74            Icon::Info,
75            format!("Entropy contribution folded (epoch {epoch})"),
76        )),
77        StepFact::EntropyDrawn { path, .. } => {
78            Some((Icon::Info, format!("Random value drawn: {path}")))
79        }
80        // Who the wrap is addressed to is the operator's last chance to stop a
81        // wrap they cannot undo, so the fingerprint is shown, not summarised
82        // away.
83        StepFact::WrapRecipientRecorded {
84            source,
85            fingerprint,
86            declared,
87            ..
88        } => Some((
89            Icon::Info,
90            if *declared {
91                format!("Recipient {source} matches the declared {fingerprint}")
92            } else {
93                format!("Recipient {source} is {fingerprint}, undeclared")
94            },
95        )),
96        // `StepFact` is `#[non_exhaustive]`, so this arm is unavoidable in a
97        // crate downstream of the one defining it. A variant reaching it is a
98        // variant added without a line of its own, which an operator sees
99        // mid-ceremony; give every new variant an arm above.
100        _ => Some((Icon::Info, UNSUMMARISED.to_string())),
101    }
102}
103
104/// One-line summary of a [`UiSignal`] for live-frontend display.
105///
106/// Returns `None` for the structured signals that frontends handle
107/// out-of-band ([`UiSignal::CeremonyOverview`], [`UiSignal::SystemInfo`],
108/// [`UiSignal::Environment`]), which populate structured fields rather than a
109/// single narration line.
110#[must_use]
111pub fn signal_summary(signal: &UiSignal) -> Option<(Icon, String)> {
112    match signal {
113        UiSignal::LogLine { icon, text, .. } => Some((*icon, text.clone())),
114        UiSignal::Progress {
115            phase, fraction, ..
116        } => {
117            let text = match fraction {
118                Some(f) => format!("{phase}: {:>5.1}%", f * 100.0),
119                None => format!("{phase}…"),
120            };
121            Some((Icon::Spinner, text))
122        }
123        UiSignal::CeremonyOverview { .. } | UiSignal::SystemInfo(_) | UiSignal::Environment(_) => {
124            None
125        }
126    }
127}
128
129/// Truncate a string to at most `max_chars` Unicode scalar values, appending
130/// `"..."` when truncation occurs.
131///
132/// Safe on multibyte input: never slices inside a UTF-8 code point.
133#[must_use]
134pub fn truncate_for_display(s: &str, max_chars: usize) -> String {
135    match s.char_indices().nth(max_chars) {
136        Some((boundary, _)) => format!("{}...", &s[..boundary]),
137        None => s.to_string(),
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::{UNSUMMARISED, fact_summary, truncate_for_display};
144    use rite_model::{StepFact, StepId};
145
146    /// A fact with no arm of its own reaches the operator as a placeholder in
147    /// the middle of a ceremony, which is worse than saying nothing.
148    #[test]
149    fn the_wrap_recipient_carries_its_fingerprint() {
150        let (_, line) = fact_summary(&StepFact::WrapRecipientRecorded {
151            step: StepId::new("wrap"),
152            source: "escrow_pubkey".to_string(),
153            fingerprint: "sha256:abcd".to_string(),
154            declared: false,
155        })
156        .expect("the live feed surfaces the recipient");
157        assert_ne!(line, UNSUMMARISED);
158        assert!(line.contains("escrow_pubkey"), "{line}");
159        assert!(line.contains("sha256:abcd"), "{line}");
160    }
161
162    #[test]
163    fn truncate_passes_through_short_input() {
164        assert_eq!(truncate_for_display("short", 10), "short");
165    }
166
167    #[test]
168    fn truncate_adds_ellipsis_when_too_long() {
169        assert_eq!(
170            truncate_for_display("this is a long string", 10),
171            "this is a ..."
172        );
173    }
174
175    #[test]
176    fn truncate_respects_utf8_code_point_boundaries() {
177        // Each emoji is a 4-byte UTF-8 sequence; naive byte slicing panics.
178        let s = "🦀🦀🦀🦀🦀";
179        assert_eq!(truncate_for_display(s, 2), "🦀🦀...");
180        assert_eq!(truncate_for_display(s, 5), "🦀🦀🦀🦀🦀");
181    }
182}