1use std::sync::LazyLock;
5
6use regex::Regex;
7
8pub const DIRECTION_TYPES: [&str; 8] = [
10 "SFX",
11 "MUSIC",
12 "AMBIENCE",
13 "BEAT",
14 "VINTAGE FILTER",
15 "FILM AUDIO",
16 "SPEAKERPHONE",
17 "PHONE FILTER",
18];
19
20static ESCAPE_ANY: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\\(.)").unwrap());
21static HEADING: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^#{1,6}\s*").unwrap());
22static SCENE_HEAD: LazyLock<Regex> =
23 LazyLock::new(|| Regex::new(r"^SCENE (\d+[A-Za-z]*):\s*(.+)").unwrap());
24static SCENE_START: LazyLock<Regex> =
25 LazyLock::new(|| Regex::new(r"^SCENE \d+[A-Za-z]*:").unwrap());
26static DIVIDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^={3,}$|^-{3,}$").unwrap());
27static BRACKETS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]").unwrap());
28static BRACKET_STRIP: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\[[^\]]+\]").unwrap());
29
30pub fn strip_markdown_escapes(text: &str) -> String {
32 let text = text
33 .replace("\\[", "[")
34 .replace("\\]", "]")
35 .replace("\\===", "===")
36 .replace("\\=", "=");
37 ESCAPE_ANY.replace_all(&text, "$1").into_owned()
38}
39
40pub fn strip_markdown_formatting(text: &str) -> String {
42 text.split('\n')
43 .map(|line| {
44 HEADING
45 .replace(line, "")
46 .replace("**", "")
47 .trim_end()
48 .to_string()
49 })
50 .collect::<Vec<_>>()
51 .join("\n")
52}
53
54pub fn classify_direction(text: &str) -> Option<&'static str> {
56 let t = text.trim();
57 for dt in DIRECTION_TYPES {
58 if t.starts_with(dt) {
59 return Some(dt);
60 }
61 }
62 if t == "BEAT" || t == "LONG BEAT" {
63 return Some("BEAT");
64 }
65 if t == "INTRO MUSIC" || t == "OUTRO MUSIC" {
66 return Some("MUSIC");
67 }
68 None
69}
70
71pub fn is_stage_direction(line: &str) -> bool {
73 line.starts_with('[') && line.contains(']')
74}
75
76pub fn find_brackets(line: &str) -> Vec<String> {
78 BRACKETS
79 .captures_iter(line)
80 .map(|c| c[1].to_string())
81 .collect()
82}
83
84pub fn strip_brackets(line: &str) -> String {
86 BRACKET_STRIP
87 .replace_all(line.trim(), "")
88 .trim()
89 .to_string()
90}
91
92pub fn is_scene_header(line: &str) -> bool {
93 SCENE_START.is_match(line)
94}
95
96pub fn parse_scene_header(line: &str) -> (Option<String>, Option<String>) {
98 match SCENE_HEAD.captures(line) {
99 Some(c) => (Some(c[1].to_string()), Some(c[2].trim().to_string())),
100 None => (None, None),
101 }
102}
103
104pub fn is_divider(line: &str) -> bool {
105 DIVIDER.is_match(line.trim())
106}
107
108pub fn is_metadata_section(line: &str) -> bool {
110 matches!(
111 line.trim(),
112 "PRODUCTION NOTES:"
113 | "SOCIAL MEDIA PROMPT:"
114 | "KEY CHANGES FROM ORIGINAL:"
115 | "ACCESSIBILITY NOTES:"
116 | "VOICES NEEDED THIS EPISODE:"
117 | "KEY SOUND EFFECTS:"
118 | "MUSIC CUES:"
119 )
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn escapes_then_formatting() {
128 assert_eq!(
129 strip_markdown_escapes(r"\[SFX\] \=\== a\.b \~ \*"),
130 "[SFX] === a.b ~ *"
131 );
132 assert_eq!(
133 strip_markdown_formatting("## Head\n**bold** text \nplain"),
134 "Head\nbold text\nplain"
135 );
136 assert_eq!(
137 strip_markdown_formatting("####### seven hashes"),
138 "# seven hashes"
139 );
140 }
141
142 #[test]
143 fn direction_classification() {
144 assert_eq!(classify_direction("SFX: DOOR"), Some("SFX"));
145 assert_eq!(classify_direction(" BEAT "), Some("BEAT"));
146 assert_eq!(classify_direction("LONG BEAT"), Some("BEAT"));
147 assert_eq!(classify_direction("BEAT — 3 SECONDS"), Some("BEAT"));
148 assert_eq!(classify_direction("INTRO MUSIC"), Some("MUSIC"));
149 assert_eq!(classify_direction("OUTRO MUSIC"), Some("MUSIC"));
150 assert_eq!(
151 classify_direction("PHONE FILTER: ENGAGES"),
152 Some("PHONE FILTER")
153 );
154 assert_eq!(classify_direction("drawn out"), None);
155 assert_eq!(classify_direction(""), None);
156 }
157
158 #[test]
159 fn scene_and_divider_shapes() {
160 assert!(is_scene_header("SCENE 5A: THE ALLEY"));
161 assert!(!is_scene_header("SCENE: THE ALLEY"));
162 assert_eq!(
163 parse_scene_header("SCENE 12: THE ROOM "),
164 (Some("12".into()), Some("THE ROOM".into()))
165 );
166 assert_eq!(
167 parse_scene_header("SCENE 3:"),
168 (None, None),
169 "a name is required"
170 );
171 assert!(is_divider("==="));
172 assert!(is_divider(" ----- "));
173 assert!(!is_divider("--"));
174 }
175
176 #[test]
177 fn brackets_found_and_stripped() {
178 assert_eq!(
179 find_brackets("[MUSIC: A] and [SFX: B]"),
180 vec!["MUSIC: A", "SFX: B"]
181 );
182 assert_eq!(
183 find_brackets("[]"),
184 Vec::<String>::new(),
185 "empty brackets do not match"
186 );
187 assert_eq!(
188 strip_brackets("SCENE 1: ROOM [AMBIENCE: hum]"),
189 "SCENE 1: ROOM"
190 );
191 assert!(is_stage_direction("[BEAT]"));
192 assert!(!is_stage_direction("text [BEAT]"));
193 }
194}