Skip to main content

xil/cmd/
sfx_match.rs

1//! `xil sfx-match` — find existing library assets for cues whose declared
2//! source is not on disk. Port of `XILU022_sfx_match.py`.
3//!
4//! Each missing cue is scored against every pool asset's title (else its
5//! prompt, else its filename) on coverage (`|cue ∩ cand| / |cue|`), with
6//! jaccard as the tie-breaker. A category gate keeps AMBIENCE cues off
7//! MUSIC assets, and a margin rule sends near-ties to human review.
8
9use std::collections::BTreeSet;
10use std::ffi::OsString;
11use std::fs;
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::sync::LazyLock;
15
16use clap::Parser;
17use regex::Regex;
18use serde_json::{Map, Value};
19use xil_core::fsutil::{abspath, basename, relpath};
20use xil_core::journal::append_sfx_edit;
21use xil_core::pycsv;
22use xil_core::pyfmt::{fixed, head, pad_right, round_to};
23use xil_core::pyjson::{dumps, py_float, Style};
24use xil_core::sfxlib::{shared_sfx_path, slugify_effect_key};
25use xil_core::workspace::{resolve_slug, workspace_root};
26use xil_core::{banner, log};
27
28use super::sfx_impact::{classify_placement, discover_configs};
29use super::sfx_lib::fetch_local_records;
30
31const SCRIPT_NAME: &str = "XILU022_sfx_match";
32
33const TIERS: [&str; 4] = ["EXACT", "STRONG", "REVIEW", "NONE"];
34
35/// Tokens with no discriminating signal. "up", "out", "down", "off" and
36/// "back" are deliberately kept: cue text leans on them.
37const STOPWORDS: [&str; 32] = [
38    "sfx",
39    "ambience",
40    "ambient",
41    "music",
42    "beat",
43    "new",
44    "stem",
45    "needed",
46    "mp3",
47    "wav",
48    "elevenlabs",
49    "a",
50    "an",
51    "and",
52    "as",
53    "at",
54    "by",
55    "for",
56    "from",
57    "in",
58    "into",
59    "it",
60    "its",
61    "of",
62    "on",
63    "or",
64    "s",
65    "the",
66    "then",
67    "to",
68    "with",
69    "",
70];
71
72const REVIEW_FLOOR: f64 = 0.5;
73const STRONG_COVERAGE: f64 = 0.8;
74const STRONG_JACCARD: f64 = 0.4;
75const STRONG_MARGIN: f64 = 0.15;
76const JACCARD_WEIGHT: f64 = 0.25;
77
78const CSV_COLUMNS: [&str; 14] = [
79    "show",
80    "episode",
81    "cue",
82    "current_source",
83    "tier",
84    "rank",
85    "candidate",
86    "candidate_title",
87    "candidate_scope",
88    "coverage",
89    "jaccard",
90    "score",
91    "duration_s",
92    "also_in",
93];
94
95static TOKEN_SPLIT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^a-z0-9]+").unwrap());
96
97#[derive(Parser)]
98#[command(
99    name = "xil-sfx-match",
100    about = "Find existing SFX library assets for cues whose declared source is not on disk — the cues that make 'xil produce' refuse to start. Reports by default; --apply copies the match into the show's pool and journals the new source."
101)]
102struct Args {
103    /// Restrict to one show slug (default: every show in the workspace)
104    #[arg(long)]
105    show: Option<String>,
106    /// Restrict to one episode tag (e.g. S01E01)
107    #[arg(long, alias = "tag")]
108    episode: Option<String>,
109    /// Candidates to report per cue (default: 3)
110    #[arg(long, default_value_t = 3)]
111    top: usize,
112    /// Coverage a candidate must reach to be reported (default: 0.5)
113    #[arg(long, default_value_t = REVIEW_FLOOR)]
114    min_coverage: f64,
115    /// CSV output path, or '-' for stdout (default: reports/sfx_match_<date>.csv)
116    #[arg(long)]
117    output: Option<String>,
118    /// Also write a paste-ready pipe-hint block (default path: reports/sfx_match_hints_<date>.md)
119    #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
120    emit_hints: Option<String>,
121    /// Copy each accepted match into the show's SFX pool under the cue's own slug, retitle the copy, and journal the new source. Acts on EXACT and STRONG only unless --accept-review is given.
122    #[arg(long)]
123    apply: bool,
124    /// Widen --apply to REVIEW matches. Read the CSV first — a REVIEW tier means the top candidate had no clear margin over its runners-up.
125    #[arg(long)]
126    accept_review: bool,
127    /// With --apply, report every copy and journal record without writing
128    #[arg(long)]
129    dry_run: bool,
130    /// Suppress the console summary (CSV only)
131    #[arg(long)]
132    quiet: bool,
133}
134
135// ── text handling ──────────────────────────────────────────────────────────
136
137/// Fold a simple plural so CHAIRS matches CHAIR (`glass` is protected).
138fn singular(token: &str) -> String {
139    if token.chars().count() >= 4 && token.ends_with('s') && !token.ends_with("ss") {
140        token[..token.len() - 1].to_string()
141    } else {
142        token.to_string()
143    }
144}
145
146/// Lowercase, stopword-stripped, singularised token set.
147pub fn tokenize(text: &str) -> BTreeSet<String> {
148    let lower = text.to_lowercase();
149    TOKEN_SPLIT
150        .split(&lower)
151        .filter(|t| !STOPWORDS.contains(t))
152        .map(singular)
153        .collect()
154}
155
156/// `(coverage, jaccard)` of a cue against a candidate.
157fn score_pair(cue: &BTreeSet<String>, cand: &BTreeSet<String>) -> (f64, f64) {
158    if cue.is_empty() || cand.is_empty() {
159        return (0.0, 0.0);
160    }
161    let shared = cue.intersection(cand).count() as f64;
162    let union = cue.union(cand).count() as f64;
163    (shared / cue.len() as f64, shared / union)
164}
165
166fn composite(coverage: f64, jaccard: f64) -> f64 {
167    coverage + JACCARD_WEIGHT * jaccard
168}
169
170/// `f[3].isalpha()` on the lowercased filename.
171fn fourth_is_alpha(f: &str) -> bool {
172    f.chars().nth(3).is_some_and(char::is_alphabetic)
173}
174
175/// Classify an untitled asset by filename prefix.
176fn placement_from_filename(filename: &str) -> &'static str {
177    let f = filename.to_lowercase();
178    if ["ambience_", "ambience-", "amb-", "amb_"]
179        .iter()
180        .any(|p| f.starts_with(p))
181        || (f.starts_with("amb") && fourth_is_alpha(&f))
182    {
183        return "AMBI(bg)";
184    }
185    if ["music_", "music-", "mus-", "mus_"]
186        .iter()
187        .any(|p| f.starts_with(p))
188        || (f.starts_with("mus") && fourth_is_alpha(&f))
189    {
190        return "MUSIC(bg)";
191    }
192    if f.starts_with("beat") {
193        return "BEAT(fg)";
194    }
195    "SFX(fg)"
196}
197
198// ── pool ───────────────────────────────────────────────────────────────────
199
200#[derive(Debug, Clone)]
201struct PoolAsset {
202    filename: String,
203    path: String,
204    /// Show slug, or "" for the flat SFX/ root.
205    scope: String,
206    title: String,
207    prompt: String,
208    duration_s: Option<f64>,
209    placement: &'static str,
210    tokens: BTreeSet<String>,
211    also_in: Vec<String>,
212}
213
214impl PoolAsset {
215    /// Title, else prompt, else filename.
216    fn match_text(&self) -> &str {
217        if !self.title.is_empty() {
218            &self.title
219        } else if !self.prompt.is_empty() {
220            &self.prompt
221        } else {
222            &self.filename
223        }
224    }
225}
226
227fn scope_rank(scope: &str, own_show: Option<&str>) -> u8 {
228    if own_show.is_some_and(|s| s == scope) {
229        0
230    } else if scope.is_empty() {
231        1
232    } else {
233        2
234    }
235}
236
237/// The filename still agrees with the title it carries.
238fn is_canonical_name(a: &PoolAsset) -> bool {
239    !a.title.is_empty() && a.filename == format!("{}.mp3", slugify_effect_key(&a.title))
240}
241
242fn asset_from_record(rec: &Map<String, Value>) -> PoolAsset {
243    let s = |k: &str| rec.get(k).and_then(Value::as_str).unwrap_or("");
244    let title = s("title").trim().to_string();
245    let filename = s("filename").to_string();
246    let placement = if title.is_empty() {
247        placement_from_filename(&filename)
248    } else {
249        classify_placement(&title)
250    };
251    let mut a = PoolAsset {
252        filename,
253        path: s("path").to_string(),
254        scope: s("show").to_string(),
255        title,
256        prompt: s("prompt").trim().to_string(),
257        duration_s: rec.get("duration_seconds").and_then(Value::as_f64),
258        placement,
259        tokens: BTreeSet::new(),
260        also_in: Vec::new(),
261    };
262    a.tokens = tokenize(a.match_text());
263    a
264}
265
266fn load_assets(workspace: &Path) -> Vec<PoolAsset> {
267    fetch_local_records(&workspace.join("SFX"))
268        .iter()
269        .map(asset_from_record)
270        .collect()
271}
272
273/// One entry per distinct sound, grouped by `(match tokens, duration)`.
274fn build_pool(assets: &[PoolAsset], own_show: Option<&str>) -> Vec<PoolAsset> {
275    let mut groups: indexmap::IndexMap<(String, Option<u64>), Vec<&PoolAsset>> =
276        indexmap::IndexMap::new();
277    for a in assets {
278        let key = (
279            a.tokens.iter().cloned().collect::<Vec<_>>().join(" "),
280            a.duration_s.map(f64::to_bits),
281        );
282        groups.entry(key).or_default().push(a);
283    }
284    let mut pool = Vec::new();
285    for members in groups.values_mut() {
286        members.sort_by(|x, y| {
287            (
288                scope_rank(&x.scope, own_show),
289                !is_canonical_name(x),
290                x.filename.chars().count(),
291                &x.filename,
292            )
293                .cmp(&(
294                    scope_rank(&y.scope, own_show),
295                    !is_canonical_name(y),
296                    y.filename.chars().count(),
297                    &y.filename,
298                ))
299        });
300        let also_in: BTreeSet<String> = members[1..]
301            .iter()
302            .map(|m| {
303                if m.scope.is_empty() {
304                    "SFX/".to_string()
305                } else {
306                    m.scope.clone()
307                }
308            })
309            .collect();
310        let mut winner = members[0].clone();
311        winner.also_in = also_in.into_iter().collect();
312        pool.push(winner);
313    }
314    pool
315}
316
317/// filename → preferred copy, over every asset rather than the deduped pool.
318fn build_exact_index(
319    assets: &[PoolAsset],
320    own_show: Option<&str>,
321) -> indexmap::IndexMap<String, PoolAsset> {
322    let mut index: indexmap::IndexMap<String, PoolAsset> = indexmap::IndexMap::new();
323    for a in assets {
324        let replace = match index.get(&a.filename) {
325            None => true,
326            Some(cur) => scope_rank(&a.scope, own_show) < scope_rank(&cur.scope, own_show),
327        };
328        if replace {
329            index.insert(a.filename.clone(), a.clone());
330        }
331    }
332    index
333}
334
335// ── matching ───────────────────────────────────────────────────────────────
336
337#[derive(Debug, Clone)]
338struct Candidate {
339    asset: PoolAsset,
340    coverage: f64,
341    jaccard: f64,
342}
343
344impl Candidate {
345    fn score(&self) -> f64 {
346        composite(self.coverage, self.jaccard)
347    }
348}
349
350struct CueMatch {
351    show: String,
352    episode: String,
353    cue: String,
354    current_source: String,
355    config_path: PathBuf,
356    tier: &'static str,
357    candidates: Vec<Candidate>,
358}
359
360impl CueMatch {
361    fn best(&self) -> Option<&Candidate> {
362        self.candidates.first()
363    }
364
365    fn rows(&self) -> Vec<Map<String, Value>> {
366        let base = || {
367            let mut m = Map::new();
368            m.insert("show".into(), self.show.clone().into());
369            m.insert("episode".into(), self.episode.clone().into());
370            m.insert("cue".into(), self.cue.clone().into());
371            m.insert("current_source".into(), self.current_source.clone().into());
372            m.insert("tier".into(), self.tier.into());
373            m
374        };
375        if self.candidates.is_empty() {
376            let mut m = base();
377            for c in &CSV_COLUMNS[5..] {
378                m.insert((*c).into(), "".into());
379            }
380            return vec![m];
381        }
382        self.candidates
383            .iter()
384            .enumerate()
385            .map(|(i, c)| {
386                let mut m = base();
387                m.insert("rank".into(), Value::from(i + 1));
388                m.insert("candidate".into(), c.asset.filename.clone().into());
389                m.insert("candidate_title".into(), c.asset.match_text().into());
390                m.insert(
391                    "candidate_scope".into(),
392                    if c.asset.scope.is_empty() {
393                        "SFX/".into()
394                    } else {
395                        c.asset.scope.clone().into()
396                    },
397                );
398                m.insert("coverage".into(), py_float(round_to(c.coverage, 2)));
399                m.insert("jaccard".into(), py_float(round_to(c.jaccard, 2)));
400                m.insert("score".into(), py_float(round_to(c.score(), 3)));
401                m.insert(
402                    "duration_s".into(),
403                    c.asset.duration_s.map_or("".into(), py_float),
404                );
405                m.insert("also_in".into(), c.asset.also_in.join(", ").into());
406                m
407            })
408            .collect()
409    }
410}
411
412fn match_cue(
413    cue: &str,
414    pool: &[PoolAsset],
415    exact_index: &indexmap::IndexMap<String, PoolAsset>,
416    top: usize,
417    min_coverage: f64,
418) -> (&'static str, Vec<Candidate>) {
419    let exact_name = format!("{}.mp3", slugify_effect_key(cue));
420    let hit = exact_index
421        .get(&exact_name)
422        .or_else(|| pool.iter().find(|a| a.filename == exact_name));
423    if let Some(hit) = hit {
424        return (
425            "EXACT",
426            vec![Candidate {
427                asset: hit.clone(),
428                coverage: 1.0,
429                jaccard: 1.0,
430            }],
431        );
432    }
433
434    let cue_tokens = tokenize(cue);
435    let wanted = classify_placement(cue);
436    let mut scored: Vec<Candidate> = pool
437        .iter()
438        .filter(|a| a.placement == wanted)
439        .filter_map(|a| {
440            let (cov, jac) = score_pair(&cue_tokens, &a.tokens);
441            (cov >= min_coverage).then(|| Candidate {
442                asset: a.clone(),
443                coverage: cov,
444                jaccard: jac,
445            })
446        })
447        .collect();
448    scored.sort_by(|x, y| {
449        y.score()
450            .partial_cmp(&x.score())
451            .unwrap_or(std::cmp::Ordering::Equal)
452            .then_with(|| x.asset.filename.cmp(&y.asset.filename))
453    });
454    scored.truncate(top);
455
456    let Some(leader) = scored.first() else {
457        return ("NONE", scored);
458    };
459    let margin = leader.score() - scored.get(1).map_or(0.0, Candidate::score);
460    let tier = if leader.coverage >= STRONG_COVERAGE
461        && leader.jaccard >= STRONG_JACCARD
462        && margin >= STRONG_MARGIN
463    {
464        "STRONG"
465    } else {
466        "REVIEW"
467    };
468    (tier, scored)
469}
470
471fn source_missing(source: &str, workspace: &Path) -> bool {
472    if source.is_empty() {
473        return false;
474    }
475    let p = Path::new(source);
476    let p = if p.is_absolute() {
477        p.to_path_buf()
478    } else {
479        workspace.join(source)
480    };
481    !p.is_file()
482}
483
484fn analyze(
485    workspace: &Path,
486    show: Option<&str>,
487    episode: Option<&str>,
488    top: usize,
489    min_coverage: f64,
490) -> (Vec<CueMatch>, usize) {
491    let configs = discover_configs(workspace, show, episode);
492    let mut pools: indexmap::IndexMap<String, Vec<PoolAsset>> = indexmap::IndexMap::new();
493    let mut indexes: indexmap::IndexMap<String, indexmap::IndexMap<String, PoolAsset>> =
494        indexmap::IndexMap::new();
495    let mut assets: Option<Vec<PoolAsset>> = None;
496    let mut matches = Vec::new();
497
498    for (slug, tag, path) in &configs {
499        let data: Value = match fs::read_to_string(path)
500            .map_err(|e| e.to_string())
501            .and_then(|t| serde_json::from_str(&t).map_err(|e| e.to_string()))
502        {
503            Ok(v) => v,
504            Err(e) => {
505                log::warning(&format!("  Skipping {} — {e}", path.display()));
506                continue;
507            }
508        };
509        let effects = data
510            .get("effects")
511            .and_then(Value::as_object)
512            .cloned()
513            .unwrap_or_default();
514        for (cue, effect) in &effects {
515            let source = effect
516                .get("source")
517                .filter(|v| super::truthy(v))
518                .map(xil_core::workspace::python_str)
519                .unwrap_or_default();
520            if !source_missing(&source, workspace) {
521                continue;
522            }
523            let all = assets.get_or_insert_with(|| {
524                // Deferred so a workspace where every source resolves never
525                // pays for a full library scan.
526                log::info("  Indexing the SFX library…");
527                let a = load_assets(workspace);
528                log::info(&format!("  Indexed {} asset(s)", a.len()));
529                a
530            });
531            if !pools.contains_key(slug) {
532                pools.insert(slug.clone(), build_pool(all, Some(slug)));
533                indexes.insert(slug.clone(), build_exact_index(all, Some(slug)));
534            }
535            let (tier, candidates) =
536                match_cue(cue, &pools[slug], &indexes[slug], top, min_coverage);
537            matches.push(CueMatch {
538                show: slug.clone(),
539                episode: tag.clone(),
540                cue: cue.clone(),
541                current_source: source,
542                config_path: path.clone(),
543                tier,
544                candidates,
545            });
546        }
547    }
548    (matches, configs.len())
549}
550
551// ── repair ─────────────────────────────────────────────────────────────────
552
553/// `sfx_common.sfx_dir`: the per-show pool when it exists, else flat SFX/.
554fn sfx_dir(slug: &str) -> PathBuf {
555    let root = workspace_root();
556    let per_show = root.join("SFX").join(slug);
557    if per_show.is_dir() {
558        per_show
559    } else {
560        root.join("SFX")
561    }
562}
563
564fn write_config_source(config_path: &Path, cue: &str, source: &str) -> anyhow::Result<()> {
565    let mut data: Value = serde_json::from_str(&fs::read_to_string(config_path)?)?;
566    let obj = data
567        .as_object_mut()
568        .ok_or_else(|| anyhow::anyhow!("config is not an object"))?;
569    let effects = obj
570        .entry("effects")
571        .or_insert_with(|| Value::Object(Map::new()));
572    let entry = effects
573        .as_object_mut()
574        .ok_or_else(|| anyhow::anyhow!("effects is not an object"))?
575        .entry(cue.to_string())
576        .or_insert_with(|| Value::Object(Map::new()));
577    if let Some(e) = entry.as_object_mut() {
578        e.insert("source".into(), source.into());
579    }
580    fs::write(config_path, dumps(&data, Style::INDENT2_UTF8) + "\n")?;
581    Ok(())
582}
583
584/// `shutil.copy2`: contents plus the modification time.
585fn copy2(src: &Path, dst: &Path) -> std::io::Result<()> {
586    fs::copy(src, dst)?;
587    if let Ok(m) = fs::metadata(src).and_then(|m| m.modified()) {
588        let f = fs::File::options().write(true).open(dst)?;
589        f.set_modified(m)?;
590    }
591    Ok(())
592}
593
594fn apply_match(m: &CueMatch, workspace: &Path, dry_run: bool) -> anyhow::Result<()> {
595    let Some(best) = m.best() else {
596        return Ok(());
597    };
598    let dest = shared_sfx_path(&sfx_dir(&m.show), &m.cue, "elevenlabs");
599    let rel_dest = relpath(&dest, workspace).to_string_lossy().into_owned();
600    let src = PathBuf::from(&best.asset.path);
601    let rel_src = relpath(&src, workspace).to_string_lossy().into_owned();
602
603    let action = if abspath(&dest) == abspath(&src) {
604        "already in place".to_string()
605    } else if dest.exists() {
606        "destination exists, reusing".to_string()
607    } else {
608        if !dry_run {
609            if let Some(parent) = dest.parent() {
610                fs::create_dir_all(parent)?;
611            }
612            copy2(&src, &dest)?;
613            xil_audio::tags::retag_title_comment(
614                &dest,
615                &m.cue,
616                &format!("copied from {rel_src} by xil sfx-match"),
617            )?;
618        }
619        format!("copy {rel_src}")
620    };
621
622    log::info(&format!(
623        "    {}{}  →  {}  ({})",
624        if dry_run { "[dry-run] " } else { "" },
625        head(&m.cue, 44),
626        rel_dest,
627        action
628    ));
629
630    if !dry_run {
631        write_config_source(&m.config_path, &m.cue, &rel_dest)?;
632        let mut fields = Map::new();
633        fields.insert("source".into(), rel_dest.clone().into());
634        append_sfx_edit(&m.config_path, &m.cue, &fields)?;
635    }
636    Ok(())
637}
638
639// ── output ─────────────────────────────────────────────────────────────────
640
641fn write_csv<W: Write>(matches: &[CueMatch], out: &mut W) -> std::io::Result<()> {
642    let rows: Vec<Map<String, Value>> = matches.iter().flat_map(CueMatch::rows).collect();
643    pycsv::write_dicts(out, &CSV_COLUMNS, &rows)
644}
645
646fn render_hints(matches: &[CueMatch], workspace: &Path) -> String {
647    let today = chrono::Local::now().format("%Y-%m-%d");
648    let mut out: Vec<String> = vec![
649        "# SFX Pipe-Hint Repairs".into(),
650        format!("# Generated {today} by xil sfx-match"),
651        "# Replace the matching direction line in the script with the [CUE | file] line.".into(),
652        "# Alternates are listed beneath each match — swap in whichever is right.".into(),
653        String::new(),
654    ];
655    for tier in ["EXACT", "STRONG", "REVIEW"] {
656        let rows: Vec<&CueMatch> = matches.iter().filter(|m| m.tier == tier).collect();
657        if rows.is_empty() {
658            continue;
659        }
660        out.push(format!("## {tier} ({})", rows.len()));
661        out.push(String::new());
662        for m in rows {
663            let dest = basename(&shared_sfx_path(&sfx_dir(&m.show), &m.cue, "elevenlabs"));
664            out.push(format!("[{} | {dest}]", m.cue));
665            for c in &m.candidates {
666                let rel = relpath(Path::new(&c.asset.path), workspace)
667                    .to_string_lossy()
668                    .into_owned();
669                out.push(format!(
670                    "#   cov {}  “{}”",
671                    fixed(c.coverage, 2),
672                    c.asset.match_text()
673                ));
674                out.push(format!("#     {rel}"));
675            }
676            out.push(String::new());
677        }
678    }
679    let none_rows: Vec<&CueMatch> = matches.iter().filter(|m| m.tier == "NONE").collect();
680    if !none_rows.is_empty() {
681        out.push(format!(
682            "## NONE — no existing asset, needs generation ({})",
683            none_rows.len()
684        ));
685        out.push(String::new());
686        out.extend(
687            none_rows
688                .iter()
689                .map(|m| format!("# {}/{}  {}", m.show, m.episode, m.cue)),
690        );
691        out.push(String::new());
692    }
693    out.join("\n")
694}
695
696fn log_summary(matches: &[CueMatch], configs_scanned: usize) {
697    let mut by_show: indexmap::IndexMap<&str, indexmap::IndexMap<&str, usize>> =
698        indexmap::IndexMap::new();
699    for m in matches {
700        *by_show
701            .entry(m.show.as_str())
702            .or_insert_with(|| TIERS.iter().map(|t| (*t, 0)).collect())
703            .entry(m.tier)
704            .or_insert(0) += 1;
705    }
706
707    log::info("");
708    log::info(&format!(
709        "  Scanned {configs_scanned} SFX config(s), {} cue(s) with an unresolvable source",
710        matches.len()
711    ));
712    if matches.is_empty() {
713        log::info("");
714        log::info("  Every declared source resolves — nothing to match.");
715        return;
716    }
717
718    log::info("");
719    let row = |name: &str, t: &indexmap::IndexMap<&str, usize>| {
720        format!(
721            "  {} {:>7} {:>7} {:>7} {:>7}",
722            pad_right(name, 24),
723            t["EXACT"],
724            t["STRONG"],
725            t["REVIEW"],
726            t["NONE"]
727        )
728    };
729    let header = format!(
730        "  {} {:>7} {:>7} {:>7} {:>7}",
731        pad_right("show", 24),
732        "EXACT",
733        "STRONG",
734        "REVIEW",
735        "NONE"
736    );
737    let rule = format!("  {}", "-".repeat(header.chars().count() - 2));
738    log::info(&header);
739    log::info(&rule);
740    let mut totals: indexmap::IndexMap<&str, usize> = TIERS.iter().map(|t| (*t, 0)).collect();
741    let mut shows: Vec<_> = by_show.iter().collect();
742    shows.sort_by(|a, b| a.0.cmp(b.0));
743    for (slug, tiers) in shows {
744        log::info(&row(slug, tiers));
745        for t in TIERS {
746            totals[t] += tiers[t];
747        }
748    }
749    log::info(&rule);
750    log::info(&row("TOTAL", &totals));
751
752    let review: Vec<&CueMatch> = matches.iter().filter(|m| m.tier == "REVIEW").collect();
753    if !review.is_empty() {
754        log::info("");
755        log::info(&format!("  {} cue(s) need a human decision:", review.len()));
756        for m in review.iter().take(15) {
757            let Some(best) = m.best() else { continue };
758            log::info(&format!(
759                "    {}  cov {}  “{}”",
760                pad_right(&head(&m.cue, 42), 42),
761                fixed(best.coverage, 2),
762                head(best.asset.match_text(), 52)
763            ));
764        }
765        if review.len() > 15 {
766            log::info(&format!(
767                "    … and {} more — see the CSV",
768                review.len() - 15
769            ));
770        }
771    }
772
773    let nothing = matches.iter().filter(|m| m.tier == "NONE").count();
774    if nothing > 0 {
775        log::info("");
776        log::info(&format!(
777            "  {nothing} cue(s) have no existing asset and need generation."
778        ));
779    }
780}
781
782// ── CLI ────────────────────────────────────────────────────────────────────
783
784fn execute(a: &Args) -> anyhow::Result<i32> {
785    let workspace = workspace_root();
786    let show = match (&a.show, &a.episode) {
787        (None, Some(_)) => Some(resolve_slug(None, "project.json")),
788        (s, _) => s.clone(),
789    };
790    let to_stdout = a.output.as_deref() == Some("-");
791    let (matches, configs_scanned) = analyze(
792        &workspace,
793        show.as_deref(),
794        a.episode.as_deref(),
795        a.top,
796        a.min_coverage,
797    );
798
799    if configs_scanned == 0 {
800        let scope = match &show {
801            Some(s) => format!("show={s}"),
802            None => "workspace".to_string(),
803        };
804        let msg = format!(
805            "No SFX configs found ({scope}) under {}",
806            workspace.join("configs").display()
807        );
808        if to_stdout {
809            eprintln!("{msg}");
810        } else {
811            log::error(&msg);
812        }
813        return Ok(1);
814    }
815
816    let date = chrono::Local::now().format("%Y-%m-%d").to_string();
817    if to_stdout {
818        let mut out = std::io::stdout().lock();
819        write_csv(&matches, &mut out)?;
820        out.flush()?;
821    } else {
822        let out_path = match &a.output {
823            Some(p) => PathBuf::from(p),
824            None => workspace
825                .join("reports")
826                .join(format!("sfx_match_{date}.csv")),
827        };
828        if let Some(parent) = out_path.parent() {
829            fs::create_dir_all(parent)?;
830        }
831        let mut f = fs::File::create(&out_path)?;
832        write_csv(&matches, &mut f)?;
833        log::info(&format!(
834            "  Wrote {} ({} cue(s))",
835            out_path.display(),
836            matches.len()
837        ));
838    }
839
840    if let Some(hints) = &a.emit_hints {
841        let hints_path = if hints.is_empty() {
842            workspace
843                .join("reports")
844                .join(format!("sfx_match_hints_{date}.md"))
845        } else {
846            PathBuf::from(hints)
847        };
848        if let Some(parent) = hints_path.parent() {
849            fs::create_dir_all(parent)?;
850        }
851        fs::write(&hints_path, render_hints(&matches, &workspace))?;
852        let msg = format!("Wrote {}", hints_path.display());
853        if to_stdout {
854            eprintln!("{msg}");
855        } else {
856            log::info(&format!("  {msg}"));
857        }
858    }
859
860    if a.apply {
861        let accepted: &[&str] = if a.accept_review {
862            &["EXACT", "STRONG", "REVIEW"]
863        } else {
864            &["EXACT", "STRONG"]
865        };
866        let todo: Vec<&CueMatch> = matches
867            .iter()
868            .filter(|m| accepted.contains(&m.tier) && m.best().is_some())
869            .collect();
870        log::info("");
871        log::info(&format!(
872            "  {}Applying {} match(es) [{}]:",
873            if a.dry_run { "[dry-run] " } else { "" },
874            todo.len(),
875            accepted.join(", ")
876        ));
877        for m in &todo {
878            apply_match(m, &workspace, a.dry_run)?;
879        }
880        if a.dry_run && !todo.is_empty() {
881            log::info("  Re-run without --dry-run to apply.");
882        }
883    }
884
885    if !a.quiet && !to_stdout {
886        log_summary(&matches, configs_scanned);
887    }
888    Ok(0)
889}
890
891pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
892    log::init("sfx-match");
893    let a: Args = match super::parse_or_exit("xil-sfx-match", args) {
894        Ok(a) => a,
895        Err(code) => return Ok(code),
896    };
897    // `--output -` makes stdout the CSV stream; the banner would corrupt it.
898    if a.output.as_deref() == Some("-") {
899        execute(&a)
900    } else {
901        let _banner = banner::begin(SCRIPT_NAME, &super::argv_line(args));
902        execute(&a)
903    }
904}
905
906/// The clap definition behind `--help`, for man pages.
907pub fn command() -> clap::Command {
908    <Args as clap::CommandFactory>::command()
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914
915    fn asset(filename: &str, scope: &str, title: &str, dur: Option<f64>) -> PoolAsset {
916        let mut rec = Map::new();
917        rec.insert("filename".into(), filename.into());
918        rec.insert("path".into(), format!("/ws/SFX/{scope}/{filename}").into());
919        rec.insert("show".into(), scope.into());
920        rec.insert("title".into(), title.into());
921        rec.insert("prompt".into(), "".into());
922        rec.insert("duration_seconds".into(), dur.map_or(Value::Null, py_float));
923        asset_from_record(&rec)
924    }
925
926    #[test]
927    fn tokens_drop_stopwords_and_fold_plurals() {
928        let t: Vec<String> = tokenize("SFX: CHAIRS — the Glass, KEYS up")
929            .into_iter()
930            .collect();
931        assert_eq!(t, vec!["chair", "glass", "key", "up"]);
932        assert!(tokenize("NEW STEM NEEDED: x.mp3").contains("x"));
933    }
934
935    #[test]
936    fn filename_placement_matches_export_kit_buckets() {
937        assert_eq!(placement_from_filename("AMBGras-x.mp3"), "AMBI(bg)");
938        assert_eq!(placement_from_filename("amb1.mp3"), "SFX(fg)");
939        assert_eq!(placement_from_filename("MUSFolk-theme.mp3"), "MUSIC(bg)");
940        assert_eq!(placement_from_filename("beat_x.mp3"), "BEAT(fg)");
941    }
942
943    #[test]
944    fn pool_dedupes_by_tokens_and_duration_preferring_own_show() {
945        let assets = vec![
946            asset("sfx_door.mp3", "", "SFX: DOOR", Some(1.0)),
947            asset("sfx_door.mp3", "myshow", "SFX: DOOR", Some(1.0)),
948            asset("SFX-_DOORS.mp3", "other", "SFX: DOORS", Some(1.0)),
949            asset("sfx_door.mp3", "", "SFX: DOOR", Some(2.0)),
950        ];
951        let pool = build_pool(&assets, Some("myshow"));
952        assert_eq!(pool.len(), 2);
953        assert_eq!(pool[0].scope, "myshow");
954        assert_eq!(pool[0].also_in, vec!["SFX/", "other"]);
955        let idx = build_exact_index(&assets, Some("myshow"));
956        assert_eq!(idx["sfx_door.mp3"].scope, "myshow");
957    }
958
959    #[test]
960    fn tiers_follow_thresholds_and_margin() {
961        let assets = vec![
962            asset("sfx_phone-buzz.mp3", "", "SFX: PHONE BUZZ", Some(1.0)),
963            asset(
964                "sfx_phone-buzz-twice.mp3",
965                "",
966                "SFX: PHONE BUZZ TWICE",
967                Some(2.0),
968            ),
969            asset("a.mp3", "", "AMBIENCE: HEAVY RAIN ON A WINDOW", Some(2.0)),
970        ];
971        let pool = build_pool(&assets, None);
972        let idx = build_exact_index(&assets, None);
973        assert_eq!(match_cue("SFX: PHONE BUZZ", &pool, &idx, 3, 0.5).0, "EXACT");
974        let (t, c) = match_cue("SFX: PHONE BUZZ LOUD", &pool, &idx, 3, 0.5);
975        assert_eq!(t, "REVIEW");
976        assert_eq!(c[0].asset.filename, "sfx_phone-buzz.mp3");
977        assert_eq!(
978            match_cue("AMBIENCE: RAIN ON WINDOW", &pool, &idx, 3, 0.5).0,
979            "STRONG"
980        );
981        assert_eq!(
982            match_cue("MUSIC: RAIN ON WINDOW", &pool, &idx, 3, 0.5).0,
983            "NONE"
984        );
985    }
986}