use std::sync::Mutex;
use indicatif::{ProgressBar, ProgressDrawTarget};
use subx_core::core::report::{AiUsage, ProgressEvent, Reporter};
pub struct TerminalReporter {
enable_progress_bar: bool,
bar: Mutex<Option<ProgressBar>>,
}
impl Default for TerminalReporter {
fn default() -> Self {
Self::new(true)
}
}
impl TerminalReporter {
pub fn new(enable_progress_bar: bool) -> Self {
Self {
enable_progress_bar,
bar: Mutex::new(None),
}
}
fn render_stream_event(&self, event: &ProgressEvent<'_>) {
let mut slot = self.bar.lock().unwrap_or_else(|e| e.into_inner());
match event {
ProgressEvent::Started { total } => {
if let Some(old) = slot.take() {
old.finish_and_clear();
}
let bar = crate::cli::ui::create_progress_bar(*total);
if !self.enable_progress_bar {
bar.set_draw_target(ProgressDrawTarget::hidden());
}
*slot = Some(bar);
}
ProgressEvent::Advanced {
done,
total: _,
item,
} => {
if let Some(bar) = slot.as_ref() {
bar.set_position(*done);
if let Some(item) = item {
bar.set_message(item.to_string());
}
}
}
ProgressEvent::Finished { .. } => {
if let Some(bar) = slot.take() {
bar.finish();
}
}
_ => {}
}
}
}
impl Reporter for TerminalReporter {
fn diagnostic(&self, message: &str) {
if crate::cli::output::active_mode().is_json() {
return;
}
eprintln!("{message}");
}
fn warn(&self, message: &str) {
if crate::cli::output::active_mode().is_json() {
return;
}
eprintln!("{message}");
}
fn ai_usage(&self, usage: &AiUsage) {
crate::cli::ui::display_ai_usage(usage);
}
fn progress(&self, event: &ProgressEvent<'_>) {
match event {
ProgressEvent::Message(_) => {
if crate::cli::output::active_mode().is_json() || crate::cli::output::is_quiet() {
return;
}
let slot = self.bar.lock().unwrap_or_else(|e| e.into_inner());
match (&*slot, event) {
(Some(bar), ProgressEvent::Message(message)) => {
bar.set_message(message.to_string());
}
(None, ProgressEvent::Message(message)) => eprintln!("{message}"),
_ => {}
}
}
_ => self.render_stream_event(event),
}
}
}
pub fn terminal_reporter() -> std::sync::Arc<dyn subx_core::core::report::Reporter> {
terminal_reporter_with_progress_bar(true)
}
pub fn terminal_reporter_with_progress_bar(
enable_progress_bar: bool,
) -> std::sync::Arc<dyn subx_core::core::report::Reporter> {
std::sync::Arc::new(TerminalReporter::new(enable_progress_bar))
}
#[cfg(test)]
mod tests {
use super::*;
use subx_core::core::report::noop;
#[test]
fn text_mode_routes_channels_to_their_streams() {
let reporter = TerminalReporter::new(true);
assert!(
!crate::cli::output::active_mode().is_json(),
"unit tests run with the default text mode"
);
reporter.diagnostic("detail");
reporter.warn("Warning: careful");
reporter.progress(&ProgressEvent::Message(
"📊 Translation Progress:\n Processed cues: 2/2",
));
reporter.ai_usage(&AiUsage {
model: "gpt-4.1-mini".to_string(),
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
});
}
#[test]
fn terminal_reporter_is_send_sync_trait_object() {
let reporter: std::sync::Arc<dyn Reporter> = terminal_reporter();
fn assert_send_sync<T: Send + Sync>(_: &T) {}
assert_send_sync(&reporter);
}
#[test]
fn structured_stream_lifecycle_renders_without_panicking() {
let reporter = TerminalReporter::new(true);
reporter.progress(&ProgressEvent::Started { total: 2 });
reporter.progress(&ProgressEvent::Message("Active: 1 | Queued: 1".into()));
reporter.progress(&ProgressEvent::Advanced {
done: 1,
total: 2,
item: Some("movie.srt"),
});
reporter.progress(&ProgressEvent::Started { total: 1 });
reporter.progress(&ProgressEvent::Finished { done: 1, total: 1 });
assert!(
reporter.bar.lock().unwrap().is_none(),
"Finished clears the slot"
);
}
#[test]
fn noop_and_terminal_reporters_are_distinct_sinks() {
let sinks: Vec<std::sync::Arc<dyn Reporter>> = vec![noop(), terminal_reporter()];
assert_eq!(sinks.len(), 2);
}
#[test]
fn disabled_flag_hides_the_bar_instead_of_skipping_the_lifecycle() {
let reporter = TerminalReporter::new(false);
reporter.progress(&ProgressEvent::Started { total: 2 });
{
let slot = reporter.bar.lock().unwrap();
let bar = slot
.as_ref()
.expect("Started builds the bar even when disabled");
assert!(bar.is_hidden(), "disabled flag must hide the draw target");
}
reporter.progress(&ProgressEvent::Advanced {
done: 1,
total: 2,
item: None,
});
{
let slot = reporter.bar.lock().unwrap();
assert_eq!(slot.as_ref().expect("bar still open").position(), 1);
}
reporter.progress(&ProgressEvent::Finished { done: 2, total: 2 });
assert!(
reporter.bar.lock().unwrap().is_none(),
"Finished ends the hidden bar and clears the slot"
);
}
}