Skip to main content

subx_cli/cli/
reporter.rs

1//! The CLI's terminal implementation of the core reporting seam.
2//!
3//! [`TerminalReporter`] is the only consumer of the process-global output
4//! mode ([`crate::cli::output::active_mode`]) and quiet flag
5//! ([`crate::cli::output::is_quiet`]) **on behalf of core**: every message a
6//! core engine or service client reports reaches the terminal through this
7//! type, which decides per channel whether and where it is rendered. Core
8//! itself never consults these globals (enforced by
9//! `tests/core_cli_boundary.rs`).
10
11use std::sync::Mutex;
12
13use indicatif::{ProgressBar, ProgressDrawTarget};
14use subx_core::core::report::{AiUsage, ProgressEvent, Reporter};
15
16/// Renders [`subx_core::core::report`] channels to stdout/stderr.
17///
18/// Channel-and-suppression matrix (see the `machine-readable-output`
19/// capability):
20///
21/// | Channel      | Stream | Suppressed when                       |
22/// |--------------|--------|---------------------------------------|
23/// | `diagnostic` | stderr | output mode is `json`                 |
24/// | `warn`       | stderr | output mode is `json`                 |
25/// | `ai_usage`   | stdout | output mode is `json`                 |
26/// | `progress`   | stderr | output mode is `json` **or** `--quiet` |
27///
28/// Messages are written verbatim followed by exactly one `\n` — no prefix,
29/// symbol, or colour — so a message replacing an `eprintln!` arrives
30/// byte-identically, and embedded newlines are written as one atomic block.
31/// `--quiet` silences `progress` only; diagnostics and warnings survive it.
32///
33/// # Batch progress bar ownership
34///
35/// This reporter is the **single owner** of the batch progress bar in the
36/// CLI (see `expose-core-orchestration-apis`). A [`ProgressEvent::Started`]
37/// constructs exactly one bar through [`crate::cli::ui::create_progress_bar`]
38/// — which force-hides it in JSON mode — and additionally hides it when
39/// `enable_progress_bar` (read once from the `ConfigService` at
40/// construction) is `false`. `Advanced` sets the position and, when an
41/// `item` is supplied, the message; `Finished` ends the bar and clears the
42/// slot (the old "All tasks completed" `finish_with_message` text is
43/// intentionally dropped — the final rendered frame shows `{pos}/{len}`).
44/// A `Started` while a bar is open replaces it rather than nesting. A
45/// `Message` received while a stream is open becomes the bar's message
46/// (the parallel batch's `Active: … | Queued: …` ticker) instead of a
47/// separate line; with no stream open, messages print as before.
48/// Structured events are processed in every output mode — suppression
49/// happens through the draw target, never by skipping the lifecycle.
50pub struct TerminalReporter {
51    /// `general.enable_progress_bar`, read once at construction.
52    enable_progress_bar: bool,
53    /// The one open stream's bar, if any.
54    bar: Mutex<Option<ProgressBar>>,
55}
56
57impl Default for TerminalReporter {
58    fn default() -> Self {
59        Self::new(true)
60    }
61}
62
63impl TerminalReporter {
64    /// Build a reporter honouring `enable_progress_bar` for every batch
65    /// stream it renders.
66    pub fn new(enable_progress_bar: bool) -> Self {
67        Self {
68            enable_progress_bar,
69            bar: Mutex::new(None),
70        }
71    }
72
73    /// Render a structured-stream event against the currently open bar,
74    /// owning the replace/advance/finish transitions.
75    fn render_stream_event(&self, event: &ProgressEvent<'_>) {
76        let mut slot = self.bar.lock().unwrap_or_else(|e| e.into_inner());
77        match event {
78            ProgressEvent::Started { total } => {
79                // Replace, don't nest: end and remove any open bar first.
80                if let Some(old) = slot.take() {
81                    old.finish_and_clear();
82                }
83                let bar = crate::cli::ui::create_progress_bar(*total);
84                if !self.enable_progress_bar {
85                    bar.set_draw_target(ProgressDrawTarget::hidden());
86                }
87                *slot = Some(bar);
88            }
89            ProgressEvent::Advanced {
90                done,
91                total: _,
92                item,
93            } => {
94                if let Some(bar) = slot.as_ref() {
95                    bar.set_position(*done);
96                    if let Some(item) = item {
97                        bar.set_message(item.to_string());
98                    }
99                }
100            }
101            ProgressEvent::Finished { .. } => {
102                if let Some(bar) = slot.take() {
103                    bar.finish();
104                }
105            }
106            _ => {}
107        }
108    }
109}
110
111impl Reporter for TerminalReporter {
112    fn diagnostic(&self, message: &str) {
113        if crate::cli::output::active_mode().is_json() {
114            return;
115        }
116        eprintln!("{message}");
117    }
118
119    fn warn(&self, message: &str) {
120        if crate::cli::output::active_mode().is_json() {
121            return;
122        }
123        eprintln!("{message}");
124    }
125
126    fn ai_usage(&self, usage: &AiUsage) {
127        crate::cli::ui::display_ai_usage(usage);
128    }
129
130    fn progress(&self, event: &ProgressEvent<'_>) {
131        match event {
132            ProgressEvent::Message(_) => {
133                // Free-form chatter keeps A1's rules: json/quiet silence,
134                // otherwise one verbatim stderr line — except while a
135                // structured stream is open, where it renders as the
136                // bar's {msg} segment instead of an extra line (the
137                // parallel batch ticker; task 7.5 of
138                // expose-core-orchestration-apis names this choice).
139                if crate::cli::output::active_mode().is_json() || crate::cli::output::is_quiet() {
140                    return;
141                }
142                let slot = self.bar.lock().unwrap_or_else(|e| e.into_inner());
143                match (&*slot, event) {
144                    (Some(bar), ProgressEvent::Message(message)) => {
145                        bar.set_message(message.to_string());
146                    }
147                    (None, ProgressEvent::Message(message)) => eprintln!("{message}"),
148                    _ => {}
149                }
150            }
151            // Structured lifecycle: processed in every mode; JSON silence
152            // and `enable_progress_bar = false` are enforced through the
153            // draw target (create_progress_bar force-hides in JSON mode),
154            // never by skipping construction.
155            _ => self.render_stream_event(event),
156        }
157    }
158}
159
160/// Shared handle to the CLI's terminal reporter, with progress bars
161/// enabled.
162///
163/// Command implementations attach this at component-construction sites
164/// (`ComponentFactory::new(...)?.with_reporter(terminal_reporter())`) so
165/// core output follows the CLI's output-mode rules. Commands that have a
166/// loaded configuration SHALL use
167/// [`terminal_reporter_with_progress_bar`] so `general.enable_progress_bar`
168/// is honoured.
169///
170/// # Examples
171///
172/// ```
173/// use subx_cli::core::report::Reporter;
174///
175/// let reporter = subx_cli::cli::terminal_reporter();
176/// // Text mode is the default output mode, so this reaches stderr.
177/// reporter.diagnostic("status detail");
178/// ```
179pub fn terminal_reporter() -> std::sync::Arc<dyn subx_core::core::report::Reporter> {
180    terminal_reporter_with_progress_bar(true)
181}
182
183/// Shared handle to a terminal reporter honouring `general.enable_progress_bar`.
184///
185/// The flag is read once, here, at construction — never per event — from
186/// the `ConfigService` at whichever place builds the reporter (the CLI
187/// reporter is the single enforcement point for the flag).
188///
189/// # Examples
190///
191/// ```
192/// use subx_cli::core::report::Reporter;
193///
194/// let reporter = subx_cli::cli::terminal_reporter_with_progress_bar(false);
195/// // A batch stream opened against this reporter renders no frames.
196/// reporter.progress(&subx_cli::core::report::ProgressEvent::Started { total: 3 });
197/// reporter.progress(&subx_cli::core::report::ProgressEvent::Finished { done: 3, total: 3 });
198/// ```
199pub fn terminal_reporter_with_progress_bar(
200    enable_progress_bar: bool,
201) -> std::sync::Arc<dyn subx_core::core::report::Reporter> {
202    std::sync::Arc::new(TerminalReporter::new(enable_progress_bar))
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use subx_core::core::report::noop;
209
210    /// Channel/stream matrix in the default (`text`, not quiet) mode — the
211    /// only mode assertable without mutating the process-wide `OnceLock`
212    /// in `output.rs`. The JSON-mode branches are covered end-to-end by the
213    /// `assert_cmd` tests in `tests/cli/match_command_json_silence.rs`.
214    #[test]
215    fn text_mode_routes_channels_to_their_streams() {
216        let reporter = TerminalReporter::new(true);
217        assert!(
218            !crate::cli::output::active_mode().is_json(),
219            "unit tests run with the default text mode"
220        );
221
222        // diagnostic/warn/progress → stderr, ai_usage → stdout (via
223        // display_ai_usage). Assert against the streams indirectly: the
224        // calls must not panic and must consult the live mode.
225        reporter.diagnostic("detail");
226        reporter.warn("Warning: careful");
227        reporter.progress(&ProgressEvent::Message(
228            "📊 Translation Progress:\n   Processed cues: 2/2",
229        ));
230        reporter.ai_usage(&AiUsage {
231            model: "gpt-4.1-mini".to_string(),
232            prompt_tokens: 10,
233            completion_tokens: 5,
234            total_tokens: 15,
235        });
236    }
237
238    #[test]
239    fn terminal_reporter_is_send_sync_trait_object() {
240        let reporter: std::sync::Arc<dyn Reporter> = terminal_reporter();
241        // Trait-object storage compiles and the Send + Sync bound holds.
242        fn assert_send_sync<T: Send + Sync>(_: &T) {}
243        assert_send_sync(&reporter);
244    }
245
246    #[test]
247    fn structured_stream_lifecycle_renders_without_panicking() {
248        // Text mode, bar enabled: the full Started/Advanced/Finished
249        // lifecycle (including a replacement Started) must be panic-free
250        // and end with the slot cleared.
251        let reporter = TerminalReporter::new(true);
252        reporter.progress(&ProgressEvent::Started { total: 2 });
253        reporter.progress(&ProgressEvent::Message("Active: 1 | Queued: 1".into()));
254        reporter.progress(&ProgressEvent::Advanced {
255            done: 1,
256            total: 2,
257            item: Some("movie.srt"),
258        });
259        // A second Started replaces the open bar rather than nesting.
260        reporter.progress(&ProgressEvent::Started { total: 1 });
261        reporter.progress(&ProgressEvent::Finished { done: 1, total: 1 });
262        assert!(
263            reporter.bar.lock().unwrap().is_none(),
264            "Finished clears the slot"
265        );
266    }
267
268    #[test]
269    fn noop_and_terminal_reporters_are_distinct_sinks() {
270        // Both must be attachable interchangeably through the seam.
271        let sinks: Vec<std::sync::Arc<dyn Reporter>> = vec![noop(), terminal_reporter()];
272        assert_eq!(sinks.len(), 2);
273    }
274
275    #[test]
276    fn disabled_flag_hides_the_bar_instead_of_skipping_the_lifecycle() {
277        // `enable_progress_bar = false` is enforced through the draw target,
278        // never by skipping construction: the bar must still exist (so
279        // Advanced/Finished keep working against it) but be hidden, so it
280        // can render no frames on any target.
281        let reporter = TerminalReporter::new(false);
282        reporter.progress(&ProgressEvent::Started { total: 2 });
283        {
284            let slot = reporter.bar.lock().unwrap();
285            let bar = slot
286                .as_ref()
287                .expect("Started builds the bar even when disabled");
288            assert!(bar.is_hidden(), "disabled flag must hide the draw target");
289        }
290        // The rest of the stream contract proceeds unchanged.
291        reporter.progress(&ProgressEvent::Advanced {
292            done: 1,
293            total: 2,
294            item: None,
295        });
296        {
297            let slot = reporter.bar.lock().unwrap();
298            assert_eq!(slot.as_ref().expect("bar still open").position(), 1);
299        }
300        reporter.progress(&ProgressEvent::Finished { done: 2, total: 2 });
301        assert!(
302            reporter.bar.lock().unwrap().is_none(),
303            "Finished ends the hidden bar and clears the slot"
304        );
305    }
306}