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}