1use std::collections::HashMap;
6use std::path::Path;
7
8use serde_json::{Map, Value};
9
10use crate::fsutil::basename;
11
12pub 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
35pub 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 prefix.trim().parse::<i64>().ok()
53}
54
55pub 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}