Skip to main content

xil/cmd/
parse.rs

1//! `xil parse` — production script markdown → structured JSON, plus the
2//! cast and SFX config skeletons. Port of `XILP001_script_parser.py`'s CLI.
3
4use std::ffi::OsString;
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::sync::LazyLock;
9
10use clap::Parser;
11use indexmap::IndexMap;
12use regex::Regex;
13use serde_json::{Map, Value};
14use xil_core::fsutil::basename;
15use xil_core::journal::{append_sfx_edit, replay_sfx_edits, sfx_edits_path};
16use xil_core::pycsv;
17use xil_core::pyjson::{dumps, Style};
18use xil_core::script::hints::filter_sfx_overrides;
19use xil_core::script::speakers::{key_to_display, load_speakers, load_speakers_registry};
20use xil_core::script::{parse_script, Entry, ParseOpts, Parsed};
21use xil_core::workspace::{
22    derive_paths, episode_tag, resolve_project_type, resolve_season, resolve_season_title,
23    resolve_slug, show_slug, workspace_root,
24};
25use xil_core::{banner, log};
26
27static BEAT_SECONDS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\d+)\s+SECOND").unwrap());
28
29const DEBUG_TRUNCATE: usize = 200;
30
31#[derive(Parser)]
32#[command(
33    name = "xil-parse",
34    about = "Parse production script markdown into structured JSON"
35)]
36struct Args {
37    /// Path to the production script markdown file
38    script: PathBuf,
39    /// Episode tag (e.g. S01E01) — validates header and auto-generates absent cast/sfx configs
40    #[arg(long, conflicts_with = "tag")]
41    episode: Option<String>,
42    /// Raw tag for non-episodic content (e.g. V01C03, D01, CH003) — skips season/episode header validation
43    #[arg(long)]
44    tag: Option<String>,
45    /// Show name override (default: from project.json)
46    #[arg(long)]
47    show: Option<String>,
48    /// Output JSON path (default: parsed/parsed_<slug>_<TAG>.json)
49    #[arg(long, short = 'o')]
50    output: Option<PathBuf>,
51    /// Show first N dialogue lines (default: show all)
52    #[arg(long)]
53    preview: Option<usize>,
54    /// Only output JSON, skip summary/preview
55    #[arg(long)]
56    quiet: bool,
57    /// Write diagnostic CSV alongside JSON output
58    #[arg(long)]
59    debug: bool,
60    /// Print per-speaker dialogue distribution (lines, words, chars, %)
61    #[arg(long)]
62    stats: bool,
63    /// Path to speakers.json (default: auto-detect from CWD, then built-in)
64    #[arg(long)]
65    speakers: Option<PathBuf>,
66}
67
68/// Parse the file at `path` with the project fallbacks applied.
69fn run_parse(path: &Path, speakers: Option<&Path>) -> anyhow::Result<Parsed> {
70    let raw = fs::read_to_string(path)?;
71    let opts = ParseOpts {
72        project_type: &resolve_project_type("project.json"),
73        speakers_path: speakers,
74        season_fallback: resolve_season(None, "project.json"),
75        season_title_fallback: resolve_season_title(None, "project.json"),
76    };
77    Ok(parse_script(&raw, &basename(path), &opts, &mut |m| {
78        log::warning(&m)
79    }))
80}
81
82// ── config skeletons ────────────────────────────────────────────────────
83
84/// Write the cast skeleton: one entry per speaker found in the script.
85fn generate_cast_config(
86    parsed: &Value,
87    cast_path: &Path,
88    tag_override: Option<&str>,
89    registry: &IndexMap<String, Map<String, Value>>,
90    key_display: &IndexMap<String, String>,
91) -> anyhow::Result<()> {
92    let speakers: Vec<String> = parsed["stats"]["speakers"]
93        .as_array()
94        .cloned()
95        .unwrap_or_default()
96        .iter()
97        .filter_map(|v| v.as_str().map(str::to_string))
98        .collect();
99
100    let mut cast = Map::new();
101    for key in &speakers {
102        let empty = Map::new();
103        let reg = registry.get(key).unwrap_or(&empty);
104        let display = key_display.get(key).cloned().unwrap_or_else(|| key.clone());
105        let default_name = title_case(&display.replace('_', " "));
106
107        let mut member = Map::new();
108        member.insert(
109            "full_name".into(),
110            or_default(reg.get("full_name"), Value::String(default_name)),
111        );
112        member.insert(
113            "voice_id".into(),
114            or_default(reg.get("voice_id"), Value::String("TBD".into())),
115        );
116        member.insert(
117            "pan".into(),
118            reg.get("pan").cloned().unwrap_or(Value::from(0.0)),
119        );
120        member.insert(
121            "filter".into(),
122            reg.get("filter").cloned().unwrap_or(Value::Bool(false)),
123        );
124        member.insert(
125            "role".into(),
126            or_default(reg.get("role"), Value::String("TBD".into())),
127        );
128        for field in [
129            "stability",
130            "similarity_boost",
131            "style",
132            "use_speaker_boost",
133            "language_code",
134        ] {
135            if let Some(v) = reg.get(field).filter(|v| !v.is_null()) {
136                member.insert(field.into(), v.clone());
137            }
138        }
139        cast.insert(key.clone(), Value::Object(member));
140    }
141
142    let mut config = Map::new();
143    config.insert(
144        "show".into(),
145        parsed
146            .get("show")
147            .cloned()
148            .unwrap_or(Value::String("Unknown Show".into())),
149    );
150    config.insert(
151        "season".into(),
152        if tag_override.is_some() {
153            Value::Null
154        } else {
155            parsed["season"].clone()
156        },
157    );
158    config.insert(
159        "episode".into(),
160        if tag_override.is_some() {
161            Value::Null
162        } else {
163            parsed["episode"].clone()
164        },
165    );
166    config.insert(
167        "title".into(),
168        parsed
169            .get("title")
170            .cloned()
171            .unwrap_or(Value::String(String::new())),
172    );
173    config.insert(
174        "season_title".into(),
175        parsed.get("season_title").cloned().unwrap_or(Value::Null),
176    );
177    config.insert("cast".into(), Value::Object(cast));
178    if let Some(t) = tag_override {
179        config.insert("tag_override".into(), Value::String(t.to_string()));
180    }
181
182    write_json_no_newline(cast_path, &Value::Object(config))?;
183    log::info(&format!(
184        "Created {} with {} speakers (voice_id=TBD — run XILU001 to assign)",
185        cast_path.display(),
186        speakers.len()
187    ));
188    Ok(())
189}
190
191/// `x or default` — a null, empty string or absent value takes the default.
192fn or_default(v: Option<&Value>, default: Value) -> Value {
193    match v {
194        Some(Value::Null) | None => default,
195        Some(Value::String(s)) if s.is_empty() => default,
196        Some(Value::Bool(false)) => default,
197        Some(other) => other.clone(),
198    }
199}
200
201/// Python `str.title()`: first letter of each run of letters uppercased.
202pub(crate) fn title_case(s: &str) -> String {
203    let mut out = String::with_capacity(s.len());
204    let mut prev_alpha = false;
205    for c in s.chars() {
206        if c.is_alphabetic() {
207            if prev_alpha {
208                out.extend(c.to_lowercase());
209            } else {
210                out.extend(c.to_uppercase());
211            }
212            prev_alpha = true;
213        } else {
214            out.push(c);
215            prev_alpha = false;
216        }
217    }
218    out
219}
220
221/// Apply attribute hints to one effect entry, in place. A `play_duration`
222/// hint clears `duration_seconds` on source-backed cues — the two are
223/// mutually exclusive and the skeleton default would contradict the hint.
224fn apply_sfx_overrides(
225    key: &str,
226    entry: &mut Map<String, Value>,
227    overrides: &IndexMap<String, f64>,
228) -> usize {
229    let is_silence = entry.get("type").and_then(Value::as_str) == Some("silence");
230    let applied = filter_sfx_overrides(key, is_silence, overrides, Some(&mut |m| log::warning(&m)));
231    if applied.is_empty() {
232        return 0;
233    }
234    for (k, v) in &applied {
235        entry.insert(k.clone(), json_f64(*v));
236    }
237    if applied.contains_key("play_duration") && entry.get("source").is_some_and(|s| !s.is_null()) {
238        entry.shift_remove("duration_seconds");
239    }
240    applied.len()
241}
242
243fn json_f64(v: f64) -> Value {
244    serde_json::Number::from_f64(v)
245        .map(Value::Number)
246        .unwrap_or(Value::Null)
247}
248
249/// Overrides carried on a parsed direction entry, as the JSON holds them.
250fn entry_overrides(e: &Value) -> IndexMap<String, f64> {
251    e.get("sfx_overrides")
252        .and_then(Value::as_object)
253        .map(|m| {
254            m.iter()
255                .filter_map(|(k, v)| v.as_f64().map(|f| (k.clone(), f)))
256                .collect()
257        })
258        .unwrap_or_default()
259}
260
261/// Default generation length by cue category.
262fn default_duration(text: &str) -> f64 {
263    if text.starts_with("AMBIENCE:") {
264        30.0
265    } else if text.starts_with("MUSIC:") {
266        15.0
267    } else {
268        5.0
269    }
270}
271
272/// Write the SFX skeleton: one entry per unique direction.
273fn generate_sfx_config(
274    parsed: &Value,
275    sfx_path: &Path,
276    tag_override: Option<&str>,
277) -> anyhow::Result<()> {
278    let mut effects: Map<String, Value> = Map::new();
279    let (mut silence_count, mut sfx_count) = (0usize, 0usize);
280
281    for e in parsed["entries"].as_array().cloned().unwrap_or_default() {
282        if e.get("type").and_then(Value::as_str) != Some("direction") {
283            continue;
284        }
285        let text = e
286            .get("text")
287            .and_then(Value::as_str)
288            .unwrap_or("")
289            .to_string();
290        if effects.contains_key(&text) {
291            continue;
292        }
293        let source = e
294            .get("sfx_source")
295            .and_then(Value::as_str)
296            .map(str::to_string);
297        let overrides = entry_overrides(&e);
298
299        let mut entry = Map::new();
300        if text == "BEAT" {
301            entry.insert("type".into(), Value::String("silence".into()));
302            entry.insert("duration_seconds".into(), json_f64(1.0));
303            silence_count += 1;
304        } else if text == "LONG BEAT" {
305            entry.insert("type".into(), Value::String("silence".into()));
306            entry.insert("duration_seconds".into(), json_f64(2.0));
307            silence_count += 1;
308        } else if text.starts_with("BEAT") {
309            // "BEAT — 3 SECONDS", "BEAT — LONG, 5 SECONDS", …
310            let dur = BEAT_SECONDS
311                .captures(&text)
312                .and_then(|c| c[1].parse().ok())
313                .unwrap_or(1.0);
314            entry.insert("type".into(), Value::String("silence".into()));
315            entry.insert("duration_seconds".into(), json_f64(dur));
316            silence_count += 1;
317        } else if text == "AMBIENCE: STOP" || text.ends_with("FADES OUT") {
318            entry.insert("type".into(), Value::String("silence".into()));
319            entry.insert("duration_seconds".into(), json_f64(0.0));
320            silence_count += 1;
321        } else if text.starts_with("FILM AUDIO")
322            || text.starts_with("SPEAKERPHONE")
323            || text.starts_with("PHONE FILTER")
324        {
325            // A span marker: the treatment lands on dialogue stems in the
326            // mixer, so the cue has no audio of its own. Zero duration keeps
327            // it out of the generation set. Must stay ahead of the source
328            // branch so a stray pipe hint cannot create one.
329            entry.insert("type".into(), Value::String("silence".into()));
330            entry.insert("duration_seconds".into(), json_f64(0.0));
331            silence_count += 1;
332        } else if let Some(src) = &source {
333            entry.insert("source".into(), Value::String(src.clone()));
334            entry.insert("duration_seconds".into(), json_f64(default_duration(&text)));
335            if text.starts_with("AMBIENCE:") {
336                entry.insert("loop".into(), Value::Bool(true));
337            }
338            sfx_count += 1;
339        } else if text.starts_with("AMBIENCE:") {
340            entry.insert("prompt".into(), Value::String(text.clone()));
341            entry.insert("duration_seconds".into(), json_f64(30.0));
342            entry.insert("loop".into(), Value::Bool(true));
343            sfx_count += 1;
344        } else if text.starts_with("MUSIC:") {
345            entry.insert("prompt".into(), Value::String(text.clone()));
346            entry.insert("duration_seconds".into(), json_f64(15.0));
347            sfx_count += 1;
348        } else {
349            entry.insert("prompt".into(), Value::String(text.clone()));
350            entry.insert("duration_seconds".into(), json_f64(5.0));
351            sfx_count += 1;
352        }
353
354        apply_sfx_overrides(&text, &mut entry, &overrides);
355        effects.insert(text, Value::Object(entry));
356    }
357
358    let mut config = Map::new();
359    config.insert("_docs".into(), docs_block());
360    config.insert(
361        "show".into(),
362        parsed
363            .get("show")
364            .cloned()
365            .unwrap_or(Value::String("Unknown Show".into())),
366    );
367    config.insert(
368        "season".into(),
369        if tag_override.is_some() {
370            Value::Null
371        } else {
372            parsed["season"].clone()
373        },
374    );
375    config.insert(
376        "episode".into(),
377        if tag_override.is_some() {
378            Value::Null
379        } else {
380            parsed["episode"].clone()
381        },
382    );
383    config.insert("defaults".into(), defaults_block());
384    config.insert("effects".into(), Value::Object(effects));
385    if let Some(t) = tag_override {
386        config.insert("tag_override".into(), Value::String(t.to_string()));
387    }
388
389    write_json_no_newline(sfx_path, &Value::Object(config))?;
390    let total = silence_count + sfx_count;
391    log::info(&format!(
392        "Created {} with {total} effects ({silence_count} silence, {sfx_count} sfx — review prompts before generation)",
393        sfx_path.display()
394    ));
395
396    // A fresh skeleton wipes hand-tuned overrides — replay the journal.
397    let replay = replay_sfx_edits(sfx_path, false, &mut |m| log::warning(&m))?;
398    if replay.applied > 0 {
399        let note = if replay.orphans.is_empty() {
400            String::new()
401        } else {
402            format!(" ({} orphaned key(s))", replay.orphans.len())
403        };
404        log::info(&format!(
405            "Reapplied {} timeline sound edit(s) from {}{note}",
406            replay.applied,
407            sfx_edits_path(sfx_path).display()
408        ));
409    }
410    Ok(())
411}
412
413fn docs_block() -> Value {
414    let mut d = Map::new();
415    d.insert(
416        "defaults".into(),
417        Value::String(
418            "Episode-wide fallbacks. Category prefixes: ambience_, music_, sfx_, vintage_filter_. \
419             E.g. 'ambience_volume_percentage': 30. Global fallbacks: volume_percentage, ramp_in_seconds, \
420             ramp_out_seconds."
421                .into(),
422        ),
423    );
424    d.insert(
425        "per_effect_override".into(),
426        Value::String(
427            "Use plain 'volume_percentage' (no prefix) inside an effect entry to override the category default \
428             for that one cue only."
429                .into(),
430        ),
431    );
432    d.insert(
433        "source".into(),
434        Value::String(
435            "Relative path from workspace root, e.g. 'SFX/filename.mp3'. Explicit source always wins over the \
436             shared pool cache."
437                .into(),
438        ),
439    );
440    d.insert(
441        "play_duration".into(),
442        Value::String(
443            "Percentage of clip to play (0–100, e.g. 50 = half). Not applicable to AMBIENCE."
444                .into(),
445        ),
446    );
447    d.insert(
448        "prompt".into(),
449        Value::String(
450            "ElevenLabs SFX API natural-language description for generated effects.".into(),
451        ),
452    );
453    d.insert(
454        "type".into(),
455        Value::String("'sfx' for API-generated audio, 'silence' for local silence padding.".into()),
456    );
457    Value::Object(d)
458}
459
460fn defaults_block() -> Value {
461    let mut d = Map::new();
462    d.insert("prompt_influence".into(), json_f64(0.3));
463    d.insert("volume_percentage".into(), Value::from(20));
464    d.insert("ramp_in_seconds".into(), json_f64(1.0));
465    d.insert("ramp_out_seconds".into(), json_f64(1.0));
466    d.insert("ambience_volume_percentage".into(), Value::from(30));
467    d.insert("ambience_ramp_in_seconds".into(), json_f64(1.0));
468    d.insert("ambience_ramp_out_seconds".into(), json_f64(1.0));
469    Value::Object(d)
470}
471
472/// Both skeleton writers use `json.dump` with no trailing newline.
473fn write_json_no_newline(path: &Path, v: &Value) -> anyhow::Result<()> {
474    if let Some(d) = path.parent() {
475        if !d.as_os_str().is_empty() {
476            fs::create_dir_all(d)?;
477        }
478    }
479    fs::write(path, dumps(v, Style::INDENT2_UTF8))?;
480    Ok(())
481}
482
483/// Does a hint's `SFX/...` path resolve on disk? Guards forced replacement:
484/// plenty of working sources are bare `SFX/<file>` with no slug-form twin.
485pub(crate) fn hint_target_exists(source: &str) -> bool {
486    let p = Path::new(source);
487    if p.is_absolute() {
488        p.exists()
489    } else {
490        workspace_root().join(source).exists()
491    }
492}
493
494/// Fill missing `source` fields on an existing SFX config from parsed hints.
495///
496/// A hint never replaces an existing source unless `force`, and then only
497/// when the hinted file resolves. Attribute hints go the other way: the
498/// script is authoritative and overwrites whatever the config holds.
499pub(crate) fn backfill_sfx_sources(
500    parsed: &Value,
501    sfx_path: &Path,
502    force: bool,
503) -> anyhow::Result<()> {
504    let text = fs::read_to_string(sfx_path)?;
505    let mut data: Value = serde_json::from_str(&text)?;
506    let obj = data
507        .as_object_mut()
508        .ok_or_else(|| anyhow::anyhow!("sfx config is not an object"))?;
509    obj.entry("effects")
510        .or_insert_with(|| Value::Object(Map::new()));
511
512    // Piped keys left by a pre-fix parse, mapped to their clean form.
513    let mut stale_key_map: Vec<(String, String)> = Vec::new();
514    for existing in obj["effects"]
515        .as_object()
516        .cloned()
517        .unwrap_or_default()
518        .keys()
519    {
520        let h = xil_core::script::hints::parse_direction_hint(existing, "", &mut |_| {});
521        if h.source.is_some() && &h.clean != existing {
522            stale_key_map.push((existing.clone(), h.clean));
523        }
524    }
525
526    let mut updated = 0usize;
527    let mut journaled: Vec<(String, String)> = Vec::new();
528    let mut seen_clean: Vec<String> = Vec::new();
529
530    for e in parsed["entries"].as_array().cloned().unwrap_or_default() {
531        if e.get("type").and_then(Value::as_str) != Some("direction") {
532            continue;
533        }
534        let source = e
535            .get("sfx_source")
536            .and_then(Value::as_str)
537            .map(str::to_string);
538        let overrides = entry_overrides(&e);
539        if source.is_none() && overrides.is_empty() {
540            continue;
541        }
542        let text = e
543            .get("text")
544            .and_then(Value::as_str)
545            .unwrap_or("")
546            .to_string();
547        if seen_clean.contains(&text) {
548            continue;
549        }
550        seen_clean.push(text.clone());
551
552        let effects = obj["effects"].as_object_mut().expect("effects");
553        if effects.contains_key(&text) {
554            let current = effects[&text]
555                .get("source")
556                .and_then(Value::as_str)
557                .map(str::to_string);
558            let entry = effects[&text].as_object_mut().expect("effect object");
559            if let (Some(src), None) = (&source, &current) {
560                entry.insert("source".into(), Value::String(src.clone()));
561                if entry.get("prompt").and_then(Value::as_str) == Some(text.as_str()) {
562                    entry.shift_remove("prompt");
563                }
564                journaled.push((text.clone(), src.clone()));
565                updated += 1;
566            } else if force && source.is_some() && current.as_deref() != source.as_deref() {
567                let src = source.clone().expect("checked");
568                if hint_target_exists(&src) {
569                    entry.insert("source".into(), Value::String(src.clone()));
570                    entry.shift_remove("prompt");
571                    journaled.push((text.clone(), src));
572                    updated += 1;
573                } else {
574                    log::warning(&format!(
575                        "  Keeping '{text}' source {} — hint target {src} not found on disk",
576                        current.as_deref().unwrap_or("None")
577                    ));
578                }
579            }
580        } else if let Some(stale) = stale_key_map
581            .iter()
582            .find(|(_, clean)| *clean == text)
583            .map(|(k, _)| k.clone())
584        {
585            // A stale piped key: rename it and attach the source.
586            if let Some(mut old) = effects.shift_remove(&stale) {
587                if let (Some(src), Some(o)) = (&source, old.as_object_mut()) {
588                    o.insert("source".into(), Value::String(src.clone()));
589                    o.shift_remove("prompt");
590                    journaled.push((text.clone(), src.clone()));
591                }
592                effects.insert(text.clone(), old);
593                updated += 1;
594            }
595        } else {
596            // Absent entirely: create it.
597            let mut new_entry = Map::new();
598            new_entry.insert("duration_seconds".into(), json_f64(default_duration(&text)));
599            match &source {
600                Some(src) => new_entry.insert("source".into(), Value::String(src.clone())),
601                // A volume-only hint on an unknown cue still needs a prompt.
602                None => new_entry.insert("prompt".into(), Value::String(text.clone())),
603            };
604            if text.starts_with("AMBIENCE:") {
605                new_entry.insert("loop".into(), Value::Bool(true));
606            }
607            effects.insert(text.clone(), Value::Object(new_entry));
608            if let Some(src) = &source {
609                journaled.push((text.clone(), src.clone()));
610            }
611            updated += 1;
612        }
613
614        // The script wins for attribute hints.
615        let effects = obj["effects"].as_object_mut().expect("effects");
616        let entry = effects[&text].as_object_mut().expect("effect object");
617        let changed: IndexMap<String, f64> = overrides
618            .into_iter()
619            .filter(|(k, v)| entry.get(k).and_then(Value::as_f64) != Some(*v))
620            .collect();
621        if apply_sfx_overrides(&text, entry, &changed) > 0 {
622            updated += 1;
623        }
624    }
625
626    if updated > 0 {
627        write_json_no_newline(sfx_path, &data)?;
628        // Journal after the config write: a record that survives a failed
629        // write would reinstate a source the config never had.
630        for (key, src) in journaled {
631            let mut fields = Map::new();
632            fields.insert("source".into(), Value::String(src));
633            append_sfx_edit(sfx_path, &key, &fields)?;
634        }
635        log::info(&format!(
636            "Backfilled {updated} script hint(s) in {}",
637            sfx_path.display()
638        ));
639    }
640    Ok(())
641}
642
643// ── reporting ───────────────────────────────────────────────────────────
644
645fn write_debug_csv(path: &Path, parsed: &Parsed) -> anyhow::Result<()> {
646    let mut rows = Vec::new();
647    for (line_num, raw_line, idx) in &parsed.debug_line_map {
648        let e: &Entry = &parsed.entries[*idx];
649        let mut row = Map::new();
650        row.insert("md_line_num".into(), Value::from(*line_num));
651        row.insert("md_raw".into(), Value::String(truncate(raw_line)));
652        row.insert("seq".into(), Value::from(e.seq));
653        row.insert("type".into(), Value::from(e.kind));
654        row.insert(
655            "section".into(),
656            Value::String(e.section.clone().unwrap_or_default()),
657        );
658        row.insert(
659            "scene".into(),
660            Value::String(e.scene.clone().unwrap_or_default()),
661        );
662        row.insert(
663            "speaker".into(),
664            Value::String(e.speaker.clone().unwrap_or_default()),
665        );
666        row.insert(
667            "direction".into(),
668            Value::String(e.direction.clone().unwrap_or_default()),
669        );
670        row.insert(
671            "direction_type".into(),
672            Value::String(e.direction_type.unwrap_or("").to_string()),
673        );
674        row.insert("text".into(), Value::String(truncate(&e.text)));
675        rows.push(row);
676    }
677    let cols = [
678        "md_line_num",
679        "md_raw",
680        "seq",
681        "type",
682        "section",
683        "scene",
684        "speaker",
685        "direction",
686        "direction_type",
687        "text",
688    ];
689    let mut f = fs::File::create(path)?;
690    pycsv::write_dicts(&mut f, &cols, &rows)?;
691    Ok(())
692}
693
694fn truncate(s: &str) -> String {
695    s.chars().take(DEBUG_TRUNCATE).collect()
696}
697
698/// `f"{n:,}"` — thousands separators.
699fn commas(n: usize) -> String {
700    let s = n.to_string();
701    let mut out = String::new();
702    for (i, c) in s.chars().enumerate() {
703        if i > 0 && (s.len() - i) % 3 == 0 {
704            out.push(',');
705        }
706        out.push(c);
707    }
708    out
709}
710
711fn print_speaker_stats(parsed: &Parsed) {
712    let rows = xil_core::script::compute_speaker_stats(&parsed.entries);
713    if rows.is_empty() {
714        log::info("  No dialogue entries found.");
715        return;
716    }
717    let (tl, tw, tc): (usize, usize, usize) = rows.iter().fold((0, 0, 0), |a, r| {
718        (a.0 + r.lines, a.1 + r.words, a.2 + r.chars)
719    });
720
721    log::info(&format!(
722        "\n{:<15} {:>6} {:>6} {:>7} {:>6} {:>8} {:>6}",
723        "Speaker", "Lines", "%", "Words", "%", "Chars", "%"
724    ));
725    let d = |n: usize| "-".repeat(n);
726    log::info(&format!(
727        "{} {} {} {} {} {} {}",
728        d(15),
729        d(6),
730        d(6),
731        d(7),
732        d(6),
733        d(8),
734        d(6)
735    ));
736    for r in &rows {
737        log::info(&format!(
738            "{:<15} {:>6} {:>5.1}% {:>7} {:>5.1}% {:>8} {:>5.1}%",
739            r.speaker,
740            r.lines,
741            r.pct_lines,
742            commas(r.words),
743            r.pct_words,
744            commas(r.chars),
745            r.pct_chars
746        ));
747    }
748    log::info(&format!(
749        "{} {} {} {} {} {} {}",
750        d(15),
751        d(6),
752        d(6),
753        d(7),
754        d(6),
755        d(8),
756        d(6)
757    ));
758    log::info(&format!(
759        "{:<15} {:>6}        {:>7}        {:>8}",
760        "TOTAL",
761        tl,
762        commas(tw),
763        commas(tc)
764    ));
765    log::info("");
766}
767
768fn print_summary(parsed: &Parsed, json: &Value) {
769    let stats = &json["stats"];
770    let tag = episode_tag(parsed.season, parsed.episode);
771    let bar = "=".repeat(60);
772    log::info(&format!("\n{bar}"));
773    log::info(&format!("PARSED: {} {tag} — {}", parsed.show, parsed.title));
774    log::info(&format!("Source: {}", parsed.source_file));
775    log::info(&bar);
776    log::info(&format!("  Total entries:      {}", stats["total_entries"]));
777    log::info(&format!(
778        "  Dialogue lines:     {}",
779        stats["dialogue_lines"]
780    ));
781    log::info(&format!(
782        "  Stage directions:   {}",
783        stats["direction_lines"]
784    ));
785    log::info(&format!(
786        "  TTS characters:     {}",
787        commas(stats["characters_for_tts"].as_u64().unwrap_or(0) as usize)
788    ));
789    log::info(&format!(
790        "  Speakers:           {}",
791        join_strs(&stats["speakers"])
792    ));
793    log::info(&format!(
794        "  Sections:           {}",
795        join_strs(&stats["sections"])
796    ));
797    log::info(&bar);
798    print_speaker_stats(parsed);
799}
800
801fn join_strs(v: &Value) -> String {
802    v.as_array()
803        .map(|a| {
804            a.iter()
805                .filter_map(Value::as_str)
806                .collect::<Vec<_>>()
807                .join(", ")
808        })
809        .unwrap_or_default()
810}
811
812fn print_dialogue_preview(parsed: &Parsed, limit: Option<usize>) {
813    let all: Vec<&Entry> = parsed
814        .entries
815        .iter()
816        .filter(|e| e.kind == "dialogue")
817        .collect();
818    let shown = match limit.filter(|l| *l > 0) {
819        Some(l) => &all[..l.min(all.len())],
820        None => &all[..],
821    };
822    log::info(&format!(
823        "\n--- Dialogue Preview ({} lines) ---\n",
824        shown.len()
825    ));
826    for e in shown {
827        let scene_label = e
828            .scene
829            .clone()
830            .or_else(|| e.section.clone())
831            .unwrap_or_else(|| "?".to_string());
832        let direction_label = e
833            .direction
834            .as_ref()
835            .map(|d| format!(" ({d})"))
836            .unwrap_or_default();
837        let text_preview = if e.text.chars().count() > 80 {
838            format!("{}...", e.text.chars().take(80).collect::<String>())
839        } else {
840            e.text.clone()
841        };
842        log::info(&format!(
843            "  {:03} | {scene_label:<16} | {:<14}{direction_label}",
844            e.seq,
845            e.speaker.clone().unwrap_or_default()
846        ));
847        log::info(&format!("       {text_preview}"));
848        log::info("");
849    }
850}
851
852// ── entry point ─────────────────────────────────────────────────────────
853
854fn execute(a: &Args) -> anyhow::Result<i32> {
855    let mut parsed = run_parse(&a.script, a.speakers.as_deref())?;
856
857    let tag = match &a.tag {
858        Some(t) => t.clone(),
859        None => {
860            let derived = episode_tag(parsed.season, parsed.episode);
861            if let Some(ep) = &a.episode {
862                if ep != &derived {
863                    log::error(&format!(
864                        "Script header indicates {derived} but --episode {ep} was specified"
865                    ));
866                    return Ok(1);
867                }
868            }
869            derived
870        }
871    };
872
873    if let Some(s) = &a.show {
874        parsed.show = s.clone();
875    }
876    let slug = {
877        let from_header = show_slug(&parsed.show);
878        if from_header.is_empty() {
879            resolve_slug(a.show.as_deref(), "project.json")
880        } else {
881            from_header
882        }
883    };
884    let paths = derive_paths(&slug, &tag);
885    let output = a.output.clone().unwrap_or_else(|| paths["parsed"].clone());
886    if let Some(d) = output.parent() {
887        if !d.as_os_str().is_empty() {
888            fs::create_dir_all(d)?;
889        }
890    }
891
892    if a.debug {
893        // Python re-parses with the debug path set; the CSV name comes from
894        // the resolved output path.
895        let csv_path = with_extension_csv(&output);
896        parsed = run_parse(&a.script, a.speakers.as_deref())?;
897        if let Some(s) = &a.show {
898            parsed.show = s.clone();
899        }
900        write_debug_csv(&csv_path, &parsed)?;
901    }
902
903    let json = parsed.to_json();
904    // Skip the write when nothing changed, so a re-parse of an unmodified
905    // script does not bump the mtime and falsely stale the later stages.
906    let new_content = dumps(&json, Style::INDENT2_UTF8);
907    let existing = fs::read_to_string(&output).ok();
908    if existing.as_deref() == Some(new_content.as_str()) {
909        log::info("Parsed output unchanged — skipping write (mtime preserved)");
910    } else {
911        let mut f = fs::File::create(&output)?;
912        f.write_all(new_content.as_bytes())?;
913    }
914
915    if !a.quiet {
916        print_summary(&parsed, &json);
917        print_dialogue_preview(&parsed, a.preview);
918        log::info(&format!("JSON written to: {}", output.display()));
919        if a.debug {
920            log::info(&format!(
921                "Debug CSV written to: {}",
922                with_extension_csv(&output).display()
923            ));
924        }
925    }
926    if a.stats && a.quiet {
927        print_speaker_stats(&parsed);
928    }
929
930    let trigger = a.tag.as_ref().or(a.episode.as_ref());
931    if trigger.is_none() {
932        log::warning(&format!(
933            "No --episode tag given — cast and SFX skeleton configs were NOT created. \
934             Re-run with --episode {tag} to generate them."
935        ));
936        return Ok(0);
937    }
938
939    let cast_path = &paths["cast"];
940    let sfx_path = &paths["sfx"];
941    if !cast_path.exists() {
942        let registry = load_speakers_registry(a.speakers.as_deref());
943        // generate_cast_config reads the module-level speaker table, which
944        // main() has loaded without any CAST-block seeding.
945        let module_speakers = load_speakers(a.speakers.as_deref(), &[]);
946        generate_cast_config(
947            &json,
948            cast_path,
949            a.tag.as_deref(),
950            &registry,
951            &key_to_display(&module_speakers),
952        )?;
953    }
954    if !sfx_path.exists() {
955        generate_sfx_config(&json, sfx_path, a.tag.as_deref())?;
956    } else {
957        backfill_sfx_sources(&json, sfx_path, false)?;
958    }
959    Ok(0)
960}
961
962/// `os.path.splitext(p)[0] + ".csv"`.
963fn with_extension_csv(p: &Path) -> PathBuf {
964    let s = p.to_string_lossy();
965    let stem = match (s.rfind('.'), s.rfind('/')) {
966        (Some(dot), Some(slash)) if dot > slash + 1 => &s[..dot],
967        (Some(dot), None) if dot > 0 => &s[..dot],
968        _ => &s[..],
969    };
970    PathBuf::from(format!("{stem}.csv"))
971}
972
973pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
974    log::init("parse");
975    let _banner = banner::begin(super::prog(), &super::argv_line(args));
976    let a: Args = match super::parse_or_exit("xil-parse", args) {
977        Ok(a) => a,
978        Err(code) => return Ok(code),
979    };
980    execute(&a)
981}
982
983/// The clap definition behind `--help`, for man pages.
984pub fn command() -> clap::Command {
985    <Args as clap::CommandFactory>::command()
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991
992    #[test]
993    fn title_case_matches_python() {
994        assert_eq!(title_case("mr patterson"), "Mr Patterson");
995        assert_eq!(title_case("NORA WALSH"), "Nora Walsh");
996        assert_eq!(title_case("t-bone"), "T-Bone");
997        assert_eq!(title_case("o'brien"), "O'Brien");
998    }
999
1000    #[test]
1001    fn thousands_separators() {
1002        assert_eq!(commas(0), "0");
1003        assert_eq!(commas(999), "999");
1004        assert_eq!(commas(1000), "1,000");
1005        assert_eq!(commas(1234567), "1,234,567");
1006    }
1007
1008    #[test]
1009    fn csv_path_swaps_the_extension() {
1010        assert_eq!(
1011            with_extension_csv(Path::new("parsed/s/parsed_S01E01.json")),
1012            Path::new("parsed/s/parsed_S01E01.csv")
1013        );
1014        assert_eq!(
1015            with_extension_csv(Path::new("noext")),
1016            Path::new("noext.csv")
1017        );
1018    }
1019
1020    #[test]
1021    fn or_default_treats_falsy_as_absent() {
1022        assert_eq!(or_default(None, Value::from("d")), Value::from("d"));
1023        assert_eq!(
1024            or_default(Some(&Value::Null), Value::from("d")),
1025            Value::from("d")
1026        );
1027        assert_eq!(
1028            or_default(Some(&Value::from("")), Value::from("d")),
1029            Value::from("d")
1030        );
1031        assert_eq!(
1032            or_default(Some(&Value::from("x")), Value::from("d")),
1033            Value::from("x")
1034        );
1035    }
1036
1037    #[test]
1038    fn beat_durations_come_from_the_text() {
1039        assert_eq!(
1040            BEAT_SECONDS.captures("BEAT — 3 SECONDS").unwrap()[1]
1041                .parse::<f64>()
1042                .unwrap(),
1043            3.0
1044        );
1045        assert_eq!(
1046            BEAT_SECONDS.captures("BEAT — LONG, 5 SECONDS").unwrap()[1]
1047                .parse::<f64>()
1048                .unwrap(),
1049            5.0
1050        );
1051        assert!(BEAT_SECONDS.captures("BEAT — LONG").is_none());
1052    }
1053
1054    #[test]
1055    fn play_duration_hint_clears_duration_on_source_cues_only() {
1056        let mut o = IndexMap::new();
1057        o.insert("play_duration".to_string(), 35.0);
1058
1059        let mut sourced = Map::new();
1060        sourced.insert("source".into(), Value::from("SFX/a.mp3"));
1061        sourced.insert("duration_seconds".into(), json_f64(5.0));
1062        apply_sfx_overrides("MUSIC: X", &mut sourced, &o);
1063        assert!(!sourced.contains_key("duration_seconds"));
1064        assert_eq!(sourced["play_duration"], 35.0);
1065
1066        let mut generated = Map::new();
1067        generated.insert("prompt".into(), Value::from("MUSIC: X"));
1068        generated.insert("duration_seconds".into(), json_f64(15.0));
1069        apply_sfx_overrides("MUSIC: X", &mut generated, &o);
1070        assert_eq!(
1071            generated["duration_seconds"], 15.0,
1072            "generation length is not a trim"
1073        );
1074    }
1075}