Skip to main content

xil/cmd/
splice.rs

1//! `xil splice` — insert or delete parsed entries with automatic seq
2//! renumbering. Port of `XILU006_splice_parsed.py`.
3
4use std::ffi::OsString;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use clap::Parser;
9use serde_json::{Map, Value};
10use xil_core::pyjson::{dumps, Style};
11use xil_core::workspace::{derive_paths, resolve_slug};
12use xil_core::{banner, log};
13
14#[derive(Parser)]
15#[command(
16    name = "xil-splice",
17    about = "Splice Parsed JSON — insert/delete entries with automatic renumbering"
18)]
19struct Args {
20    /// Episode tag (e.g. S02E03) — derives target parsed JSON path
21    #[arg(long, required_unless_present = "tag", conflicts_with = "tag")]
22    episode: Option<String>,
23    /// Raw tag for non-episodic content (e.g. V01C03, D01)
24    #[arg(long)]
25    tag: Option<String>,
26    /// Show name override (default: from project.json)
27    #[arg(long)]
28    show: Option<String>,
29    /// Override target parsed JSON path
30    #[arg(long)]
31    parsed: Option<PathBuf>,
32    /// Seq number to insert after
33    #[arg(long)]
34    insert_after: Option<i64>,
35    /// Source parsed JSON to extract entries from
36    #[arg(long)]
37    from_parsed: Option<PathBuf>,
38    /// Seq range to extract from source (e.g. 232-233)
39    #[arg(long)]
40    from_seq_range: Option<String>,
41    /// Path to a JSON array of entries to insert
42    #[arg(long)]
43    from_json: Option<PathBuf>,
44    /// Override section on inserted entries
45    #[arg(long)]
46    section: Option<String>,
47    /// Override scene on inserted entries
48    #[arg(long)]
49    scene: Option<String>,
50    /// Seq range to delete (e.g. 100-105)
51    #[arg(long)]
52    delete_seq_range: Option<String>,
53    /// Show plan without writing files
54    #[arg(long)]
55    dry_run: bool,
56    /// Skip backup file
57    #[arg(long)]
58    no_backup: bool,
59    /// Summary only, no per-entry detail
60    #[arg(long)]
61    quiet: bool,
62}
63
64type Entry = Map<String, Value>;
65
66fn seq_of(e: &Entry) -> i64 {
67    e.get("seq").and_then(Value::as_i64).unwrap_or(0)
68}
69
70fn str_of(e: &Entry, key: &str) -> String {
71    e.get(key).and_then(Value::as_str).unwrap_or("").to_string()
72}
73
74/// `e.get(key, "")` inside an f-string. The default only applies when the
75/// key is absent: a key present with a null value renders as `None`, which
76/// is what the report lines actually print for a direction entry.
77fn py_get_str(e: &Entry, key: &str) -> String {
78    match e.get(key) {
79        None => String::new(),
80        Some(Value::Null) => "None".to_string(),
81        Some(Value::String(s)) => s.clone(),
82        Some(other) => other.to_string(),
83    }
84}
85
86/// Entries within `[start, end]` inclusive.
87pub fn extract_seq_range(entries: &[Entry], start: i64, end: i64) -> Vec<Entry> {
88    entries
89        .iter()
90        .filter(|e| (start..=end).contains(&seq_of(e)))
91        .cloned()
92        .collect()
93}
94
95/// Insert `new_entries` after `insert_after_seq`, then renumber the body.
96///
97/// Preamble entries (seq <= 0) are never renumbered. New entries inherit
98/// section and scene from the insertion point unless overridden.
99pub fn splice_entries(
100    entries: &[Entry],
101    insert_after_seq: i64,
102    new_entries: &[Entry],
103    section_override: Option<&str>,
104    scene_override: Option<&str>,
105) -> Result<Vec<Entry>, String> {
106    if insert_after_seq <= 0 {
107        return Err(format!(
108            "Cannot insert after seq {insert_after_seq} (preamble zone)"
109        ));
110    }
111    let preamble: Vec<Entry> = entries.iter().filter(|e| seq_of(e) <= 0).cloned().collect();
112    let mut body: Vec<Entry> = entries.iter().filter(|e| seq_of(e) > 0).cloned().collect();
113
114    let Some(idx) = body.iter().position(|e| seq_of(e) == insert_after_seq) else {
115        return Err(format!("seq {insert_after_seq} not found in entries"));
116    };
117    let anchor = body[idx].clone();
118
119    let prepared: Vec<Entry> = new_entries
120        .iter()
121        .map(|e| {
122            let mut ne = e.clone();
123            ne.insert(
124                "section".into(),
125                match section_override {
126                    Some(s) => Value::String(s.to_string()),
127                    None => anchor.get("section").cloned().unwrap_or(Value::Null),
128                },
129            );
130            ne.insert(
131                "scene".into(),
132                match scene_override {
133                    Some(s) => Value::String(s.to_string()),
134                    None => anchor.get("scene").cloned().unwrap_or(Value::Null),
135                },
136            );
137            ne
138        })
139        .collect();
140
141    body.splice(idx + 1..idx + 1, prepared);
142    for (i, e) in body.iter_mut().enumerate() {
143        e.insert("seq".into(), Value::from(i as i64 + 1));
144    }
145    Ok(preamble.into_iter().chain(body).collect())
146}
147
148/// Remove `[start, end]` inclusive and renumber the remainder.
149pub fn delete_entries(entries: &[Entry], start: i64, end: i64) -> Result<Vec<Entry>, String> {
150    if start <= 0 {
151        return Err(format!(
152            "Cannot delete preamble entries (seq_range starts at {start})"
153        ));
154    }
155    let preamble: Vec<Entry> = entries.iter().filter(|e| seq_of(e) <= 0).cloned().collect();
156    let mut body: Vec<Entry> = entries
157        .iter()
158        .filter(|e| seq_of(e) > 0 && !(start..=end).contains(&seq_of(e)))
159        .cloned()
160        .collect();
161    for (i, e) in body.iter_mut().enumerate() {
162        e.insert("seq".into(), Value::from(i as i64 + 1));
163    }
164    Ok(preamble.into_iter().chain(body).collect())
165}
166
167/// Recompute `stats` from the body entries (seq > 0).
168pub fn update_stats(data: &mut Map<String, Value>) {
169    let entries: Vec<Entry> = data
170        .get("entries")
171        .and_then(Value::as_array)
172        .map(|a| a.iter().filter_map(|e| e.as_object().cloned()).collect())
173        .unwrap_or_default();
174    let body: Vec<&Entry> = entries.iter().filter(|e| seq_of(e) > 0).collect();
175    let dialogue: Vec<&&Entry> = body
176        .iter()
177        .filter(|e| str_of(e, "type") == "dialogue")
178        .collect();
179    let directions = body
180        .iter()
181        .filter(|e| str_of(e, "type") == "direction")
182        .count();
183
184    let mut speakers: Vec<String> = dialogue
185        .iter()
186        .filter_map(|e| {
187            e.get("speaker")
188                .and_then(Value::as_str)
189                .filter(|s| !s.is_empty())
190                .map(str::to_string)
191        })
192        .collect();
193    speakers.sort();
194    speakers.dedup();
195    let mut sections: Vec<String> = body
196        .iter()
197        .filter_map(|e| {
198            e.get("section")
199                .and_then(Value::as_str)
200                .filter(|s| !s.is_empty())
201                .map(str::to_string)
202        })
203        .collect();
204    sections.sort();
205    sections.dedup();
206    let tts_chars: usize = dialogue
207        .iter()
208        .map(|e| str_of(e, "text").chars().count())
209        .sum();
210
211    let mut stats = Map::new();
212    stats.insert("total_entries".into(), Value::from(body.len()));
213    stats.insert("dialogue_lines".into(), Value::from(dialogue.len()));
214    stats.insert("direction_lines".into(), Value::from(directions));
215    stats.insert("characters_for_tts".into(), Value::from(tts_chars));
216    stats.insert(
217        "speakers".into(),
218        Value::Array(speakers.into_iter().map(Value::String).collect()),
219    );
220    stats.insert(
221        "sections".into(),
222        Value::Array(sections.into_iter().map(Value::String).collect()),
223    );
224    data.insert("stats".into(), Value::Object(stats));
225}
226
227/// `'N-M'` → `(N, M)`.
228fn parse_range(s: &str) -> Option<(i64, i64)> {
229    let (a, b) = s.split_once('-')?;
230    Some((a.trim().parse().ok()?, b.trim().parse().ok()?))
231}
232
233/// First `n` characters, as Python's `text[:n]` slices.
234fn head(s: &str, n: usize) -> String {
235    s.chars().take(n).collect()
236}
237
238fn execute(a: &Args) -> anyhow::Result<i32> {
239    let tag = a
240        .episode
241        .clone()
242        .or_else(|| a.tag.clone())
243        .unwrap_or_default();
244    let slug = resolve_slug(a.show.as_deref(), "project.json");
245    let paths = derive_paths(&slug, &tag);
246    let target = a.parsed.clone().unwrap_or_else(|| paths["parsed"].clone());
247
248    if !target.exists() {
249        log::error(&format!(
250            "Target parsed JSON not found: {}",
251            target.display()
252        ));
253        return Ok(0);
254    }
255    log::info(&format!("  Target: {}", target.display()));
256
257    // Resolve the entries to insert.
258    let mut new_entries: Option<Vec<Entry>> = None;
259    if a.insert_after.is_some() {
260        if let (Some(from), Some(range)) = (&a.from_parsed, &a.from_seq_range) {
261            let Some((start, end)) = parse_range(range) else {
262                log::error(&format!("Expected N-M range, got: {range}"));
263                return Ok(2);
264            };
265            let source: Value = serde_json::from_str(&fs::read_to_string(from)?)?;
266            let source_entries: Vec<Entry> = source
267                .get("entries")
268                .and_then(Value::as_array)
269                .map(|v| v.iter().filter_map(|e| e.as_object().cloned()).collect())
270                .unwrap_or_default();
271            let picked = extract_seq_range(&source_entries, start, end);
272            if picked.is_empty() {
273                log::warning(&format!(
274                    "No entries found in seq range {start}–{end} of {}",
275                    from.display()
276                ));
277                return Ok(0);
278            }
279            log::info(&format!(
280                "  Source: {} seq {start}–{end} ({} entries)",
281                from.display(),
282                picked.len()
283            ));
284            new_entries = Some(picked);
285        } else if let Some(from_json) = &a.from_json {
286            let v: Value = serde_json::from_str(&fs::read_to_string(from_json)?)?;
287            let picked: Vec<Entry> = v
288                .as_array()
289                .map(|a| a.iter().filter_map(|e| e.as_object().cloned()).collect())
290                .unwrap_or_default();
291            log::info(&format!(
292                "  Source: {} ({} entries)",
293                from_json.display(),
294                picked.len()
295            ));
296            new_entries = Some(picked);
297        } else {
298            log::error("--insert-after requires --from-parsed + --from-seq-range or --from-json");
299            return Ok(0);
300        }
301    }
302
303    let delete_range = match &a.delete_seq_range {
304        Some(r) => match parse_range(r) {
305            Some(v) => Some(v),
306            None => {
307                log::error(&format!("Expected N-M range, got: {r}"));
308                return Ok(2);
309            }
310        },
311        None => None,
312    };
313
314    if new_entries.is_none() && delete_range.is_none() {
315        log::error("Nothing to do — specify --insert-after or --delete-seq-range");
316        return Ok(0);
317    }
318
319    let backup_path = (!a.no_backup && !a.dry_run).then(|| {
320        let name = format!("pre_splice_parsed_{slug}_{tag}.json");
321        match target.parent().filter(|d| !d.as_os_str().is_empty()) {
322            Some(d) => d.join(name),
323            None => PathBuf::from(name),
324        }
325    });
326
327    run_splice(
328        &target,
329        a.insert_after,
330        new_entries.as_deref(),
331        delete_range,
332        a.section.as_deref(),
333        a.scene.as_deref(),
334        a.dry_run,
335        backup_path.as_deref(),
336        a.quiet,
337    )?;
338
339    if !a.dry_run {
340        log::info("\n  Next steps:");
341        log::info(&format!(
342            "    1. python XILP007_stem_migrator.py --episode {tag} --orig-prefix pre_splice_"
343        ));
344        log::info(&format!(
345            "    2. python XILP002_producer.py --episode {tag}"
346        ));
347    }
348    Ok(0)
349}
350
351#[allow(clippy::too_many_arguments)]
352fn run_splice(
353    target: &Path,
354    insert_after: Option<i64>,
355    new_entries: Option<&[Entry]>,
356    delete_range: Option<(i64, i64)>,
357    section_override: Option<&str>,
358    scene_override: Option<&str>,
359    dry_run: bool,
360    backup_path: Option<&Path>,
361    quiet: bool,
362) -> anyhow::Result<()> {
363    let original_content = fs::read_to_string(target)?;
364    let mut data: Map<String, Value> = serde_json::from_str(&original_content)?;
365    let mut entries: Vec<Entry> = data
366        .get("entries")
367        .and_then(Value::as_array)
368        .map(|a| a.iter().filter_map(|e| e.as_object().cloned()).collect())
369        .unwrap_or_default();
370    let original_count = entries.iter().filter(|e| seq_of(e) > 0).count();
371
372    if let Some((start, end)) = delete_range {
373        let deleted: Vec<Entry> = entries
374            .iter()
375            .filter(|e| (start..=end).contains(&seq_of(e)))
376            .cloned()
377            .collect();
378        entries = delete_entries(&entries, start, end).map_err(|e| anyhow::anyhow!(e))?;
379        if !quiet {
380            log::info(&format!(
381                "\n  DELETE seq {start}–{end}: {} entries removed",
382                deleted.len()
383            ));
384            for e in &deleted {
385                log::info(&format!(
386                    "    - seq {} [{}] {} — {}",
387                    seq_of(e),
388                    str_of(e, "type"),
389                    py_get_str(e, "speaker"),
390                    head(&str_of(e, "text"), 60)
391                ));
392            }
393        }
394    }
395
396    if let (Some(after), Some(new)) = (insert_after, new_entries) {
397        if !new.is_empty() {
398            if !quiet {
399                // Python's conditional expression binds over the whole
400                // logger.info call, so with no anchor it logs an empty line.
401                match entries.iter().find(|e| seq_of(e) == after) {
402                    Some(anchor) => log::info(&format!(
403                        "\n  INSERT {} entries after seq {after} ({}...)",
404                        new.len(),
405                        head(&str_of(anchor, "text"), 40)
406                    )),
407                    None => log::info(""),
408                }
409                for e in new {
410                    let label = section_override.unwrap_or("(inherit)");
411                    log::info(&format!(
412                        "    + [{}] {} — {}  [section={label}]",
413                        str_of(e, "type"),
414                        py_get_str(e, "speaker"),
415                        head(&str_of(e, "text"), 60)
416                    ));
417                }
418            }
419            entries = splice_entries(&entries, after, new, section_override, scene_override)
420                .map_err(|e| anyhow::anyhow!(e))?;
421        }
422    }
423
424    data.insert(
425        "entries".into(),
426        Value::Array(entries.iter().cloned().map(Value::Object).collect()),
427    );
428    update_stats(&mut data);
429
430    let new_count = entries.iter().filter(|e| seq_of(e) > 0).count();
431    log::info(&format!(
432        "\n  Summary: {original_count} → {new_count} entries (body)"
433    ));
434
435    if dry_run {
436        log::info("  [DRY RUN] No files written.");
437        return Ok(());
438    }
439    if let Some(b) = backup_path {
440        fs::write(b, &original_content)?;
441        log::info(&format!("  Backup written: {}", b.display()));
442    }
443    fs::write(
444        target,
445        format!("{}\n", dumps(&Value::Object(data), Style::INDENT2_UTF8)),
446    )?;
447    log::info(&format!("  Updated: {}", target.display()));
448    Ok(())
449}
450
451pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
452    log::init("splice");
453    let _banner = banner::begin(super::prog(), &super::argv_line(args));
454    let a: Args = match super::parse_or_exit("xil-splice", args) {
455        Ok(a) => a,
456        Err(code) => return Ok(code),
457    };
458    execute(&a)
459}
460
461/// The clap definition behind `--help`, for man pages.
462pub fn command() -> clap::Command {
463    <Args as clap::CommandFactory>::command()
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use serde_json::json;
470
471    fn entries(v: Value) -> Vec<Entry> {
472        v.as_array()
473            .unwrap()
474            .iter()
475            .map(|e| e.as_object().unwrap().clone())
476            .collect()
477    }
478
479    fn seqs(es: &[Entry]) -> Vec<i64> {
480        es.iter().map(seq_of).collect()
481    }
482
483    #[test]
484    fn insert_renumbers_the_body_and_spares_the_preamble() {
485        let base = entries(json!([
486            {"seq": -1, "type": "dialogue", "text": "pre", "section": "preamble", "scene": null},
487            {"seq": 1, "type": "dialogue", "text": "a", "section": "act1", "scene": "scene-1"},
488            {"seq": 2, "type": "dialogue", "text": "b", "section": "act1", "scene": "scene-1"}
489        ]));
490        let new = entries(
491            json!([{"seq": 99, "type": "dialogue", "text": "n", "section": "x", "scene": "y"}]),
492        );
493        let out = splice_entries(&base, 1, &new, None, None).unwrap();
494        assert_eq!(seqs(&out), vec![-1, 1, 2, 3]);
495        assert_eq!(str_of(&out[2], "text"), "n");
496        assert_eq!(
497            str_of(&out[2], "section"),
498            "act1",
499            "inherited from the anchor"
500        );
501        assert_eq!(str_of(&out[2], "scene"), "scene-1");
502    }
503
504    #[test]
505    fn overrides_win_over_inheritance() {
506        let base = entries(
507            json!([{"seq": 1, "type": "dialogue", "text": "a", "section": "act1", "scene": "scene-1"}]),
508        );
509        let new = entries(json!([{"seq": 9, "type": "dialogue", "text": "n"}]));
510        let out = splice_entries(&base, 1, &new, Some("post-interview"), Some("scene-9")).unwrap();
511        assert_eq!(str_of(&out[1], "section"), "post-interview");
512        assert_eq!(str_of(&out[1], "scene"), "scene-9");
513    }
514
515    #[test]
516    fn insert_rejects_the_preamble_zone_and_missing_anchors() {
517        let base = entries(
518            json!([{"seq": 1, "type": "dialogue", "text": "a", "section": null, "scene": null}]),
519        );
520        assert_eq!(
521            splice_entries(&base, 0, &[], None, None).unwrap_err(),
522            "Cannot insert after seq 0 (preamble zone)"
523        );
524        assert_eq!(
525            splice_entries(&base, 7, &[], None, None).unwrap_err(),
526            "seq 7 not found in entries"
527        );
528    }
529
530    #[test]
531    fn delete_closes_the_gap() {
532        let base = entries(json!([
533            {"seq": -1, "type": "dialogue", "text": "pre"},
534            {"seq": 1, "type": "dialogue", "text": "a"},
535            {"seq": 2, "type": "dialogue", "text": "b"},
536            {"seq": 3, "type": "dialogue", "text": "c"},
537            {"seq": 4, "type": "dialogue", "text": "d"}
538        ]));
539        let out = delete_entries(&base, 2, 3).unwrap();
540        assert_eq!(seqs(&out), vec![-1, 1, 2]);
541        assert_eq!(str_of(&out[2], "text"), "d");
542        assert_eq!(
543            delete_entries(&base, 0, 1).unwrap_err(),
544            "Cannot delete preamble entries (seq_range starts at 0)"
545        );
546    }
547
548    #[test]
549    fn stats_count_body_entries_only() {
550        let mut data: Map<String, Value> = json!({"entries": [
551            {"seq": -1, "type": "dialogue", "text": "pre", "speaker": "tina", "section": "preamble"},
552            {"seq": 1, "type": "dialogue", "text": "Café", "speaker": "adam", "section": "act1"},
553            {"seq": 2, "type": "direction", "text": "SFX: X", "section": "act1"},
554            {"seq": 3, "type": "dialogue", "text": "hi", "speaker": "adam", "section": "act2"}
555        ]})
556        .as_object()
557        .unwrap()
558        .clone();
559        update_stats(&mut data);
560        let s = &data["stats"];
561        assert_eq!(s["total_entries"], 3, "the preamble entry is excluded");
562        assert_eq!(s["dialogue_lines"], 2);
563        assert_eq!(s["direction_lines"], 1);
564        assert_eq!(s["characters_for_tts"], 6, "4 + 2, counted in characters");
565        assert_eq!(s["speakers"], json!(["adam"]));
566        assert_eq!(s["sections"], json!(["act1", "act2"]));
567    }
568
569    #[test]
570    fn absent_and_null_speakers_render_differently() {
571        let e = entries(
572            json!([{"seq": 1, "type": "direction", "speaker": null, "text": "t"},
573                               {"seq": 2, "type": "direction", "text": "t"}]),
574        );
575        assert_eq!(
576            py_get_str(&e[0], "speaker"),
577            "None",
578            "present but null prints None"
579        );
580        assert_eq!(py_get_str(&e[1], "speaker"), "", "absent takes the default");
581    }
582
583    #[test]
584    fn range_parsing() {
585        assert_eq!(parse_range("232-233"), Some((232, 233)));
586        assert_eq!(parse_range("5-5"), Some((5, 5)));
587        assert_eq!(parse_range("nope"), None);
588        assert_eq!(parse_range("1-"), None);
589    }
590}