subx_core/core/report/mod.rs
1//! Transport-agnostic reporting seam for `src/core/` and `src/services/`.
2//!
3//! Core engines need to say things to whoever is driving them: a diagnostic
4//! about work in progress, a warning about a recovered problem, the token
5//! accounting for one AI API call, a line on the long-running-work progress
6//! stream. Historically each engine printed those messages itself and asked
7//! the CLI process-global output mode whether it was allowed to. That made
8//! `src/core/` and `src/services/` depend on the CLI layer — the one upward
9//! edge that blocks extracting core into its own crate.
10//!
11//! This module inverts the dependency. Core owns the [`Reporter`] *trait* —
12//! the sink it reports through — and never knows what transport, if any,
13//! sits behind it. The CLI owns the only terminal implementation
14//! (`TerminalReporter` in `src/cli/reporter.rs`), which is where all mode
15//! gating (`--output json`, `--quiet`) lives. When `src/core/` moves into
16//! the `subx-core` crate, this module travels with it unchanged.
17//!
18//! The layering rule this module exists to enforce: **no module under
19//! `src/core/` or `src/services/` may reference the CLI layer.** The
20//! `tests/core_cli_boundary.rs` guard test enforces it mechanically.
21//!
22//! Attachment is builder-style: engines and clients default their reporter
23//! to [`noop()`] (a [`NoopReporter`], so library consumers such as the
24//! desktop GUI see silence by default) and accept an `Arc<dyn Reporter>`
25//! through a `with_reporter` method, leaving every existing constructor
26//! signature untouched.
27
28use std::sync::Arc;
29
30/// A transport-agnostic sink for human-oriented core output.
31///
32/// Every method has a default no-op body, so an implementation opts into
33/// exactly the channels it cares about — [`NoopReporter`]'s entire
34/// implementation is empty. The trait is object-safe and `Send + Sync`
35/// because engines hold it as `Arc<dyn Reporter>` across task boundaries.
36///
37/// Core code calls these methods with fully-formatted strings; deciding
38/// *whether* and *where* a message is shown (stdout, stderr, a GUI event
39/// channel, nowhere) belongs to the implementation.
40///
41/// # Examples
42///
43/// ```
44/// use subx_core::core::report::Reporter;
45///
46/// /// Only interested in warnings; silent on everything else.
47/// struct WarningCounter(std::sync::atomic::AtomicUsize);
48///
49/// impl Reporter for WarningCounter {
50/// fn warn(&self, message: &str) {
51/// assert!(!message.is_empty());
52/// self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
53/// }
54/// }
55///
56/// let counter = WarningCounter(std::sync::atomic::AtomicUsize::new(0));
57/// counter.diagnostic("hidden by this implementation");
58/// counter.warn("counted");
59/// assert_eq!(
60/// counter.0.load(std::sync::atomic::Ordering::Relaxed),
61/// 1
62/// );
63/// ```
64pub trait Reporter: Send + Sync {
65 /// Human-oriented detail about work in progress. Not a failure.
66 ///
67 /// # Arguments
68 ///
69 /// * `message` - Fully-formatted message text; may contain embedded
70 /// `\n` separators, written by the transport as one atomic block.
71 ///
72 /// The terminal transport suppresses this channel in JSON output mode;
73 /// `--quiet` does not.
74 fn diagnostic(&self, message: &str) {
75 let _ = message;
76 }
77
78 /// A non-fatal problem the operation recovered from or worked around.
79 ///
80 /// # Arguments
81 ///
82 /// * `message` - Fully-formatted message text; may contain embedded
83 /// `\n` separators, written by the transport as one atomic block.
84 ///
85 /// The terminal transport suppresses this channel in JSON output mode;
86 /// `--quiet` deliberately does **not** silence warnings.
87 fn warn(&self, message: &str) {
88 let _ = message;
89 }
90
91 /// Token accounting for one completed AI API call.
92 ///
93 /// # Arguments
94 ///
95 /// * `usage` - The structured usage payload; implementations render or
96 /// aggregate it as they see fit.
97 ///
98 /// The terminal transport suppresses this channel in JSON output mode.
99 fn ai_usage(&self, usage: &AiUsage) {
100 let _ = usage;
101 }
102
103 /// An event on the long-running-work progress stream.
104 ///
105 /// # Arguments
106 ///
107 /// * `event` - The progress event; see [`ProgressEvent`] for coverage.
108 ///
109 /// The terminal transport suppresses this channel in JSON output mode
110 /// **and** under `--quiet` — unlike diagnostics and warnings, progress
111 /// chatter is exactly what `--quiet` exists to remove.
112 fn progress(&self, event: &ProgressEvent<'_>) {
113 let _ = event;
114 }
115
116 /// Whether the operation being reported should stop early.
117 ///
118 /// A long-running loop polls this between units of work — never inside
119 /// one — and closes its progress stream cleanly when it turns `true`.
120 /// Cancellation never surfaces as an `Err`: the loop finishes what it
121 /// has done, reports the shortfall through
122 /// [`ProgressEvent::Finished`] (`done < total`), and returns normally
123 /// (see the `expose-core-orchestration-apis` audit-loop contract). A
124 /// caller that wants a mid-await stop drops the future instead.
125 ///
126 /// The default is `false`: reporters that do not implement this —
127 /// including [`NoopReporter`] and the terminal reporter — make every
128 /// loop run to completion, exactly as before this method existed.
129 ///
130 /// # Returns
131 ///
132 /// `true` when the driver has asked for an early stop.
133 fn cancelled(&self) -> bool {
134 false
135 }
136}
137
138/// The default [`Reporter`]: silently swallows everything.
139///
140/// Every attachable engine and client defaults to this, so consumers that
141/// embed core as a library (e.g. the desktop GUI) never receive terminal
142/// chatter they did not ask for.
143///
144/// # Examples
145///
146/// ```
147/// use subx_core::core::report::{AiUsage, NoopReporter, ProgressEvent, Reporter};
148///
149/// let reporter = NoopReporter;
150/// reporter.diagnostic("ignored");
151/// reporter.warn("ignored");
152/// reporter.ai_usage(&AiUsage {
153/// model: "gpt-4.1-mini".to_string(),
154/// prompt_tokens: 10,
155/// completion_tokens: 5,
156/// total_tokens: 15,
157/// });
158/// reporter.progress(&ProgressEvent::Message("ignored"));
159/// ```
160pub struct NoopReporter;
161
162impl Reporter for NoopReporter {}
163
164/// Shared handle to a [`NoopReporter`].
165///
166/// # Examples
167///
168/// ```
169/// use subx_core::core::report::{ProgressEvent, Reporter, noop};
170///
171/// let reporter = noop();
172/// // Compiles and does nothing.
173/// reporter.progress(&ProgressEvent::Message("silence"));
174/// ```
175pub fn noop() -> Arc<dyn Reporter> {
176 Arc::new(NoopReporter)
177}
178
179/// Token accounting for one completed AI API call.
180///
181/// The canonical usage payload for the [`Reporter::ai_usage`] channel.
182/// `services::ai::AiUsageStats` is a legacy alias of this type.
183///
184/// # Examples
185///
186/// ```
187/// use subx_core::core::report::AiUsage;
188///
189/// let usage = AiUsage {
190/// model: "gpt-4.1-mini".to_string(),
191/// prompt_tokens: 120,
192/// completion_tokens: 45,
193/// total_tokens: 165,
194/// };
195/// assert_eq!(usage.prompt_tokens + usage.completion_tokens, usage.total_tokens);
196/// ```
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct AiUsage {
199 /// Model identifier reported by the provider.
200 pub model: String,
201 /// Tokens billed for the prompt.
202 pub prompt_tokens: u32,
203 /// Tokens billed for the completion.
204 pub completion_tokens: u32,
205 /// Total tokens billed for the call.
206 pub total_tokens: u32,
207}
208
209/// An event on the long-running-work progress stream.
210///
211/// Covers free-form status chatter emitted while long work advances
212/// ([`ProgressEvent::Message`]: worker-pool drain notices, per-batch
213/// translation progress, retry notices) and the structured unit-counted
214/// stream (`Started` / `Advanced` / `Finished`) opened by loops that know
215/// their work in advance.
216///
217/// # Stream contract
218///
219/// A structured stream is exactly one [`ProgressEvent::Started`], zero or
220/// more [`ProgressEvent::Advanced`], and exactly one
221/// [`ProgressEvent::Finished`]. `Advanced.done` is non-decreasing and never
222/// exceeds `total`; a `Finished` with `done < total` means the stream
223/// stopped early (cancellation or a first-error abort). A reporter keeps at
224/// most **one open stream at a time**: a second `Started` replaces the
225/// current stream rather than nesting a second one.
226///
227/// The enum stays `#[non_exhaustive]` so future variants remain a
228/// minor-version event; every consumer `match` therefore needs a wildcard
229/// arm.
230///
231/// # Examples
232///
233/// ```
234/// use subx_core::core::report::ProgressEvent;
235///
236/// let stream = [
237/// ProgressEvent::Started { total: 2 },
238/// ProgressEvent::Advanced { done: 1, total: 2, item: Some("movie.srt") },
239/// ProgressEvent::Finished { done: 2, total: 2 },
240/// ];
241/// match &stream[1] {
242/// ProgressEvent::Advanced { done, total, item } => {
243/// assert_eq!((*done, *total), (1, 2));
244/// assert_eq!(*item, Some("movie.srt"));
245/// }
246/// _ => unreachable!("second event is an Advanced"),
247/// }
248/// // A cancelled or first-error-aborted stream closes with done < total:
249/// match (ProgressEvent::Finished { done: 1, total: 2 }) {
250/// ProgressEvent::Finished { done, total } => assert!(done < total),
251/// _ => {}
252/// }
253/// ```
254#[non_exhaustive]
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum ProgressEvent<'a> {
257 /// Free-form status line emitted while long-running work advances.
258 Message(&'a str),
259 /// A unit-counted stream is opening; `total` is the number of units.
260 ///
261 /// `total: 0` is a real stream, not an absence of one: the batch was
262 /// empty, and a renderer still gets an open-and-close signal.
263 Started {
264 /// Number of units the stream will advance through.
265 total: u64,
266 },
267 /// `done` of `total` units are complete.
268 ///
269 /// # Arguments
270 ///
271 /// * `item` - Name of the unit that just completed, when the emitter
272 /// has one to hand out (the audit loop names the subtitle file; a
273 /// completion counter with no natural per-unit name passes `None`).
274 Advanced {
275 /// Units completed so far; non-decreasing, never above `total`.
276 done: u64,
277 /// Total units the opening `Started` announced.
278 total: u64,
279 /// The unit that just completed, when it has a name.
280 item: Option<&'a str>,
281 },
282 /// The stream is closing; `done < total` means it stopped early.
283 ///
284 /// Stopping early is a normal close, not a failure: cancellation and
285 /// first-error aborts both end their streams this way. A failure of the
286 /// operation itself is carried by the return value, never by a progress
287 /// event.
288 Finished {
289 /// Units actually completed before the stream closed.
290 done: u64,
291 /// Total units the opening `Started` announced.
292 total: u64,
293 },
294}
295
296/// `dyn Reporter` crosses thread boundaries inside engines; pin the bound.
297const _: fn() = || {
298 fn assert_send_sync<T: Send + Sync>() {}
299 assert_send_sync::<Arc<dyn Reporter>>();
300};
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use std::sync::Mutex;
306
307 /// Per-test recording double (never global state — AGENTS.md).
308 #[derive(Default)]
309 struct RecordingReporter {
310 events: Mutex<Vec<String>>,
311 }
312
313 impl RecordingReporter {
314 fn recorded(&self) -> Vec<String> {
315 self.events.lock().unwrap().clone()
316 }
317 }
318
319 impl Reporter for RecordingReporter {
320 fn diagnostic(&self, message: &str) {
321 self.events
322 .lock()
323 .unwrap()
324 .push(format!("diagnostic:{message}"));
325 }
326 fn warn(&self, message: &str) {
327 self.events.lock().unwrap().push(format!("warn:{message}"));
328 }
329 fn ai_usage(&self, usage: &AiUsage) {
330 self.events.lock().unwrap().push(format!(
331 "ai_usage:{}:{}:{}:{}",
332 usage.model, usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
333 ));
334 }
335 fn progress(&self, event: &ProgressEvent<'_>) {
336 // All four real variants are named; the wildcard stays for
337 // future non_exhaustive additions.
338 #[allow(unreachable_patterns)]
339 match event {
340 ProgressEvent::Message(message) => {
341 self.events
342 .lock()
343 .unwrap()
344 .push(format!("progress:{message}"));
345 }
346 ProgressEvent::Started { total } => {
347 self.events
348 .lock()
349 .unwrap()
350 .push(format!("progress:started:{total}"));
351 }
352 ProgressEvent::Advanced { done, total, item } => {
353 self.events.lock().unwrap().push(format!(
354 "progress:advanced:{done}/{total}:{}",
355 item.unwrap_or("-")
356 ));
357 }
358 ProgressEvent::Finished { done, total } => {
359 self.events
360 .lock()
361 .unwrap()
362 .push(format!("progress:finished:{done}/{total}"));
363 }
364 _ => self.events.lock().unwrap().push("progress:_".to_string()),
365 }
366 }
367 }
368
369 fn sample_usage() -> AiUsage {
370 AiUsage {
371 model: "gpt-4.1-mini".to_string(),
372 prompt_tokens: 100,
373 completion_tokens: 50,
374 total_tokens: 150,
375 }
376 }
377
378 #[test]
379 fn recording_reporter_captures_every_channel_verbatim() {
380 let reporter = RecordingReporter::default();
381 reporter.diagnostic("diag");
382 reporter.warn("warn");
383 reporter.ai_usage(&sample_usage());
384 reporter.progress(&ProgressEvent::Message("tick"));
385
386 assert_eq!(
387 reporter.recorded(),
388 vec![
389 "diagnostic:diag",
390 "warn:warn",
391 "ai_usage:gpt-4.1-mini:100:50:150",
392 "progress:tick",
393 ],
394 "each channel must receive exactly what core gave it"
395 );
396 }
397
398 #[test]
399 fn noop_reporter_swallows_all_channels() {
400 // No observable output is possible; reaching the end without a
401 // panic is the assertion.
402 let reporter = noop();
403 reporter.diagnostic("x");
404 reporter.warn("x");
405 reporter.ai_usage(&sample_usage());
406 reporter.progress(&ProgressEvent::Message("x"));
407 }
408
409 #[test]
410 fn default_trait_impl_is_all_noop() {
411 // Implementors opting into no methods must compile via defaults.
412 struct Silent;
413 impl Reporter for Silent {}
414 let silent = Silent;
415 silent.diagnostic("x");
416 silent.warn("x");
417 silent.ai_usage(&sample_usage());
418 silent.progress(&ProgressEvent::Message("x"));
419 }
420
421 #[test]
422 fn progress_event_matches_with_wildcard_arm() {
423 // Proves #[non_exhaustive] usability: exhaustive matching without
424 // `_` is rejected across crate boundaries, so consumers carry `_`.
425 // Inside the defining crate `_` is statically unreachable.
426 let event = ProgressEvent::Message("status");
427 #[allow(unreachable_patterns)]
428 let text = match &event {
429 ProgressEvent::Message(m) => *m,
430 _ => "unknown",
431 };
432 assert_eq!(text, "status");
433 assert_eq!(event, ProgressEvent::Message("status"));
434 }
435
436 #[test]
437 fn structured_variants_record_and_compare_by_value() {
438 // Eq across every field (u64 / Option<&str>) — the derive A1 pinned
439 // must survive the variants D2 adds (cargo-semver-checks reads a
440 // removed derive as major).
441 assert_eq!(
442 ProgressEvent::Advanced {
443 done: 1,
444 total: 2,
445 item: Some("a.srt")
446 },
447 ProgressEvent::Advanced {
448 done: 1,
449 total: 2,
450 item: Some("a.srt")
451 }
452 );
453 assert_ne!(
454 ProgressEvent::Advanced {
455 done: 1,
456 total: 2,
457 item: Some("a.srt")
458 },
459 ProgressEvent::Advanced {
460 done: 2,
461 total: 2,
462 item: Some("a.srt")
463 }
464 );
465 assert_eq!(
466 ProgressEvent::Started { total: 0 },
467 ProgressEvent::Started { total: 0 }
468 );
469 assert_ne!(
470 ProgressEvent::Finished { done: 1, total: 2 },
471 ProgressEvent::Finished { done: 2, total: 2 }
472 );
473
474 let reporter = RecordingReporter::default();
475 reporter.progress(&ProgressEvent::Started { total: 2 });
476 reporter.progress(&ProgressEvent::Advanced {
477 done: 1,
478 total: 2,
479 item: Some("a.srt"),
480 });
481 reporter.progress(&ProgressEvent::Advanced {
482 done: 2,
483 total: 2,
484 item: None,
485 });
486 reporter.progress(&ProgressEvent::Finished { done: 2, total: 2 });
487 assert_eq!(
488 reporter.recorded(),
489 vec![
490 "progress:started:2",
491 "progress:advanced:1/2:a.srt",
492 "progress:advanced:2/2:-",
493 "progress:finished:2/2",
494 ]
495 );
496 }
497
498 #[test]
499 fn four_variant_match_with_wildcard_compiles() {
500 // The spec-mandated consumer shape: every real variant named plus
501 // `_` for future non_exhaustive additions.
502 let events = [
503 ProgressEvent::Message("m"),
504 ProgressEvent::Started { total: 1 },
505 ProgressEvent::Advanced {
506 done: 1,
507 total: 1,
508 item: None,
509 },
510 ProgressEvent::Finished { done: 1, total: 1 },
511 ];
512 #[allow(unreachable_patterns)]
513 let kinds: Vec<&str> = events
514 .iter()
515 .map(|event| match event {
516 ProgressEvent::Message(_) => "message",
517 ProgressEvent::Started { .. } => "started",
518 ProgressEvent::Advanced { .. } => "advanced",
519 ProgressEvent::Finished { .. } => "finished",
520 _ => "unknown",
521 })
522 .collect();
523 assert_eq!(kinds, ["message", "started", "advanced", "finished"]);
524 }
525
526 #[test]
527 fn cancelled_defaults_to_false_for_non_implementors() {
528 // Noop and the plain handle: never cancel.
529 assert!(!noop().cancelled());
530 assert!(!NoopReporter.cancelled());
531 // A reporter opting into exactly one channel still gets the
532 // provided default: `cancelled` must not force implementors out
533 // of their silence.
534 struct WarnOnly;
535 impl Reporter for WarnOnly {
536 fn warn(&self, _message: &str) {}
537 }
538 assert!(!WarnOnly.cancelled());
539 }
540
541 #[test]
542 fn cancelled_override_is_observable_through_the_trait_object() {
543 use std::sync::atomic::{AtomicBool, Ordering};
544 struct Cancellable(AtomicBool);
545 impl Reporter for Cancellable {
546 fn cancelled(&self) -> bool {
547 self.0.load(Ordering::SeqCst)
548 }
549 }
550 let reporter = Cancellable(AtomicBool::new(false));
551 assert!(!reporter.cancelled());
552 reporter.0.store(true, Ordering::SeqCst);
553 assert!(reporter.cancelled());
554 // Same answer through Arc<dyn Reporter>, as engines hold it.
555 let owned: Arc<dyn Reporter> = Arc::new(Cancellable(AtomicBool::new(true)));
556 assert!(owned.cancelled());
557 }
558
559 #[test]
560 fn ai_usage_round_trips_its_fields() {
561 let usage = sample_usage();
562 assert_eq!(usage.model, "gpt-4.1-mini");
563 assert_eq!((usage.prompt_tokens, usage.completion_tokens), (100, 50));
564 assert_eq!(usage.total_tokens, 150);
565 assert_eq!(usage.clone(), usage);
566 }
567
568 /// A1 §7.2 — a `MatchEngine` with **no** reporter attached must emit
569 /// zero stdout/stderr bytes on a path that used to print directly.
570 ///
571 /// In-process capture of `eprintln!` would require global-state hacks
572 /// (forbidden), so the child process re-executes this test binary in a
573 /// mode that runs the previously-printing analysis path through a
574 /// default (no-op) reporter while the parent asserts the child's
575 /// captured streams are empty.
576 #[test]
577 fn match_engine_without_reporter_prints_nothing() {
578 if std::env::var_os("SUBX_TEST_NOOP_ENGINE_CHILD").is_some() {
579 // Child: run the previously-printing path with no reporter.
580 let rt = tokio::runtime::Builder::new_current_thread()
581 .enable_all()
582 .build()
583 .unwrap();
584 rt.block_on(async {
585 let dir =
586 std::env::temp_dir().join(format!("subx-noop-child-{}", std::process::id()));
587 std::fs::create_dir_all(&dir).unwrap();
588 std::fs::write(dir.join("Movie.mp4"), b"v").unwrap();
589 std::fs::write(
590 dir.join("movie.srt"),
591 "1\n00:00:01,000 --> 00:00:02,000\nhello\n\n",
592 )
593 .unwrap();
594 let files: Vec<std::path::PathBuf> =
595 vec![dir.join("Movie.mp4"), dir.join("movie.srt")];
596 use crate::core::matcher::engine::{ConflictResolution, FileRelocationMode};
597 let engine = crate::core::matcher::engine::MatchEngine::new(
598 Box::new(NoopProvider),
599 crate::core::matcher::engine::MatchConfig {
600 confidence_threshold: 0.8,
601 max_sample_length: 2000,
602 enable_content_analysis: false,
603 backup_enabled: false,
604 relocation_mode: FileRelocationMode::None,
605 conflict_resolution: ConflictResolution::Skip,
606 ai_model: "noop".to_string(),
607 max_subtitle_bytes: 52_428_800,
608 },
609 );
610 // match_file_list previously printed the AI analysis
611 // block, the available-files dump and the no-matches
612 // dump directly to the terminal.
613 let _ = engine.match_file_list(&files).await;
614 let _ = std::fs::remove_dir_all(&dir);
615 });
616 return;
617 }
618
619 let output = std::process::Command::new(std::env::current_exe().unwrap())
620 .arg("core::report::tests::match_engine_without_reporter_prints_nothing")
621 .arg("--exact")
622 .env("SUBX_TEST_NOOP_ENGINE_CHILD", "1")
623 .env("RUST_TEST_THREADS", "1")
624 .output()
625 .expect("re-exec test binary");
626 assert!(
627 output.status.success(),
628 "child test failed:\n{}",
629 String::from_utf8_lossy(&output.stderr)
630 );
631 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
632 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
633 // The child's own harness may print its status lines; the engine
634 // must add nothing: no engine chatter markers at all.
635 for marker in [
636 "🔍",
637 "❌",
638 "Available",
639 "AI Analysis Results",
640 "No matching files found",
641 ] {
642 assert!(
643 !stdout.contains(marker) && !stderr.contains(marker),
644 "reporter-less MatchEngine leaked {marker:?} — stdout:\n{stdout}\nstderr:\n{stderr}"
645 );
646 }
647 }
648
649 /// AI provider stub: returns zero matches so the previously-printing
650 /// no-matches path runs without needing a network or mock server.
651 struct NoopProvider;
652
653 #[async_trait::async_trait]
654 impl crate::services::ai::AIProvider for NoopProvider {
655 async fn analyze_content(
656 &self,
657 _request: crate::services::ai::AnalysisRequest,
658 ) -> crate::Result<crate::services::ai::MatchResult> {
659 Ok(crate::services::ai::MatchResult {
660 matches: Vec::new(),
661 confidence: 0.0,
662 reasoning: "noop".to_string(),
663 })
664 }
665
666 async fn verify_match(
667 &self,
668 _verification: crate::services::ai::VerificationRequest,
669 ) -> crate::Result<crate::services::ai::ConfidenceScore> {
670 unimplemented!("unused by the noop-engine test")
671 }
672 }
673}