Skip to main content

xil_core/
stems.rs

1//! Stem filename conventions, shared by the migrator, the cleanup pass and
2//! (later) the mixer. Port of the naming helpers in `mix_common.py` and
3//! `XILP007_stem_migrator.py`.
4
5use std::collections::HashMap;
6use std::path::Path;
7
8use serde_json::{Map, Value};
9
10use crate::fsutil::basename;
11
12/// `{seq:03d}_{section}[-{scene}]_{speaker|sfx}` — the stem basename for a
13/// parsed entry, with no extension.
14pub fn expected_stem_basename(entry: &Map<String, Value>) -> String {
15    let seq = entry.get("seq").and_then(Value::as_i64).unwrap_or(0);
16    let section = entry.get("section").and_then(Value::as_str).unwrap_or("");
17    let mut name = format!("{seq:03}_{section}");
18    if let Some(scene) = entry
19        .get("scene")
20        .and_then(Value::as_str)
21        .filter(|s| !s.is_empty())
22    {
23        name.push('-');
24        name.push_str(scene);
25    }
26    if entry.get("type").and_then(Value::as_str) == Some("dialogue") {
27        name.push('_');
28        name.push_str(entry.get("speaker").and_then(Value::as_str).unwrap_or(""));
29    } else {
30        name.push_str("_sfx");
31    }
32    name
33}
34
35/// Sequence number from a stem filename. Legacy preamble stems used an
36/// `n` prefix for negative seqs and are still parsed.
37///
38/// `None` where Python would raise `ValueError`.
39pub fn extract_seq(filepath: &Path) -> Option<i64> {
40    let name = basename(filepath);
41    let stem = match (name.rfind('.'), name.len()) {
42        (Some(dot), _) if dot > 0 => &name[..dot],
43        _ => &name[..],
44    };
45    let prefix = stem.split('_').next().unwrap_or("");
46    if let Some(digits) = prefix.strip_prefix('n') {
47        if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) {
48            return digits.parse::<i64>().ok().map(|n| -n);
49        }
50    }
51    // Python's int() accepts surrounding whitespace and a sign.
52    prefix.trim().parse::<i64>().ok()
53}
54
55/// `{seq: entry}` index over a parsed script's entries.
56pub fn entries_index(parsed: &Value) -> HashMap<i64, Map<String, Value>> {
57    parsed
58        .get("entries")
59        .and_then(Value::as_array)
60        .map(|entries| {
61            entries
62                .iter()
63                .filter_map(|e| {
64                    let o = e.as_object()?;
65                    Some((o.get("seq")?.as_i64()?, o.clone()))
66                })
67                .collect()
68        })
69        .unwrap_or_default()
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use serde_json::json;
76
77    fn obj(v: Value) -> Map<String, Value> {
78        v.as_object().unwrap().clone()
79    }
80
81    #[test]
82    fn basename_shapes() {
83        assert_eq!(
84            expected_stem_basename(&obj(
85                json!({"seq": 3, "section": "cold-open", "scene": "scene-1", "type": "dialogue", "speaker": "adam"})
86            )),
87            "003_cold-open-scene-1_adam"
88        );
89        assert_eq!(
90            expected_stem_basename(&obj(
91                json!({"seq": 12, "section": "act1", "scene": null, "type": "direction"})
92            )),
93            "012_act1_sfx"
94        );
95        assert_eq!(
96            expected_stem_basename(&obj(
97                json!({"seq": 100, "section": "", "type": "dialogue", "speaker": "x"})
98            )),
99            "100__x"
100        );
101    }
102
103    #[test]
104    fn seq_extraction_including_legacy_negatives() {
105        assert_eq!(
106            extract_seq(Path::new("stems/S01E01/003_cold-open_adam.mp3")),
107            Some(3)
108        );
109        assert_eq!(extract_seq(Path::new("n002_preamble_tina.mp3")), Some(-2));
110        assert_eq!(extract_seq(Path::new("nope_x.mp3")), None);
111        assert_eq!(extract_seq(Path::new("notanumber.mp3")), None);
112        assert_eq!(extract_seq(Path::new("017.mp3")), Some(17));
113    }
114
115    #[test]
116    fn index_keys_on_seq() {
117        let parsed =
118            json!({"entries": [{"seq": 1, "type": "dialogue"}, {"seq": 5, "type": "direction"}]});
119        let idx = entries_index(&parsed);
120        assert_eq!(idx.len(), 2);
121        assert_eq!(idx[&5]["type"], "direction");
122        assert!(entries_index(&json!({})).is_empty());
123    }
124}