use std::sync::Arc;
pub trait Reporter: Send + Sync {
fn diagnostic(&self, message: &str) {
let _ = message;
}
fn warn(&self, message: &str) {
let _ = message;
}
fn ai_usage(&self, usage: &AiUsage) {
let _ = usage;
}
fn progress(&self, event: &ProgressEvent<'_>) {
let _ = event;
}
fn cancelled(&self) -> bool {
false
}
}
pub struct NoopReporter;
impl Reporter for NoopReporter {}
pub fn noop() -> Arc<dyn Reporter> {
Arc::new(NoopReporter)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AiUsage {
pub model: String,
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProgressEvent<'a> {
Message(&'a str),
Started {
total: u64,
},
Advanced {
done: u64,
total: u64,
item: Option<&'a str>,
},
Finished {
done: u64,
total: u64,
},
}
const _: fn() = || {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Arc<dyn Reporter>>();
};
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Default)]
struct RecordingReporter {
events: Mutex<Vec<String>>,
}
impl RecordingReporter {
fn recorded(&self) -> Vec<String> {
self.events.lock().unwrap().clone()
}
}
impl Reporter for RecordingReporter {
fn diagnostic(&self, message: &str) {
self.events
.lock()
.unwrap()
.push(format!("diagnostic:{message}"));
}
fn warn(&self, message: &str) {
self.events.lock().unwrap().push(format!("warn:{message}"));
}
fn ai_usage(&self, usage: &AiUsage) {
self.events.lock().unwrap().push(format!(
"ai_usage:{}:{}:{}:{}",
usage.model, usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
));
}
fn progress(&self, event: &ProgressEvent<'_>) {
#[allow(unreachable_patterns)]
match event {
ProgressEvent::Message(message) => {
self.events
.lock()
.unwrap()
.push(format!("progress:{message}"));
}
ProgressEvent::Started { total } => {
self.events
.lock()
.unwrap()
.push(format!("progress:started:{total}"));
}
ProgressEvent::Advanced { done, total, item } => {
self.events.lock().unwrap().push(format!(
"progress:advanced:{done}/{total}:{}",
item.unwrap_or("-")
));
}
ProgressEvent::Finished { done, total } => {
self.events
.lock()
.unwrap()
.push(format!("progress:finished:{done}/{total}"));
}
_ => self.events.lock().unwrap().push("progress:_".to_string()),
}
}
}
fn sample_usage() -> AiUsage {
AiUsage {
model: "gpt-4.1-mini".to_string(),
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
}
}
#[test]
fn recording_reporter_captures_every_channel_verbatim() {
let reporter = RecordingReporter::default();
reporter.diagnostic("diag");
reporter.warn("warn");
reporter.ai_usage(&sample_usage());
reporter.progress(&ProgressEvent::Message("tick"));
assert_eq!(
reporter.recorded(),
vec![
"diagnostic:diag",
"warn:warn",
"ai_usage:gpt-4.1-mini:100:50:150",
"progress:tick",
],
"each channel must receive exactly what core gave it"
);
}
#[test]
fn noop_reporter_swallows_all_channels() {
let reporter = noop();
reporter.diagnostic("x");
reporter.warn("x");
reporter.ai_usage(&sample_usage());
reporter.progress(&ProgressEvent::Message("x"));
}
#[test]
fn default_trait_impl_is_all_noop() {
struct Silent;
impl Reporter for Silent {}
let silent = Silent;
silent.diagnostic("x");
silent.warn("x");
silent.ai_usage(&sample_usage());
silent.progress(&ProgressEvent::Message("x"));
}
#[test]
fn progress_event_matches_with_wildcard_arm() {
let event = ProgressEvent::Message("status");
#[allow(unreachable_patterns)]
let text = match &event {
ProgressEvent::Message(m) => *m,
_ => "unknown",
};
assert_eq!(text, "status");
assert_eq!(event, ProgressEvent::Message("status"));
}
#[test]
fn structured_variants_record_and_compare_by_value() {
assert_eq!(
ProgressEvent::Advanced {
done: 1,
total: 2,
item: Some("a.srt")
},
ProgressEvent::Advanced {
done: 1,
total: 2,
item: Some("a.srt")
}
);
assert_ne!(
ProgressEvent::Advanced {
done: 1,
total: 2,
item: Some("a.srt")
},
ProgressEvent::Advanced {
done: 2,
total: 2,
item: Some("a.srt")
}
);
assert_eq!(
ProgressEvent::Started { total: 0 },
ProgressEvent::Started { total: 0 }
);
assert_ne!(
ProgressEvent::Finished { done: 1, total: 2 },
ProgressEvent::Finished { done: 2, total: 2 }
);
let reporter = RecordingReporter::default();
reporter.progress(&ProgressEvent::Started { total: 2 });
reporter.progress(&ProgressEvent::Advanced {
done: 1,
total: 2,
item: Some("a.srt"),
});
reporter.progress(&ProgressEvent::Advanced {
done: 2,
total: 2,
item: None,
});
reporter.progress(&ProgressEvent::Finished { done: 2, total: 2 });
assert_eq!(
reporter.recorded(),
vec![
"progress:started:2",
"progress:advanced:1/2:a.srt",
"progress:advanced:2/2:-",
"progress:finished:2/2",
]
);
}
#[test]
fn four_variant_match_with_wildcard_compiles() {
let events = [
ProgressEvent::Message("m"),
ProgressEvent::Started { total: 1 },
ProgressEvent::Advanced {
done: 1,
total: 1,
item: None,
},
ProgressEvent::Finished { done: 1, total: 1 },
];
#[allow(unreachable_patterns)]
let kinds: Vec<&str> = events
.iter()
.map(|event| match event {
ProgressEvent::Message(_) => "message",
ProgressEvent::Started { .. } => "started",
ProgressEvent::Advanced { .. } => "advanced",
ProgressEvent::Finished { .. } => "finished",
_ => "unknown",
})
.collect();
assert_eq!(kinds, ["message", "started", "advanced", "finished"]);
}
#[test]
fn cancelled_defaults_to_false_for_non_implementors() {
assert!(!noop().cancelled());
assert!(!NoopReporter.cancelled());
struct WarnOnly;
impl Reporter for WarnOnly {
fn warn(&self, _message: &str) {}
}
assert!(!WarnOnly.cancelled());
}
#[test]
fn cancelled_override_is_observable_through_the_trait_object() {
use std::sync::atomic::{AtomicBool, Ordering};
struct Cancellable(AtomicBool);
impl Reporter for Cancellable {
fn cancelled(&self) -> bool {
self.0.load(Ordering::SeqCst)
}
}
let reporter = Cancellable(AtomicBool::new(false));
assert!(!reporter.cancelled());
reporter.0.store(true, Ordering::SeqCst);
assert!(reporter.cancelled());
let owned: Arc<dyn Reporter> = Arc::new(Cancellable(AtomicBool::new(true)));
assert!(owned.cancelled());
}
#[test]
fn ai_usage_round_trips_its_fields() {
let usage = sample_usage();
assert_eq!(usage.model, "gpt-4.1-mini");
assert_eq!((usage.prompt_tokens, usage.completion_tokens), (100, 50));
assert_eq!(usage.total_tokens, 150);
assert_eq!(usage.clone(), usage);
}
#[test]
fn match_engine_without_reporter_prints_nothing() {
if std::env::var_os("SUBX_TEST_NOOP_ENGINE_CHILD").is_some() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let dir =
std::env::temp_dir().join(format!("subx-noop-child-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Movie.mp4"), b"v").unwrap();
std::fs::write(
dir.join("movie.srt"),
"1\n00:00:01,000 --> 00:00:02,000\nhello\n\n",
)
.unwrap();
let files: Vec<std::path::PathBuf> =
vec![dir.join("Movie.mp4"), dir.join("movie.srt")];
use crate::core::matcher::engine::{ConflictResolution, FileRelocationMode};
let engine = crate::core::matcher::engine::MatchEngine::new(
Box::new(NoopProvider),
crate::core::matcher::engine::MatchConfig {
confidence_threshold: 0.8,
max_sample_length: 2000,
enable_content_analysis: false,
backup_enabled: false,
relocation_mode: FileRelocationMode::None,
conflict_resolution: ConflictResolution::Skip,
ai_model: "noop".to_string(),
max_subtitle_bytes: 52_428_800,
},
);
let _ = engine.match_file_list(&files).await;
let _ = std::fs::remove_dir_all(&dir);
});
return;
}
let output = std::process::Command::new(std::env::current_exe().unwrap())
.arg("core::report::tests::match_engine_without_reporter_prints_nothing")
.arg("--exact")
.env("SUBX_TEST_NOOP_ENGINE_CHILD", "1")
.env("RUST_TEST_THREADS", "1")
.output()
.expect("re-exec test binary");
assert!(
output.status.success(),
"child test failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
for marker in [
"🔍",
"❌",
"Available",
"AI Analysis Results",
"No matching files found",
] {
assert!(
!stdout.contains(marker) && !stderr.contains(marker),
"reporter-less MatchEngine leaked {marker:?} — stdout:\n{stdout}\nstderr:\n{stderr}"
);
}
}
struct NoopProvider;
#[async_trait::async_trait]
impl crate::services::ai::AIProvider for NoopProvider {
async fn analyze_content(
&self,
_request: crate::services::ai::AnalysisRequest,
) -> crate::Result<crate::services::ai::MatchResult> {
Ok(crate::services::ai::MatchResult {
matches: Vec::new(),
confidence: 0.0,
reasoning: "noop".to_string(),
})
}
async fn verify_match(
&self,
_verification: crate::services::ai::VerificationRequest,
) -> crate::Result<crate::services::ai::ConfidenceScore> {
unimplemented!("unused by the noop-engine test")
}
}
}