Skip to main content

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}