1use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::LazyLock;
7
8use indexmap::IndexMap;
9use regex::Regex;
10use serde_json::{Map, Value};
11
12use crate::workspace::{show_slug, workspace_root};
13
14static PAREN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\([^)]*?\)\s*").unwrap());
15static NON_WORD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^\w\s]").unwrap());
16static SPACES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
17static ROLE_DASH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*[—–]\s*").unwrap());
18
19const BUILTIN: [(&str, &str); 21] = [
21 ("FILM AUDIO (MARGARET'S VOICE)", "film_audio"),
22 ("STRANGER (MALE VOICE, FLAT)", "stranger"),
23 ("MARGARET (V.O.)", "margaret"),
24 ("MR. PATTERSON", "mr_patterson"),
25 ("FILM AUDIO", "film_audio"),
26 ("STRANGER", "stranger"),
27 ("MARGARET", "margaret"),
28 ("MARTHA", "martha"),
29 ("GERALD", "gerald"),
30 ("KAREN", "karen"),
31 ("SARAH", "sarah"),
32 ("ELENA", "elena"),
33 ("CLERK", "clerk"),
34 ("ADAM", "adam"),
35 ("DEZ", "dez"),
36 ("MAYA", "maya"),
37 ("AVA", "ava"),
38 ("RÍAN", "rian"),
39 ("RÍÁN", "rian"),
40 ("FRANK", "frank"),
41 ("TINA", "tina"),
42];
43
44#[derive(Clone, Debug, Default)]
46pub struct Speakers {
47 pub known: Vec<String>,
48 pub keys: IndexMap<String, String>,
49}
50
51impl Speakers {
52 fn builtin() -> Speakers {
53 Speakers {
54 known: BUILTIN.iter().map(|(d, _)| d.to_string()).collect(),
55 keys: builtin_key_order(),
59 }
60 }
61}
62
63fn builtin_key_order() -> IndexMap<String, String> {
64 const ORDER: [(&str, &str); 21] = [
65 ("FILM AUDIO (MARGARET'S VOICE)", "film_audio"),
66 ("STRANGER (MALE VOICE, FLAT)", "stranger"),
67 ("MR. PATTERSON", "mr_patterson"),
68 ("FILM AUDIO", "film_audio"),
69 ("STRANGER", "stranger"),
70 ("MARGARET (V.O.)", "margaret"),
71 ("MARGARET", "margaret"),
72 ("MARTHA", "martha"),
73 ("GERALD", "gerald"),
74 ("CLERK", "clerk"),
75 ("KAREN", "karen"),
76 ("SARAH", "sarah"),
77 ("ELENA", "elena"),
78 ("ADAM", "adam"),
79 ("DEZ", "dez"),
80 ("MAYA", "maya"),
81 ("AVA", "ava"),
82 ("RÍAN", "rian"),
83 ("RÍÁN", "rian"),
84 ("FRANK", "frank"),
85 ("TINA", "tina"),
86 ];
87 ORDER
88 .iter()
89 .map(|(d, k)| (d.to_string(), k.to_string()))
90 .collect()
91}
92
93pub fn display_to_key(display: &str) -> String {
96 let name = PAREN.replace_all(display, " ");
97 let name = NON_WORD.replace_all(name.trim(), "");
98 let name = name.to_lowercase();
99 let name = name.trim();
100 SPACES.replace_all(name, "_").trim_matches('_').to_string()
101}
102
103#[derive(Clone, Debug, PartialEq)]
105pub struct CastEntry {
106 pub display: String,
107 pub key: String,
108}
109
110pub fn extract_cast_from_script(lines: &[String]) -> Vec<CastEntry> {
112 let mut entries = Vec::new();
113 let mut seen = Vec::new();
114 let mut in_cast = false;
115 for line in lines {
116 let stripped = line.trim();
117 if stripped == "CAST:" {
118 in_cast = true;
119 continue;
120 }
121 if !in_cast {
122 continue;
123 }
124 if let Some(raw) = stripped.strip_prefix('*') {
125 let name = ROLE_DASH.split(raw.trim()).next().unwrap_or("").trim();
128 if name.is_empty() {
129 continue;
130 }
131 let key = display_to_key(name);
132 if !key.is_empty() && !seen.contains(&key) {
133 entries.push(CastEntry {
134 display: name.to_string(),
135 key: key.clone(),
136 });
137 seen.push(key);
138 }
139 } else if stripped == "===" || stripped == "---" || !stripped.is_empty() {
140 break;
141 }
142 }
143 entries
144}
145
146pub fn resolve_speakers_file(path: Option<&Path>) -> Option<PathBuf> {
148 if let Some(p) = path {
149 return Some(p.to_path_buf());
150 }
151 let root = workspace_root();
152 let show = crate::workspace::read_project("project.json")
153 .get("show")
154 .and_then(Value::as_str)
155 .unwrap_or("Sample Show")
156 .to_string();
157 let normalized = root
158 .join("configs")
159 .join(show_slug(&show))
160 .join("speakers.json");
161 if normalized.exists() {
162 return Some(normalized);
163 }
164 let legacy = root.join("speakers.json");
165 legacy.exists().then_some(legacy)
166}
167
168fn read_speakers_json(path: &Path) -> Vec<Map<String, Value>> {
169 fs::read_to_string(path)
170 .ok()
171 .and_then(|t| serde_json::from_str::<Value>(&t).ok())
172 .and_then(|v| v.as_array().cloned())
173 .map(|a| {
174 a.into_iter()
175 .filter_map(|e| e.as_object().cloned())
176 .collect()
177 })
178 .unwrap_or_default()
179}
180
181pub fn load_speakers(path: Option<&Path>, cast_entries: &[CastEntry]) -> Speakers {
188 let speakers_file = resolve_speakers_file(path).filter(|p| p.exists());
189 if cast_entries.is_empty() && speakers_file.is_none() {
190 return Speakers::builtin();
191 }
192
193 let mut known: Vec<String> = Vec::new();
194 let mut keys: IndexMap<String, String> = IndexMap::new();
195
196 for entry in cast_entries {
197 if !keys.contains_key(&entry.display) {
198 known.push(entry.display.clone());
199 keys.insert(entry.display.clone(), entry.key.clone());
200 }
201 if entry.display.contains(' ') {
202 let underscore = entry.display.replace(' ', "_");
203 if !keys.contains_key(&underscore) {
204 known.push(underscore.clone());
205 }
206 keys.insert(underscore, entry.key.clone());
207 }
208 }
209
210 if let Some(f) = &speakers_file {
211 for entry in read_speakers_json(f) {
212 let (Some(display), Some(key)) = (
213 entry.get("display").and_then(Value::as_str),
214 entry.get("key").and_then(Value::as_str),
215 ) else {
216 continue;
217 };
218 if !keys.contains_key(display) {
219 known.push(display.to_string());
220 }
221 keys.insert(display.to_string(), key.to_string());
222 if display.contains(' ') {
223 let underscore = display.replace(' ', "_");
224 if !keys.contains_key(&underscore) {
225 known.push(underscore.clone());
226 }
227 keys.insert(underscore, key.to_string());
228 }
229 }
230 }
231
232 known.sort_by_key(|a| std::cmp::Reverse(a.chars().count()));
235 Speakers { known, keys }
236}
237
238pub fn load_speakers_registry(path: Option<&Path>) -> IndexMap<String, Map<String, Value>> {
240 let Some(f) = resolve_speakers_file(path).filter(|p| p.exists()) else {
241 return IndexMap::new();
242 };
243 read_speakers_json(&f)
244 .into_iter()
245 .filter_map(|e| {
246 e.get("key")
247 .and_then(Value::as_str)
248 .map(|k| (k.to_string(), e.clone()))
249 })
250 .collect()
251}
252
253pub struct SpeakerMatch {
255 pub key: String,
256 pub direction: Option<String>,
257 pub text: String,
258}
259
260pub fn try_match_speaker(line: &str, speakers: &Speakers) -> Option<SpeakerMatch> {
262 for speaker in &speakers.known {
263 let Some(rest) = line.strip_prefix(speaker.as_str()) else {
264 continue;
265 };
266 match rest.chars().next() {
268 Some(c) if c != ' ' && c != '(' => continue,
269 _ => {}
270 }
271 let mut rest = rest.trim_start();
272 let mut direction = None;
273 if rest.starts_with('(') {
274 if let Some(end) = rest.find(')') {
275 direction = Some(rest[1..end].trim().to_string());
276 rest = rest[end + 1..].trim();
277 }
278 }
279 return Some(SpeakerMatch {
280 key: speakers.keys[speaker].clone(),
281 direction,
282 text: rest.to_string(),
283 });
284 }
285 None
286}
287
288pub fn key_to_display(speakers: &Speakers) -> IndexMap<String, String> {
291 let mut out: IndexMap<String, String> = IndexMap::new();
292 for (display, key) in &speakers.keys {
293 out.entry(key.clone()).or_insert_with(|| display.clone());
294 }
295 out
296}
297
298pub fn builtin_speakers() -> Speakers {
300 Speakers::builtin()
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 fn lines(s: &str) -> Vec<String> {
308 s.split('\n').map(str::to_string).collect()
309 }
310
311 #[test]
312 fn key_derivation_matches_docstring() {
313 assert_eq!(display_to_key("ADAM"), "adam");
314 assert_eq!(display_to_key("MR. PATTERSON"), "mr_patterson");
315 assert_eq!(
316 display_to_key("FILM AUDIO (MARGARET'S VOICE)"),
317 "film_audio"
318 );
319 assert_eq!(
320 display_to_key("DETECTIVE NORA WALSH"),
321 "detective_nora_walsh"
322 );
323 assert_eq!(display_to_key("RÍAN"), "rían", "word chars include accents");
324 }
325
326 #[test]
327 fn cast_block_stops_at_the_first_non_bullet() {
328 let c = extract_cast_from_script(&lines(
329 "HEADER\nCAST:\n* ADAM — Host\n* MR. PATTERSON — Caller\n* T-BONE\n* ADAM — dupe\n\n===\nCOLD OPEN\n* LATE",
330 ));
331 assert_eq!(
332 c,
333 vec![
334 CastEntry {
335 display: "ADAM".into(),
336 key: "adam".into()
337 },
338 CastEntry {
339 display: "MR. PATTERSON".into(),
340 key: "mr_patterson".into()
341 },
342 CastEntry {
343 display: "T-BONE".into(),
344 key: "tbone".into()
345 },
346 ]
347 );
348 }
349
350 #[test]
351 fn cast_seeds_speakers_and_registers_underscore_forms() {
352 let cast = vec![CastEntry {
353 display: "NORA WALSH".into(),
354 key: "nora_walsh".into(),
355 }];
356 let s = load_speakers(Some(Path::new("/nonexistent")), &cast);
357 assert_eq!(s.known, vec!["NORA WALSH", "NORA_WALSH"]);
360 assert_eq!(s.keys["NORA WALSH"], "nora_walsh");
361 assert_eq!(s.keys["NORA_WALSH"], "nora_walsh");
362 }
363
364 #[test]
365 fn builtins_only_when_nothing_else_exists() {
366 let s = load_speakers(Some(Path::new("/nonexistent")), &[]);
367 assert_eq!(s.known.len(), 21);
368 assert_eq!(s.known[0], "FILM AUDIO (MARGARET'S VOICE)");
369 assert_eq!(s.keys["MARGARET (V.O.)"], "margaret");
370 }
371
372 #[test]
373 fn json_key_overrides_the_cast_derived_one() {
374 let tmp = tempfile::tempdir().unwrap();
375 let f = tmp.path().join("speakers.json");
376 fs::write(&f, r#"[{"display": "NORA WALSH", "key": "nora"}, {"display": "NEW GUY", "key": "newguy"}]"#).unwrap();
377 let cast = vec![CastEntry {
378 display: "NORA WALSH".into(),
379 key: "nora_walsh".into(),
380 }];
381 let s = load_speakers(Some(&f), &cast);
382 assert_eq!(s.keys["NORA WALSH"], "nora");
383 assert_eq!(s.keys["NORA_WALSH"], "nora");
384 assert_eq!(s.keys["NEW GUY"], "newguy");
385 }
386
387 #[test]
388 fn speaker_match_splits_direction_and_text() {
389 let s = load_speakers(Some(Path::new("/nonexistent")), &[]);
390 let m = try_match_speaker("ADAM (quietly) Hello there.", &s).unwrap();
391 assert_eq!(
392 (m.key.as_str(), m.direction.as_deref(), m.text.as_str()),
393 ("adam", Some("quietly"), "Hello there.")
394 );
395 let bare = try_match_speaker("ADAM", &s).unwrap();
396 assert_eq!(
397 (bare.key.as_str(), bare.direction, bare.text.as_str()),
398 ("adam", None, "")
399 );
400 assert!(
401 try_match_speaker("ADAMANT sounds", &s).is_none(),
402 "must end on space, ( or EOL"
403 );
404 assert!(try_match_speaker("Adam lowercase", &s).is_none());
405 }
406
407 #[test]
408 fn longest_name_wins_over_its_prefix() {
409 let s = load_speakers(Some(Path::new("/nonexistent")), &[]);
410 let m = try_match_speaker("MARGARET (V.O.) distant", &s).unwrap();
411 assert_eq!(m.key, "margaret");
412 assert_eq!(
413 m.text, "distant",
414 "the (V.O.) form matched whole, not MARGARET + direction"
415 );
416 }
417}