Skip to main content

ftts_cli/
style.rs

1//! Human-facing console output: colored status lines and interactive confirmation.
2//!
3//! # Why this is not `gum`
4//!
5//! The shell-installer house style reaches for [`gum`](https://github.com/charmbracelet/gum) and
6//! falls back to raw ANSI when it is absent. A CLI cannot borrow that directly: shelling out to a
7//! formatter would make a *runtime dependency* out of pretty output, and this project ships one
8//! binary with no runtime dependencies at all. What transfers is the shape of the output stack —
9//! `info` / `ok` / `warn`, one visual grammar, and graceful degradation — so it is reimplemented
10//! here in a dozen lines of ANSI instead.
11//!
12//! # Degradation is the contract
13//!
14//! Color is emitted only when stdout is a terminal and `NO_COLOR` is unset. Everything here is
15//! therefore inert under a pipe, a file, a test harness capturing to a `Vec<u8>`, and robot mode —
16//! which matters more than the color does: NDJSON consumers and golden-output tests must never
17//! have to strip escape sequences. Prompts follow the same rule and refuse to block when there is
18//! no human attached.
19
20use std::io::{IsTerminal, Write};
21
22/// Whether human-facing decoration should be emitted at all.
23///
24/// Honors the [NO_COLOR convention](https://no-color.org): any non-empty value disables color.
25#[must_use]
26pub fn decorate() -> bool {
27    if std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()) {
28        return false;
29    }
30    std::io::stdout().is_terminal()
31}
32
33/// Paints `text` with an SGR code, or returns it unchanged when decoration is off.
34fn paint(code: &str, text: &str) -> String {
35    if decorate() {
36        format!("\u{1b}[{code}m{text}\u{1b}[0m")
37    } else {
38        text.to_owned()
39    }
40}
41
42/// A completed step.
43///
44/// # Errors
45///
46/// When the sink cannot be written.
47pub fn ok(out: &mut dyn Write, message: &str) -> std::io::Result<()> {
48    writeln!(out, "{} {message}", paint("32;1", "✓"))
49}
50
51/// A step in progress, or a neutral fact worth showing.
52///
53/// # Errors
54///
55/// When the sink cannot be written.
56pub fn info(out: &mut dyn Write, message: &str) -> std::io::Result<()> {
57    writeln!(out, "{} {message}", paint("34;1", "→"))
58}
59
60/// Something the user should notice but which is not a failure.
61///
62/// # Errors
63///
64/// When the sink cannot be written.
65pub fn warn(out: &mut dyn Write, message: &str) -> std::io::Result<()> {
66    writeln!(out, "{} {message}", paint("33;1", "!"))
67}
68
69/// Dims secondary detail so it reads as subordinate to the line above it.
70#[must_use]
71pub fn detail(text: &str) -> String {
72    paint("2", text)
73}
74
75/// Emphasizes a path or value inside a sentence.
76#[must_use]
77pub fn emphasis(text: &str) -> String {
78    paint("1", text)
79}
80
81/// Asks a yes/no question, defaulting to no.
82///
83/// Returns `None` when there is no human to ask — stdin or stdout is not a terminal — so callers
84/// can keep their non-interactive behavior (a clear error) instead of blocking a script or an
85/// agent forever on a prompt nothing will answer. That distinction is the whole reason this
86/// returns an `Option` rather than a `bool`.
87///
88/// # Errors
89///
90/// When the prompt cannot be written or stdin cannot be read.
91pub fn confirm(out: &mut dyn Write, question: &str) -> std::io::Result<Option<bool>> {
92    if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
93        return Ok(None);
94    }
95    write!(
96        out,
97        "{} {question} {} ",
98        paint("33;1", "?"),
99        detail("[y/N]")
100    )?;
101    out.flush()?;
102    let mut answer = String::new();
103    std::io::stdin().read_line(&mut answer)?;
104    let answer = answer.trim();
105    Ok(Some(
106        answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"),
107    ))
108}
109
110/// Whether a human is reading stdout, as opposed to a pipe, a file, an agent, or CI.
111///
112/// Deliberately distinct from [`decorate`]: `NO_COLOR` means "no color", not "give me JSON", so a
113/// user who sets it still gets human output, just uncolored.
114#[must_use]
115pub fn is_interactive() -> bool {
116    std::io::stdout().is_terminal()
117}
118
119/// Renders the `say` lifecycle as something a person wants to read.
120///
121/// The NDJSON stream is a machine contract — stable schema, one event per line, `audio_chunk` per
122/// packet — and it is exactly the wrong thing to put in front of somebody who typed a sentence and
123/// wants a file. Rather than weaken that contract, this consumes the same events and prints a
124/// different view of them; the stream is unchanged for every non-terminal consumer.
125#[derive(Default)]
126pub struct SayPresenter {
127    destination: Option<String>,
128    load_started_ms: u64,
129    synthesis_started_ms: u64,
130    load_ms: u64,
131    synthesis_ms: u64,
132    frames: u64,
133}
134
135impl SayPresenter {
136    /// Names the file the audio lands in, so the summary can say where it went.
137    ///
138    /// The event stream never carries this — a machine consumer passed the path in and knows it —
139    /// but a person reading four lines of output should not have to look back at their own command
140    /// to find out what was written.
141    #[must_use]
142    pub fn writing_to(destination: Option<String>) -> Self {
143        Self {
144            destination,
145            ..Self::default()
146        }
147    }
148
149    /// Feed one lifecycle event. Unknown events are ignored, so a schema addition cannot break
150    /// human output — it simply will not be narrated until someone teaches this about it.
151    ///
152    /// # Errors
153    ///
154    /// When the sink cannot be written.
155    pub fn event(&mut self, event: &serde_json::Value, out: &mut dyn Write) -> std::io::Result<()> {
156        let kind = event.get("event").and_then(serde_json::Value::as_str);
157        let elapsed = event
158            .get("elapsed_ms")
159            .and_then(serde_json::Value::as_u64)
160            .unwrap_or(0);
161        match kind {
162            Some("run_start") => {
163                let voice = event
164                    .get("voice")
165                    .and_then(serde_json::Value::as_str)
166                    .unwrap_or("default");
167                writeln!(out, "{} {}", detail("voice"), emphasis(voice))?;
168            }
169            Some("stage") => {
170                let name = event
171                    .get("name")
172                    .and_then(serde_json::Value::as_str)
173                    .unwrap_or("");
174                let state = event
175                    .get("state")
176                    .and_then(serde_json::Value::as_str)
177                    .unwrap_or("");
178                match (name, state) {
179                    ("load", "begin") => self.load_started_ms = elapsed,
180                    ("load", "end") => {
181                        self.load_ms = elapsed.saturating_sub(self.load_started_ms);
182                        ok(
183                            out,
184                            &format!("model loaded {}", detail(&secs(self.load_ms))),
185                        )?;
186                    }
187                    ("synthesis", "begin") => self.synthesis_started_ms = elapsed,
188                    ("synthesis", "end") => {
189                        self.synthesis_ms = elapsed.saturating_sub(self.synthesis_started_ms);
190                    }
191                    _ => {}
192                }
193            }
194            // Per-packet chunks are the machine contract's business. A person watching a file get
195            // written does not want one line per 320 ms of audio.
196            Some("audio_chunk") => {}
197            Some("run_complete") => {
198                let audio_ms = event
199                    .get("duration_ms")
200                    .and_then(serde_json::Value::as_u64)
201                    .unwrap_or(0);
202                self.frames = event
203                    .get("frames")
204                    .and_then(serde_json::Value::as_u64)
205                    .unwrap_or(0);
206                // `--check` completes without synthesizing anything; announcing "0 frames" there
207                // would describe work that was never attempted.
208                if self.frames > 0 {
209                    let where_to = self
210                        .destination
211                        .as_deref()
212                        .map_or_else(String::new, |path| format!(" → {}", emphasis(path)));
213                    ok(
214                        out,
215                        &format!(
216                            "synthesized {} frames {}{where_to}",
217                            self.frames,
218                            detail(&secs(self.synthesis_ms))
219                        ),
220                    )?;
221                }
222                let mut tail = format!("{} of audio in {} total", secs(audio_ms), secs(elapsed));
223                if let Some(ttfa) = event.get("ttfa_ms").and_then(serde_json::Value::as_u64) {
224                    tail.push_str(&format!(" · first audio {ttfa} ms"));
225                }
226                // Report synthesis against real time, not the whole run: model load is a fixed
227                // one-off, and folding it in makes a short sentence look slow for a reason that
228                // has nothing to do with how fast the engine generates.
229                if audio_ms > 0 && self.synthesis_ms > 0 {
230                    #[allow(clippy::cast_precision_loss)]
231                    let ratio = audio_ms as f64 / self.synthesis_ms as f64;
232                    tail.push_str(&format!(" · synthesis {ratio:.2}× real time"));
233                }
234                writeln!(out, "  {}", detail(&tail))?;
235            }
236            _ => {}
237        }
238        Ok(())
239    }
240}
241
242/// Milliseconds as a short human duration.
243fn secs(ms: u64) -> String {
244    #[allow(clippy::cast_precision_loss)]
245    let seconds = ms as f64 / 1000.0;
246    if seconds < 10.0 {
247        format!("{seconds:.2} s")
248    } else {
249        format!("{seconds:.1} s")
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    /// Captured output must be free of escape sequences: golden tests and NDJSON consumers read
258    /// this same text, and a stray SGR code would be a parsing bug rather than a cosmetic one.
259    #[test]
260    fn status_lines_carry_no_escapes_when_the_sink_is_not_a_terminal() {
261        let mut buffer: Vec<u8> = Vec::new();
262        ok(&mut buffer, "enrolled").expect("write");
263        info(&mut buffer, "loading").expect("write");
264        warn(&mut buffer, "noisy reference").expect("write");
265        let text = String::from_utf8(buffer).expect("utf8");
266        assert!(
267            !text.contains('\u{1b}'),
268            "decoration leaked into a captured sink: {text:?}"
269        );
270        assert!(text.contains("enrolled") && text.contains("loading"));
271    }
272
273    /// A prompt with no terminal attached must not block; it reports "nobody to ask".
274    #[test]
275    fn confirm_declines_to_block_without_a_terminal() {
276        let mut buffer: Vec<u8> = Vec::new();
277        let answer = confirm(&mut buffer, "Overwrite?").expect("confirm");
278        assert_eq!(
279            answer, None,
280            "a non-interactive run must fall through to the caller's own policy"
281        );
282        assert!(
283            buffer.is_empty(),
284            "nothing should be printed with no reader"
285        );
286    }
287}