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::StepFact;
15
16use crate::protocol::{Icon, UiSignal};
17
18/// One-line summary of a [`StepFact`] for live-frontend display.
19///
20/// Returns `None` for facts the live UI shouldn't surface:
21/// - `PromptAnswered`: the operator just typed it.
22/// - `BackendOperation` / `AttestationRecorded`: the surrounding action
23///   handler already calls `Reporter::log` with its own narrative line.
24/// - `CeremonyCompleted`: the frontend renders a dedicated completion
25///   screen with the fingerprint.
26#[must_use]
27pub fn fact_summary(fact: &StepFact) -> Option<(Icon, String)> {
28    match fact {
29        StepFact::CeremonyStarted { name, .. } => Some((Icon::Info, format!("Ceremony: {name}"))),
30        StepFact::ActStarted { label, .. } => Some((Icon::Info, format!("Act: {label}"))),
31        StepFact::StepStarted {
32            id,
33            label,
34            role_name,
35            ..
36        } => Some((
37            Icon::Info,
38            format!("Step {label} ({id}), role: {role_name}"),
39        )),
40        StepFact::PromptAnswered { .. }
41        | StepFact::BackendOperation { .. }
42        | StepFact::AttestationRecorded { .. }
43        | StepFact::CeremonyCompleted { .. } => None,
44        StepFact::ArtifactWritten { path, .. } => Some((
45            Icon::Checkmark,
46            format!("Artifact written: {}", path.display()),
47        )),
48        StepFact::DeviationRecorded { text, .. } => {
49            Some((Icon::Warning, format!("Deviation: {text}")))
50        }
51        StepFact::StepCompleted { outcome, .. } => match outcome {
52            rite_model::StepOutcome::Completed { message } => {
53                Some((Icon::Checkmark, message.clone()))
54            }
55            _ => Some((Icon::Checkmark, "Step completed".to_string())),
56        },
57        StepFact::CeremonyFailed { error, .. } => {
58            Some((Icon::Cross, format!("Ceremony failed: {}", error.message)))
59        }
60        StepFact::EntropySeeded { source, .. } => {
61            Some((Icon::Info, format!("Entropy source seeded ({source})")))
62        }
63        StepFact::EntropyContributed { epoch, .. } => Some((
64            Icon::Info,
65            format!("Entropy contribution folded (epoch {epoch})"),
66        )),
67        StepFact::EntropyDrawn { path, .. } => {
68            Some((Icon::Info, format!("Random value drawn: {path}")))
69        }
70        // `StepFact` is `#[non_exhaustive]`; future variants may carry
71        // payloads the live UI shouldn't blindly Debug-print. Surface a
72        // typed placeholder until each new variant is given a summary.
73        _ => Some((Icon::Info, "unknown fact variant".to_string())),
74    }
75}
76
77/// One-line summary of a [`UiSignal`] for live-frontend display.
78///
79/// Returns `None` for the structured signals that frontends handle
80/// out-of-band ([`UiSignal::CeremonyOverview`], [`UiSignal::SystemInfo`],
81/// [`UiSignal::Environment`]), which populate structured fields rather than a
82/// single narration line.
83#[must_use]
84pub fn signal_summary(signal: &UiSignal) -> Option<(Icon, String)> {
85    match signal {
86        UiSignal::LogLine { icon, text, .. } => Some((*icon, text.clone())),
87        UiSignal::Progress {
88            phase, fraction, ..
89        } => {
90            let text = match fraction {
91                Some(f) => format!("{phase}: {:>5.1}%", f * 100.0),
92                None => format!("{phase}…"),
93            };
94            Some((Icon::Spinner, text))
95        }
96        UiSignal::CeremonyOverview { .. } | UiSignal::SystemInfo(_) | UiSignal::Environment(_) => {
97            None
98        }
99    }
100}
101
102/// Truncate a string to at most `max_chars` Unicode scalar values, appending
103/// `"..."` when truncation occurs.
104///
105/// Safe on multibyte input: never slices inside a UTF-8 code point.
106#[must_use]
107pub fn truncate_for_display(s: &str, max_chars: usize) -> String {
108    match s.char_indices().nth(max_chars) {
109        Some((boundary, _)) => format!("{}...", &s[..boundary]),
110        None => s.to_string(),
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::truncate_for_display;
117
118    #[test]
119    fn truncate_passes_through_short_input() {
120        assert_eq!(truncate_for_display("short", 10), "short");
121    }
122
123    #[test]
124    fn truncate_adds_ellipsis_when_too_long() {
125        assert_eq!(
126            truncate_for_display("this is a long string", 10),
127            "this is a ..."
128        );
129    }
130
131    #[test]
132    fn truncate_respects_utf8_code_point_boundaries() {
133        // Each emoji is a 4-byte UTF-8 sequence; naive byte slicing panics.
134        let s = "🦀🦀🦀🦀🦀";
135        assert_eq!(truncate_for_display(s, 2), "🦀🦀...");
136        assert_eq!(truncate_for_display(s, 5), "🦀🦀🦀🦀🦀");
137    }
138}