Skip to main content

xil/cmd/
scan.rs

1//! `xil scan` — pre-flight check of a production script. Port of
2//! `XILP000_script_scanner.py`.
3
4use std::ffi::OsString;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::sync::LazyLock;
8
9use clap::Parser;
10use indexmap::IndexMap;
11use regex::Regex;
12use serde_json::{Map, Value};
13use unicode_normalization::UnicodeNormalization;
14use xil_core::fsutil::{basename, glob_recursive};
15use xil_core::pyjson::{dumps, Style};
16use xil_core::script::hints::parse_direction_hint;
17use xil_core::script::parse_script_header;
18use xil_core::script::sections::get_section_map;
19use xil_core::script::speakers::{
20    display_to_key, extract_cast_from_script, load_speakers, try_match_speaker, Speakers,
21};
22use xil_core::script::text::{
23    is_divider, is_scene_header, is_stage_direction, strip_markdown_escapes,
24    strip_markdown_formatting,
25};
26use xil_core::textsim::get_close_matches;
27use xil_core::workspace::{derive_paths, resolve_slug, show_slug, workspace_root};
28use xil_core::{banner, log};
29
30/// Native paralinguistic cues Chatterbox Turbo renders. Anything else is
31/// silently stripped at generation time, which is what makes a near-miss
32/// worth flagging. Mirrors `ALLOWED_TAGS` in `chatterbox_turbo_worker.py`.
33const ALLOWED_TAGS: [&str; 19] = [
34    "advertisement",
35    "angry",
36    "chuckle",
37    "clear throat",
38    "cough",
39    "crying",
40    "dramatic",
41    "fear",
42    "gasp",
43    "groan",
44    "happy",
45    "laugh",
46    "narration",
47    "sarcastic",
48    "shush",
49    "sigh",
50    "sniff",
51    "surprised",
52    "whispering",
53];
54
55/// Similarity floor for suggesting a Turbo cue. Genuine mistakes score
56/// >= 0.73; legitimate ElevenLabs-only tags top out at 0.55.
57const NEAR_MISS_CUTOFF: f64 = 0.72;
58
59/// Variants difflib scores too low because the wording diverges.
60const TAG_ALIASES: [(&str, &str); 5] = [
61    ("throat clearing", "clear throat"),
62    ("throat clear", "clear throat"),
63    ("cries", "crying"),
64    ("cry", "crying"),
65    ("crys", "crying"),
66];
67
68static INLINE_TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\[\]]+)\]").unwrap());
69
70#[derive(Parser)]
71#[command(
72    name = "xil-scan",
73    about = "Pre-flight scanner: check a production script for unknown speakers/sections."
74)]
75struct Args {
76    /// Path to the markdown production script (required unless --harvest-cast or --backfill-cast)
77    path: Option<PathBuf>,
78    /// Output machine-readable JSON instead of the human report
79    #[arg(long)]
80    json: bool,
81    /// Path to speakers.json (default: auto-detect from CWD, then built-in)
82    #[arg(long)]
83    speakers: Option<PathBuf>,
84    /// Show name override — selects configs/{slug}/speakers.json (default: from project.json)
85    #[arg(long, value_name = "NAME")]
86    show: Option<String>,
87    /// Path to sfx_<TAG>.json — enables direction-text audit against existing config
88    #[arg(long, value_name = "PATH")]
89    sfx: Option<PathBuf>,
90    /// Episode tag (e.g. S04E04) — auto-discovers sfx config when --sfx is omitted
91    #[arg(long, value_name = "TAG")]
92    episode: Option<String>,
93    /// Scan all scripts in scripts/ for CAST: blocks and report speakers missing from speakers.json
94    #[arg(long)]
95    harvest_cast: bool,
96    /// Add CAST: blocks to scripts that don't have one, inferring speakers from parsed JSON or body scan
97    #[arg(long)]
98    backfill_cast: bool,
99    /// Scripts directory for --harvest-cast / --backfill-cast (default: scripts/ under workspace root)
100    #[arg(long, value_name = "DIR")]
101    scripts_dir: Option<PathBuf>,
102    /// Apply changes without confirmation (for --harvest-cast and --backfill-cast)
103    #[arg(long, short = 'y')]
104    yes: bool,
105    /// Preview backfill changes without writing files (implied when --yes is absent for --backfill-cast)
106    #[arg(long)]
107    dry_run: bool,
108}
109
110/// Read and apply the parser's two-pass normalization.
111fn load_and_normalize(path: &Path) -> std::io::Result<Vec<String>> {
112    let text = fs::read_to_string(path)?;
113    let text: String = text.nfc().collect();
114    let text = strip_markdown_escapes(&text);
115    let text = strip_markdown_formatting(&text);
116    Ok(text.split('\n').map(str::to_string).collect())
117}
118
119/// A bare ALL-CAPS line worth classifying.
120fn is_all_caps_candidate(line: &str) -> bool {
121    let len = line.chars().count();
122    if !(2..80).contains(&len) {
123        return false;
124    }
125    if line != line.to_uppercase() {
126        return false;
127    }
128    !(is_divider(line) || is_stage_direction(line) || is_scene_header(line) || line.ends_with(':'))
129}
130
131struct Scan {
132    sections: Vec<Value>,
133    speakers: IndexMap<String, (String, usize, Vec<usize>)>,
134    unrecognized: IndexMap<String, Vec<usize>>,
135}
136
137/// Classify every ALL-CAPS candidate into section, speaker or unknown.
138fn scan_script(
139    lines: &[String],
140    speakers_tbl: &Speakers,
141    section_map: &IndexMap<String, String>,
142) -> Scan {
143    let mut sections = Vec::new();
144    let mut speakers: IndexMap<String, (String, usize, Vec<usize>)> = IndexMap::new();
145    let mut unrecognized: IndexMap<String, Vec<usize>> = IndexMap::new();
146
147    for (i, raw) in lines.iter().enumerate() {
148        let line = raw.trim();
149        let lineno = i + 1;
150        if line.is_empty() {
151            continue;
152        }
153        if line.starts_with("END OF") {
154            break;
155        }
156        // Never mine a stage direction for speaker names.
157        if is_stage_direction(line) {
158            continue;
159        }
160
161        if let Some(m) = try_match_speaker(line, speakers_tbl) {
162            // Report the speaker prefix that matched, not the whole line.
163            let display = speakers_tbl
164                .known
165                .iter()
166                .find(|s| line.starts_with(s.as_str()))
167                .cloned()
168                .unwrap_or_else(|| line.split('(').next().unwrap_or(line).trim().to_string());
169            let slot = speakers.entry(m.key).or_insert((display, 0, Vec::new()));
170            slot.1 += 1;
171            slot.2.push(lineno);
172            continue;
173        }
174
175        if !is_all_caps_candidate(line) {
176            continue;
177        }
178
179        // `is_section_header` tests the legacy SECTION_MAP, and the slug is
180        // read from that same table.
181        if let Some(slug) = section_map.get(line) {
182            let mut s = Map::new();
183            s.insert("text".into(), Value::String(line.to_string()));
184            s.insert("slug".into(), Value::String(slug.clone()));
185            s.insert("line".into(), Value::from(lineno));
186            sections.push(Value::Object(s));
187            continue;
188        }
189
190        unrecognized
191            .entry(line.to_string())
192            .or_default()
193            .push(lineno);
194    }
195
196    Scan {
197        sections,
198        speakers,
199        unrecognized,
200    }
201}
202
203/// Every `<marker> ENGAGES` must have a matching `DISENGAGES`.
204fn scan_span_pairing(lines: &[String], marker: &str, allow_colon: bool) -> Vec<Value> {
205    // A trailing token is the span's speaker scope ([PHONE FILTER: ENGAGES
206    // DEZ]); pairing ignores it, but the pattern must accept it or a scoped
207    // ENGAGES reads as no marker and its DISENGAGES looks orphaned.
208    let pattern = Regex::new(&format!(
209        r"^{}{}\s+(ENGAGES|DISENGAGES)(?:[\s:,-]+\S+)?$",
210        regex::escape(marker),
211        if allow_colon { ":?" } else { "" }
212    ))
213    .expect("static pattern");
214
215    let mut stack: Vec<Value> = Vec::new();
216    let mut unpaired: Vec<Value> = Vec::new();
217    for (i, raw) in lines.iter().enumerate() {
218        let line = raw.trim();
219        if !is_stage_direction(line) {
220            continue;
221        }
222        let inner = inner_text(line);
223        let Some(m) = pattern.captures(&inner) else {
224            continue;
225        };
226        let kind = if &m[1] == "ENGAGES" {
227            "ENGAGES"
228        } else {
229            "DISENGAGES"
230        };
231        let mut item = Map::new();
232        item.insert("text".into(), Value::String(inner.clone()));
233        item.insert("line".into(), Value::from(i + 1));
234        item.insert("type".into(), Value::String(kind.to_string()));
235        if kind == "ENGAGES" {
236            stack.push(Value::Object(item));
237        } else if stack.pop().is_none() {
238            unpaired.push(Value::Object(item));
239        }
240    }
241    unpaired.extend(stack);
242    unpaired
243}
244
245/// `line[1:-1].strip()` — the bracket interior, as the scanner slices it.
246fn inner_text(line: &str) -> String {
247    let chars: Vec<char> = line.chars().collect();
248    if chars.len() < 2 {
249        return String::new();
250    }
251    chars[1..chars.len() - 1]
252        .iter()
253        .collect::<String>()
254        .trim()
255        .to_string()
256}
257
258/// Every looping AMBIENCE needs a stop marker.
259fn scan_ambience_coverage(lines: &[String]) -> Vec<Value> {
260    let mut open: Vec<Value> = Vec::new();
261    let mut unclosed: Vec<Value> = Vec::new();
262    for (i, raw) in lines.iter().enumerate() {
263        let line = raw.trim();
264        if !is_stage_direction(line) {
265            continue;
266        }
267        let inner = inner_text(line);
268        let Some(body) = inner.strip_prefix("AMBIENCE:") else {
269            continue;
270        };
271        let body = body.trim();
272        if body == "STOP" || body.to_uppercase().ends_with("FADES OUT") {
273            open.pop(); // a spurious STOP with nothing open is ignored
274        } else {
275            if let Some(prev) = open.pop() {
276                unclosed.push(prev);
277            }
278            let mut item = Map::new();
279            item.insert("text".into(), Value::String(inner.clone()));
280            item.insert("line".into(), Value::from(i + 1));
281            open.push(Value::Object(item));
282        }
283    }
284    unclosed.extend(open);
285    unclosed
286}
287
288/// Inline `[tags]` that look like misspelled Turbo cues.
289fn scan_paralinguistic_tags(lines: &[String]) -> Vec<Value> {
290    let candidates: Vec<String> = {
291        let mut v: Vec<String> = ALLOWED_TAGS.iter().map(|s| s.to_string()).collect();
292        v.sort();
293        v
294    };
295    let mut seen: IndexMap<String, (String, String, Vec<usize>)> = IndexMap::new();
296
297    for (i, raw) in lines.iter().enumerate() {
298        let line = raw.trim();
299        if line.is_empty() {
300            continue;
301        }
302        // Filter per token, not per line: a line may open with a cue and
303        // continue into dialogue ("[sarcastic] Oh, great.").
304        for m in INLINE_TAG.captures_iter(line) {
305            let token = m[1].trim().to_string();
306            let name = token
307                .to_lowercase()
308                .split_whitespace()
309                .collect::<Vec<_>>()
310                .join(" ");
311            if name.is_empty() || name.contains(':') || ALLOWED_TAGS.contains(&name.as_str()) {
312                continue;
313            }
314            let suggestion = match TAG_ALIASES.iter().find(|(k, _)| *k == name) {
315                Some((_, v)) => v.to_string(),
316                None => match get_close_matches(&name, &candidates, 1, NEAR_MISS_CUTOFF).first() {
317                    Some(s) => s.clone(),
318                    None => continue,
319                },
320            };
321            seen.entry(name)
322                .or_insert((token, suggestion, Vec::new()))
323                .2
324                .push(i + 1);
325        }
326    }
327
328    seen.into_values()
329        .map(|(text, suggestion, lines)| {
330            let mut m = Map::new();
331            m.insert("text".into(), Value::String(text));
332            m.insert("suggestion".into(), Value::String(suggestion));
333            m.insert(
334                "lines".into(),
335                Value::Array(lines.into_iter().map(Value::from).collect()),
336            );
337            Value::Object(m)
338        })
339        .collect()
340}
341
342/// Audit direction texts against an existing SFX config.
343fn scan_direction_texts(lines: &[String], sfx_effects: &Map<String, Value>) -> Value {
344    let mut seen: IndexMap<String, (String, Option<String>, Vec<usize>)> = IndexMap::new();
345    for (i, raw) in lines.iter().enumerate() {
346        let line = raw.trim();
347        if !is_stage_direction(line) {
348            continue;
349        }
350        let inner = inner_text(line);
351        if inner.is_empty() {
352            continue;
353        }
354        // Only SFX/MUSIC/AMBIENCE are config-keyed.
355        if !["SFX:", "MUSIC:", "AMBIENCE:"]
356            .iter()
357            .any(|dt| inner.starts_with(dt))
358        {
359            continue;
360        }
361        let h = parse_direction_hint(&inner, "", &mut |_| {});
362        let slot =
363            seen.entry(h.clean.clone())
364                .or_insert((h.clean.clone(), h.source.clone(), Vec::new()));
365        slot.2.push(i + 1);
366        if slot.1.is_none() {
367            if let Some(src) = h.source {
368                slot.1 = Some(src);
369            }
370        }
371    }
372
373    let (mut matched, mut hinted, mut new) = (Vec::new(), Vec::new(), Vec::new());
374    for (text, hint, line_nos) in seen.into_values() {
375        let mut info = Map::new();
376        info.insert("text".into(), Value::String(text.clone()));
377        info.insert(
378            "hint".into(),
379            hint.clone().map(Value::String).unwrap_or(Value::Null),
380        );
381        info.insert(
382            "lines".into(),
383            Value::Array(line_nos.into_iter().map(Value::from).collect()),
384        );
385        let info = Value::Object(info);
386
387        let has_source = sfx_effects
388            .get(&text)
389            .and_then(|e| e.get("source"))
390            .is_some_and(|s| !s.is_null() && s.as_str() != Some(""));
391        if has_source {
392            matched.push(info);
393        } else if hint.is_some() {
394            hinted.push(info);
395        } else {
396            new.push(info);
397        }
398    }
399    let mut m = Map::new();
400    m.insert("matched".into(), Value::Array(matched));
401    m.insert("hinted".into(), Value::Array(hinted));
402    m.insert("new".into(), Value::Array(new));
403    Value::Object(m)
404}
405
406/// The full scan result, in the key order the JSON output uses.
407fn build_scan(
408    lines: &[String],
409    speakers_tbl: &Speakers,
410    section_map: &IndexMap<String, String>,
411) -> Map<String, Value> {
412    let scan = scan_script(lines, speakers_tbl, section_map);
413
414    let mut speakers_obj = Map::new();
415    for (key, (display, count, line_nos)) in &scan.speakers {
416        let mut info = Map::new();
417        info.insert("display".into(), Value::String(display.clone()));
418        info.insert("count".into(), Value::from(*count));
419        info.insert(
420            "lines".into(),
421            Value::Array(line_nos.iter().map(|n| Value::from(*n)).collect()),
422        );
423        speakers_obj.insert(key.clone(), Value::Object(info));
424    }
425    let unrecognized: Vec<Value> = scan
426        .unrecognized
427        .iter()
428        .map(|(text, line_nos)| {
429            let mut u = Map::new();
430            u.insert("text".into(), Value::String(text.clone()));
431            u.insert(
432                "lines".into(),
433                Value::Array(line_nos.iter().map(|n| Value::from(*n)).collect()),
434            );
435            Value::Object(u)
436        })
437        .collect();
438
439    let slugs: Vec<&str> = scan
440        .sections
441        .iter()
442        .filter_map(|s| s["slug"].as_str())
443        .collect();
444    let mut pp = Map::new();
445    pp.insert("preamble".into(), Value::Bool(slugs.contains(&"preamble")));
446    pp.insert(
447        "postamble".into(),
448        Value::Bool(slugs.contains(&"postamble")),
449    );
450
451    let mut out = Map::new();
452    out.insert("sections".into(), Value::Array(scan.sections));
453    out.insert("speakers".into(), Value::Object(speakers_obj));
454    out.insert("unrecognized".into(), Value::Array(unrecognized));
455    out.insert("preamble_postamble".into(), Value::Object(pp));
456    // VINTAGE FILTER deliberately does not accept the colon form: widening
457    // it would newly fail scripts that pass today.
458    out.insert(
459        "vintage_filter_unpaired".into(),
460        Value::Array(scan_span_pairing(lines, "VINTAGE FILTER", false)),
461    );
462    out.insert(
463        "film_audio_unpaired".into(),
464        Value::Array(scan_span_pairing(lines, "FILM AUDIO", true)),
465    );
466    out.insert(
467        "speakerphone_unpaired".into(),
468        Value::Array(scan_span_pairing(lines, "SPEAKERPHONE", true)),
469    );
470    out.insert(
471        "phone_filter_unpaired".into(),
472        Value::Array(scan_span_pairing(lines, "PHONE FILTER", true)),
473    );
474    out.insert(
475        "ambience_unclosed".into(),
476        Value::Array(scan_ambience_coverage(lines)),
477    );
478    out.insert(
479        "paralinguistic_near_misses".into(),
480        Value::Array(scan_paralinguistic_tags(lines)),
481    );
482    out
483}
484
485// ── report ──────────────────────────────────────────────────────────────
486
487fn arr(scan: &Map<String, Value>, key: &str) -> Vec<Value> {
488    scan.get(key)
489        .and_then(Value::as_array)
490        .cloned()
491        .unwrap_or_default()
492}
493
494/// Pad to `width` by character count, as Python's `:<N` does.
495fn pad(s: &str, width: usize) -> String {
496    let n = s.chars().count();
497    if n >= width {
498        s.to_string()
499    } else {
500        format!("{s}{}", " ".repeat(width - n))
501    }
502}
503
504fn line_list(line_nos: &[Value]) -> String {
505    let shown: Vec<String> = line_nos.iter().take(5).map(|v| v.to_string()).collect();
506    let mut s = shown.join(", ");
507    if line_nos.len() > 5 {
508        s.push_str(&format!(" (+{} more)", line_nos.len() - 5));
509    }
510    s
511}
512
513fn format_report(scan: &Map<String, Value>, header: &Map<String, Value>) -> String {
514    let mut out: Vec<String> = Vec::new();
515
516    let show = header.get("show").and_then(Value::as_str).unwrap_or("");
517    let title = header.get("title").and_then(Value::as_str).unwrap_or("");
518    let season = header.get("season").and_then(Value::as_i64);
519    let episode = header.get("episode").and_then(Value::as_i64);
520    // `if season and episode` — zero is falsy in Python too.
521    let ep_tag = match (season, episode) {
522        (Some(s), Some(e)) if s != 0 && e != 0 => format!("S{s:02}E{e:02}"),
523        _ => String::new(),
524    };
525    let headline = [show, ep_tag.as_str(), title]
526        .iter()
527        .filter(|p| !p.is_empty())
528        .copied()
529        .collect::<Vec<_>>()
530        .join(" — ");
531    if !headline.is_empty() {
532        out.push(format!("=== {headline} ==="));
533    }
534    out.push(String::new());
535
536    let sections = arr(scan, "sections");
537    out.push(format!("SECTIONS ({} found)", sections.len()));
538    if sections.is_empty() {
539        out.push("  (none)".into());
540    } else {
541        for s in &sections {
542            out.push(format!(
543                "  ✓  {} → {}",
544                pad(s["text"].as_str().unwrap_or(""), 30),
545                s["slug"].as_str().unwrap_or("")
546            ));
547        }
548    }
549    out.push(String::new());
550
551    let speakers = scan
552        .get("speakers")
553        .and_then(Value::as_object)
554        .cloned()
555        .unwrap_or_default();
556    out.push(format!("SPEAKERS ({} found)", speakers.len()));
557    if speakers.is_empty() {
558        out.push("  (none)".into());
559    } else {
560        let mut keys: Vec<&String> = speakers.keys().collect();
561        keys.sort();
562        for key in keys {
563            let info = &speakers[key];
564            out.push(format!(
565                "  ✓  {} → {} ({} lines)",
566                pad(info["display"].as_str().unwrap_or(""), 18),
567                pad(key, 18),
568                info["count"]
569            ));
570        }
571    }
572    out.push(String::new());
573
574    let unknown = arr(scan, "unrecognized");
575    if !unknown.is_empty() {
576        out.push(format!(
577            "UNRECOGNIZED CANDIDATES ({} — action needed before XILP001)",
578            unknown.len()
579        ));
580        for u in &unknown {
581            let nos = u["lines"].as_array().cloned().unwrap_or_default();
582            out.push(format!(
583                "  ⚠  {}  lines: {}",
584                pad(u["text"].as_str().unwrap_or(""), 30),
585                line_list(&nos)
586            ));
587        }
588        out.push(String::new());
589        out.push(format!(
590            "⚠️  {} unrecognized candidate(s). Add to speakers.json or SECTION_MAP before parsing.",
591            unknown.len()
592        ));
593    } else {
594        out.push("UNRECOGNIZED CANDIDATES".into());
595        out.push("  (none)".into());
596    }
597
598    if let Some(pp) = scan.get("preamble_postamble").and_then(Value::as_object) {
599        out.push(String::new());
600        out.push("PREAMBLE / POSTAMBLE".into());
601        for key in ["preamble", "postamble"] {
602            let present = pp.get(key).and_then(Value::as_bool).unwrap_or(false);
603            let mark = if present { "✓" } else { "⚠" };
604            let status = if present {
605                "present"
606            } else {
607                "MISSING — add before broadcast production"
608            };
609            out.push(format!("  {mark}  {} {status}", pad(key, 12)));
610        }
611    }
612
613    for (title, key) in [
614        ("VINTAGE FILTER PAIRING", "vintage_filter_unpaired"),
615        ("FILM AUDIO PAIRING", "film_audio_unpaired"),
616        ("SPEAKERPHONE PAIRING", "speakerphone_unpaired"),
617        ("PHONE FILTER PAIRING", "phone_filter_unpaired"),
618    ] {
619        let items = arr(scan, key);
620        out.push(String::new());
621        out.push(title.into());
622        if items.is_empty() {
623            out.push("  ✓  all markers paired (or none present)".into());
624        } else {
625            for item in &items {
626                out.push(format!(
627                    "  ⚠  {} unpaired  line {}",
628                    pad(item["type"].as_str().unwrap_or(""), 12),
629                    item["line"]
630                ));
631            }
632        }
633    }
634
635    let amb = arr(scan, "ambience_unclosed");
636    out.push(String::new());
637    out.push("AMBIENCE LOOP COVERAGE".into());
638    if amb.is_empty() {
639        out.push("  ✓  all ambience loops have stop markers (or none present)".into());
640    } else {
641        for item in &amb {
642            out.push(format!(
643                "  ⚠  no stop marker for [{}]  line {}",
644                item["text"].as_str().unwrap_or(""),
645                item["line"]
646            ));
647        }
648    }
649
650    let near = arr(scan, "paralinguistic_near_misses");
651    if !near.is_empty() {
652        out.push(String::new());
653        out.push(format!("PARALINGUISTIC TAG NEAR-MISSES ({})", near.len()));
654        for item in &near {
655            let nos = item["lines"].as_array().cloned().unwrap_or_default();
656            out.push(format!(
657                "  ⚠  [{}] → did you mean [{}]?  lines: {}",
658                item["text"].as_str().unwrap_or(""),
659                item["suggestion"].as_str().unwrap_or(""),
660                line_list(&nos)
661            ));
662        }
663        out.push(
664            "\n  ℹ  Chatterbox Turbo renders only its exact cue tokens and silently strips the rest; \
665             other backends are unaffected."
666                .into(),
667        );
668    }
669
670    if let Some(dt) = scan.get("direction_texts").and_then(Value::as_object) {
671        let get = |k: &str| {
672            dt.get(k)
673                .and_then(Value::as_array)
674                .cloned()
675                .unwrap_or_default()
676        };
677        let (matched, hinted, new) = (get("matched"), get("hinted"), get("new"));
678        out.push(String::new());
679        out.push(format!(
680            "DIRECTION TEXT AUDIT  ({} matched / {} hinted / {} new)",
681            matched.len(),
682            hinted.len(),
683            new.len()
684        ));
685        for i in &matched {
686            out.push(format!(
687                "  ✓  [reuse]  {}",
688                i["text"].as_str().unwrap_or("")
689            ));
690        }
691        for i in &hinted {
692            let fname = i["hint"]
693                .as_str()
694                .map(|h| basename(Path::new(h)))
695                .unwrap_or_else(|| "?".into());
696            out.push(format!(
697                "  +  [hydrate] {}  → {fname}",
698                i["text"].as_str().unwrap_or("")
699            ));
700        }
701        for i in &new {
702            out.push(format!(
703                "  ·  [new]    {}",
704                i["text"].as_str().unwrap_or("")
705            ));
706        }
707        if !hinted.is_empty() {
708            out.push(format!(
709                "\n  ℹ  {} hinted — run `xil sfx-hydrate` to write source fields",
710                hinted.len()
711            ));
712        }
713        if !new.is_empty() {
714            out.push(format!(
715                "  ℹ  {} new — add prompts to sfx config before producing",
716                new.len()
717            ));
718        }
719    }
720
721    out.push(String::new());
722    let fatal = !unknown.is_empty() || !arr(scan, "vintage_filter_unpaired").is_empty();
723    out.push(if fatal {
724        "❌  Errors found — resolve before running XILP001.".into()
725    } else {
726        "✅  All sections and speakers recognized — safe to run XILP001.".to_string()
727    });
728    out.join("\n")
729}
730
731// ── migration modes ─────────────────────────────────────────────────────
732
733/// `--speakers`, else `configs/{slug}/speakers.json` when `--show` names one.
734fn resolve_speakers_path(speakers: Option<&Path>, show: Option<&str>) -> Option<PathBuf> {
735    if let Some(p) = speakers {
736        return Some(p.to_path_buf());
737    }
738    let show = show?;
739    let candidate = PathBuf::from("configs")
740        .join(show_slug(show))
741        .join("speakers.json");
742    candidate.exists().then_some(candidate)
743}
744
745/// `glob(dir/**/*.md, recursive=True) or glob(dir/*.md)`.
746fn all_scripts(scripts_dir: &Path) -> Vec<PathBuf> {
747    glob_recursive(scripts_dir, "", ".md")
748}
749
750fn harvest_cast(
751    scripts_dir: &Path,
752    speakers_path: Option<&Path>,
753    apply: bool,
754) -> anyhow::Result<()> {
755    let scripts = all_scripts(scripts_dir);
756    if scripts.is_empty() {
757        log::info(&format!(
758            "No .md scripts found in {}",
759            scripts_dir.display()
760        ));
761        return Ok(());
762    }
763
764    let mut all_cast: IndexMap<String, (String, Vec<String>)> = IndexMap::new();
765    for path in &scripts {
766        let Ok(lines) = load_and_normalize(path) else {
767            continue;
768        };
769        for entry in extract_cast_from_script(&lines) {
770            all_cast
771                .entry(entry.key.clone())
772                .or_insert((entry.display.clone(), Vec::new()))
773                .1
774                .push(basename(path));
775        }
776    }
777
778    if all_cast.is_empty() {
779        log::info(&format!(
780            "No CAST: blocks found in {} script(s).  Run --backfill-cast to add them.",
781            scripts.len()
782        ));
783        return Ok(());
784    }
785
786    let mut existing: Vec<Value> = Vec::new();
787    let mut existing_keys: Vec<String> = Vec::new();
788    if let Some(p) = speakers_path.filter(|p| p.exists()) {
789        if let Ok(v) = serde_json::from_str::<Value>(&fs::read_to_string(p)?) {
790            existing = v.as_array().cloned().unwrap_or_default();
791            existing_keys = existing
792                .iter()
793                .filter_map(|e| e.get("key")?.as_str().map(str::to_string))
794                .collect();
795        }
796    }
797
798    let mut sorted_keys: Vec<&String> = all_cast.keys().collect();
799    sorted_keys.sort();
800    let new_entries: Vec<(String, String, Vec<String>)> = sorted_keys
801        .into_iter()
802        .filter(|k| !existing_keys.contains(k))
803        .map(|k| {
804            let (display, scripts) = &all_cast[k];
805            (k.clone(), display.clone(), scripts.clone())
806        })
807        .collect();
808
809    log::info(&format!(
810        "CAST harvest — {} script(s) scanned, {} unique speaker(s) found, {} new",
811        scripts.len(),
812        all_cast.len(),
813        new_entries.len()
814    ));
815    for (key, display, in_scripts) in &new_entries {
816        log::info(&format!(
817            "  +  {}  key: {}  in: {}",
818            pad(display, 30),
819            pad(key, 22),
820            in_scripts.join(", ")
821        ));
822    }
823    if new_entries.is_empty() {
824        log::info("✅  All CAST-declared speakers already in speakers.json.");
825        return Ok(());
826    }
827    if !apply {
828        log::info("(dry-run) Re-run with --yes to add these to speakers.json.");
829        return Ok(());
830    }
831
832    let write_path = match speakers_path {
833        Some(p) => p.to_path_buf(),
834        None => {
835            let show = xil_core::workspace::read_project("project.json")
836                .get("show")
837                .and_then(Value::as_str)
838                .unwrap_or("Sample Show")
839                .to_string();
840            workspace_root()
841                .join("configs")
842                .join(show_slug(&show))
843                .join("speakers.json")
844        }
845    };
846    if let Some(d) = write_path.parent() {
847        fs::create_dir_all(d)?;
848    }
849    for (key, display, _) in &new_entries {
850        let mut e = Map::new();
851        e.insert("display".into(), Value::String(display.clone()));
852        e.insert("key".into(), Value::String(key.clone()));
853        existing.push(Value::Object(e));
854    }
855    fs::write(&write_path, dumps(&Value::Array(existing), Style::INDENT2))?;
856    log::info(&format!(
857        "✅  Added {} new speaker(s) to {}",
858        new_entries.len(),
859        write_path.display()
860    ));
861    Ok(())
862}
863
864fn backfill_cast(
865    scripts_dir: &Path,
866    speakers_path: Option<&Path>,
867    dry_run: bool,
868) -> anyhow::Result<()> {
869    let root = workspace_root();
870    let scripts = all_scripts(scripts_dir);
871    if scripts.is_empty() {
872        log::info(&format!(
873            "No .md scripts found in {}",
874            scripts_dir.display()
875        ));
876        return Ok(());
877    }
878
879    let mut registry: IndexMap<String, Map<String, Value>> = IndexMap::new();
880    if let Some(p) = speakers_path.filter(|p| p.exists()) {
881        if let Ok(Value::Array(items)) = serde_json::from_str::<Value>(&fs::read_to_string(p)?) {
882            for e in items {
883                if let (Some(o), Some(k)) = (e.as_object(), e.get("key").and_then(Value::as_str)) {
884                    registry.insert(k.to_string(), o.clone());
885                }
886            }
887        }
888    }
889    let speakers_tbl = load_speakers(speakers_path, &[]);
890    let section_map = get_section_map(&xil_core::workspace::resolve_project_type("project.json"));
891    let parsed_dir = root.join("parsed");
892
893    let mut modified = 0usize;
894    for script_path in &scripts {
895        let Ok(lines) = load_and_normalize(script_path) else {
896            continue;
897        };
898        let name = basename(script_path);
899        if lines.iter().any(|l| l.trim() == "CAST:") {
900            log::info(&format!("  SKIP  {name}  (CAST: block already present)"));
901            continue;
902        }
903
904        // Prefer the parsed JSON whose source_file names this script.
905        let mut cast_display: Vec<String> = Vec::new();
906        if parsed_dir.is_dir() {
907            for parsed_path in glob_recursive(&parsed_dir, "parsed_", ".json") {
908                let Ok(parsed) = fs::read_to_string(&parsed_path)
909                    .map_err(|e| e.to_string())
910                    .and_then(|t| serde_json::from_str::<Value>(&t).map_err(|e| e.to_string()))
911                else {
912                    continue;
913                };
914                let source_file = parsed
915                    .get("source_file")
916                    .and_then(Value::as_str)
917                    .unwrap_or("");
918                if !source_file.contains(&name) {
919                    continue;
920                }
921                let mut seen: Vec<String> = Vec::new();
922                for entry in parsed
923                    .get("entries")
924                    .and_then(Value::as_array)
925                    .cloned()
926                    .unwrap_or_default()
927                {
928                    if entry.get("type").and_then(Value::as_str) != Some("dialogue") {
929                        continue;
930                    }
931                    let k = entry.get("speaker").and_then(Value::as_str).unwrap_or("");
932                    if !k.is_empty() && !seen.iter().any(|s| s == k) {
933                        seen.push(k.to_string());
934                    }
935                }
936                for k in seen {
937                    cast_display.push(
938                        match registry
939                            .get(&k)
940                            .and_then(|r| r.get("display"))
941                            .and_then(Value::as_str)
942                        {
943                            Some(d) => d.to_string(),
944                            None => k.replace('_', " ").to_uppercase(),
945                        },
946                    );
947                }
948                break;
949            }
950        }
951
952        if cast_display.is_empty() {
953            let body = scan_script(&lines, &speakers_tbl, &section_map);
954            cast_display = body.speakers.values().map(|(d, _, _)| d.clone()).collect();
955        }
956        if cast_display.is_empty() {
957            log::info(&format!("  SKIP  {name}  (no speakers found)"));
958            continue;
959        }
960
961        let mut cast_block: Vec<String> = vec!["CAST:".into()];
962        for display in &cast_display {
963            let role = registry
964                .get(&display_to_key(display))
965                .and_then(|r| r.get("role"))
966                .and_then(Value::as_str)
967                .unwrap_or("");
968            let mut entry_line = format!("* {display}");
969            if !role.is_empty() && role != "TBD" {
970                entry_line.push_str(&format!(" — {role}"));
971            }
972            cast_block.push(entry_line);
973        }
974        cast_block.push(String::new());
975
976        if dry_run {
977            log::info(&format!("  ADD   {name}:"));
978            for bl in &cast_block {
979                log::info(&format!("        {bl}"));
980            }
981            continue;
982        }
983
984        let original = fs::read_to_string(script_path)?;
985        let original_lines: Vec<&str> = original.split_inclusive('\n').collect();
986        let Some(insert_at) = original_lines.iter().position(|l| {
987            let t = l.trim();
988            t == "===" || t == "---"
989        }) else {
990            log::warning(&format!("  WARN  {name}  (no === divider found, skipping)"));
991            continue;
992        };
993        let mut new_text = String::new();
994        new_text.push_str(&original_lines[..insert_at].concat());
995        for bl in &cast_block {
996            new_text.push_str(bl);
997            new_text.push('\n');
998        }
999        new_text.push_str(&original_lines[insert_at..].concat());
1000        fs::write(script_path, new_text)?;
1001        log::info(&format!(
1002            "  WROTE {name}  ({} speaker(s))",
1003            cast_display.len()
1004        ));
1005        modified += 1;
1006    }
1007
1008    if dry_run {
1009        log::info("(dry-run) Re-run without --dry-run to write changes.");
1010    } else {
1011        log::info(&format!(
1012            "✅  Backfill complete — {modified} script(s) updated."
1013        ));
1014    }
1015    Ok(())
1016}
1017
1018// ── entry point ─────────────────────────────────────────────────────────
1019
1020fn execute(a: &Args) -> anyhow::Result<i32> {
1021    let speakers_path = resolve_speakers_path(a.speakers.as_deref(), a.show.as_deref());
1022    let scripts_dir = a
1023        .scripts_dir
1024        .clone()
1025        .unwrap_or_else(|| workspace_root().join("scripts"));
1026
1027    if a.harvest_cast {
1028        harvest_cast(&scripts_dir, speakers_path.as_deref(), a.yes)?;
1029        return Ok(0);
1030    }
1031    if a.backfill_cast {
1032        backfill_cast(&scripts_dir, speakers_path.as_deref(), !a.yes)?;
1033        return Ok(0);
1034    }
1035
1036    let Some(path) = &a.path else {
1037        log::error("path argument required (or use --harvest-cast / --backfill-cast)");
1038        return Ok(1);
1039    };
1040    if !path.exists() {
1041        log::error(&format!("File not found: {}", path.display()));
1042        return Ok(1);
1043    }
1044
1045    let lines = load_and_normalize(path)?;
1046    let cast_entries = extract_cast_from_script(&lines);
1047    let speakers_tbl = load_speakers(speakers_path.as_deref(), &cast_entries);
1048
1049    // The header is read from the first non-blank of the opening ten lines.
1050    let mut header = Map::new();
1051    for line in lines.iter().take(10) {
1052        if line.trim().is_empty() {
1053            continue;
1054        }
1055        if let Some(h) = parse_script_header(line) {
1056            header.insert("show".into(), Value::String(h.show));
1057            header.insert(
1058                "season".into(),
1059                h.season.map(Value::from).unwrap_or(Value::Null),
1060            );
1061            header.insert("episode".into(), Value::from(h.episode));
1062            header.insert("title".into(), Value::String(h.title));
1063        }
1064        break;
1065    }
1066
1067    // The scanner reads slugs from the legacy SECTION_MAP, not a per-type map.
1068    let section_map = get_section_map("__legacy__");
1069    let mut scan = build_scan(&lines, &speakers_tbl, &section_map);
1070
1071    let sfx_path = a.sfx.clone().or_else(|| {
1072        let tag = a.episode.as_ref()?;
1073        let slug = resolve_slug(a.show.as_deref(), "project.json");
1074        derive_paths(&slug, tag).get("sfx").cloned()
1075    });
1076    if let Some(p) = sfx_path.filter(|p| p.exists()) {
1077        let data: Value = serde_json::from_str(&fs::read_to_string(&p)?)?;
1078        let effects = data
1079            .get("effects")
1080            .and_then(Value::as_object)
1081            .cloned()
1082            .unwrap_or_default();
1083        scan.insert(
1084            "direction_texts".into(),
1085            scan_direction_texts(&lines, &effects),
1086        );
1087    }
1088
1089    if a.json {
1090        println!("{}", dumps(&Value::Object(scan.clone()), Style::INDENT2));
1091    } else {
1092        log::info(&format_report(&scan, &header));
1093    }
1094
1095    let fatal = !arr(&scan, "unrecognized").is_empty()
1096        || !arr(&scan, "vintage_filter_unpaired").is_empty()
1097        || !arr(&scan, "film_audio_unpaired").is_empty()
1098        || !arr(&scan, "speakerphone_unpaired").is_empty();
1099    Ok(if fatal { 1 } else { 0 })
1100}
1101
1102pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
1103    log::init("scan");
1104    let _banner = banner::begin(super::prog(), &super::argv_line(args));
1105    let a: Args = match super::parse_or_exit("xil-scan", args) {
1106        Ok(a) => a,
1107        Err(code) => return Ok(code),
1108    };
1109    execute(&a)
1110}
1111
1112/// The clap definition behind `--help`, for man pages.
1113pub fn command() -> clap::Command {
1114    <Args as clap::CommandFactory>::command()
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use super::*;
1120
1121    fn lines(s: &str) -> Vec<String> {
1122        s.split('\n').map(str::to_string).collect()
1123    }
1124
1125    #[test]
1126    fn all_caps_candidates_exclude_structure() {
1127        assert!(is_all_caps_candidate("ADAM"));
1128        assert!(!is_all_caps_candidate("A"), "too short");
1129        assert!(!is_all_caps_candidate(&"X".repeat(80)), "too long");
1130        assert!(!is_all_caps_candidate("Adam"), "not all caps");
1131        assert!(!is_all_caps_candidate("CAST:"), "metadata label");
1132        assert!(!is_all_caps_candidate("==="));
1133        assert!(!is_all_caps_candidate("[SFX: X]"));
1134        assert!(!is_all_caps_candidate("SCENE 1: ROOM"));
1135    }
1136
1137    #[test]
1138    fn span_pairing_reports_both_orphan_kinds() {
1139        let l = lines("[VINTAGE FILTER ENGAGES]\n[VINTAGE FILTER DISENGAGES]\n[VINTAGE FILTER DISENGAGES]\n[VINTAGE FILTER ENGAGES]");
1140        let un = scan_span_pairing(&l, "VINTAGE FILTER", false);
1141        assert_eq!(un.len(), 2);
1142        assert_eq!(un[0]["type"], "DISENGAGES");
1143        assert_eq!(un[0]["line"], 3);
1144        assert_eq!(un[1]["type"], "ENGAGES");
1145        assert_eq!(un[1]["line"], 4);
1146    }
1147
1148    #[test]
1149    fn colon_form_only_matches_where_allowed() {
1150        let l = lines("[VINTAGE FILTER: ENGAGES]");
1151        assert!(
1152            scan_span_pairing(&l, "VINTAGE FILTER", false).is_empty(),
1153            "colon form is not matched"
1154        );
1155        let l = lines("[PHONE FILTER: ENGAGES DEZ]");
1156        let un = scan_span_pairing(&l, "PHONE FILTER", true);
1157        assert_eq!(un.len(), 1, "a scoped ENGAGES still counts as open");
1158        assert_eq!(un[0]["type"], "ENGAGES");
1159    }
1160
1161    #[test]
1162    fn ambience_needs_a_stop_marker() {
1163        let l = lines("[AMBIENCE: RAIN]\n[AMBIENCE: STOP]\n[AMBIENCE: WIND]\n[AMBIENCE: TRAFFIC]");
1164        let un = scan_ambience_coverage(&l);
1165        assert_eq!(
1166            un.len(),
1167            2,
1168            "WIND is replaced without a stop, TRAFFIC is left open"
1169        );
1170        assert_eq!(un[0]["text"], "AMBIENCE: WIND");
1171        assert_eq!(un[1]["text"], "AMBIENCE: TRAFFIC");
1172
1173        let fades = lines("[AMBIENCE: RAIN]\n[AMBIENCE: RAIN FADES OUT]");
1174        assert!(scan_ambience_coverage(&fades).is_empty());
1175    }
1176
1177    #[test]
1178    fn near_misses_suggest_the_real_cue() {
1179        let l = lines("ADAM [laughs] Hello.\nMAYA [clears throat] Yes.\nDEZ [exhausted] No.\n[SFX: DOOR]\nAVA [laughs] Again.");
1180        let near = scan_paralinguistic_tags(&l);
1181        assert_eq!(
1182            near.len(),
1183            2,
1184            "[exhausted] is a valid ElevenLabs tag, not a near-miss"
1185        );
1186        assert_eq!(near[0]["text"], "laughs");
1187        assert_eq!(near[0]["suggestion"], "laugh");
1188        assert_eq!(near[0]["lines"], serde_json::json!([1, 5]));
1189        assert_eq!(near[1]["suggestion"], "clear throat");
1190    }
1191
1192    #[test]
1193    fn tag_aliases_cover_what_difflib_misses() {
1194        let near = scan_paralinguistic_tags(&lines("[throat clearing] x\n[cries] y"));
1195        assert_eq!(near[0]["suggestion"], "clear throat");
1196        assert_eq!(near[1]["suggestion"], "crying");
1197    }
1198
1199    #[test]
1200    fn direction_audit_sorts_into_three_buckets() {
1201        let l = lines(
1202            "[SFX: HAS SOURCE]\n[SFX: HINTED | a.mp3]\n[SFX: BRAND NEW]\n[BEAT]\n[MUSIC: NO KEY]",
1203        );
1204        let mut effects = Map::new();
1205        effects.insert(
1206            "SFX: HAS SOURCE".into(),
1207            serde_json::json!({"source": "SFX/x.mp3"}),
1208        );
1209        effects.insert("MUSIC: NO KEY".into(), serde_json::json!({"prompt": "p"}));
1210        let dt = scan_direction_texts(&l, &effects);
1211        assert_eq!(dt["matched"].as_array().unwrap().len(), 1);
1212        assert_eq!(dt["hinted"].as_array().unwrap().len(), 1);
1213        assert_eq!(
1214            dt["new"].as_array().unwrap().len(),
1215            2,
1216            "BEAT is not config-keyed and is skipped"
1217        );
1218        assert_eq!(dt["hinted"][0]["hint"], "SFX/a.mp3");
1219    }
1220
1221    #[test]
1222    fn inner_text_slices_like_python() {
1223        assert_eq!(inner_text("[SFX: X]"), "SFX: X");
1224        assert_eq!(inner_text("[]"), "");
1225        assert_eq!(inner_text("["), "");
1226        assert_eq!(
1227            inner_text("[CAFÉ]"),
1228            "CAFÉ",
1229            "sliced by character, not byte"
1230        );
1231    }
1232
1233    #[test]
1234    fn pad_counts_characters() {
1235        assert_eq!(pad("CAFÉ", 6), "CAFÉ  ");
1236        assert_eq!(pad("toolong", 3), "toolong");
1237    }
1238}