1use 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
30const 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
55const NEAR_MISS_CUTOFF: f64 = 0.72;
58
59const 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: Option<PathBuf>,
78 #[arg(long)]
80 json: bool,
81 #[arg(long)]
83 speakers: Option<PathBuf>,
84 #[arg(long, value_name = "NAME")]
86 show: Option<String>,
87 #[arg(long, value_name = "PATH")]
89 sfx: Option<PathBuf>,
90 #[arg(long, value_name = "TAG")]
92 episode: Option<String>,
93 #[arg(long)]
95 harvest_cast: bool,
96 #[arg(long)]
98 backfill_cast: bool,
99 #[arg(long, value_name = "DIR")]
101 scripts_dir: Option<PathBuf>,
102 #[arg(long, short = 'y')]
104 yes: bool,
105 #[arg(long)]
107 dry_run: bool,
108}
109
110fn 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
119fn 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
137fn 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 if is_stage_direction(line) {
158 continue;
159 }
160
161 if let Some(m) = try_match_speaker(line, speakers_tbl) {
162 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 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
203fn scan_span_pairing(lines: &[String], marker: &str, allow_colon: bool) -> Vec<Value> {
205 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
245fn 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
258fn 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(); } 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
288fn 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 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
342fn 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 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
406fn 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 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
485fn 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
494fn 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 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 §ions {
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
731fn 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
745fn 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 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, §ion_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
1018fn 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 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 let section_map = get_section_map("__legacy__");
1069 let mut scan = build_scan(&lines, &speakers_tbl, §ion_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
1112pub 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}