1use indexmap::IndexMap;
5
6pub const HINT_ATTRS: [(&str, &str); 2] = [
9 ("play_volume_pct", "volume_percentage"),
10 ("play_duration_pct", "play_duration"),
11];
12
13pub fn hint_range(field: &str) -> Option<(f64, f64)> {
15 match field {
16 "volume_percentage" => Some((0.0, 200.0)),
17 "play_duration" => Some((0.0, 100.0)),
18 _ => None,
19 }
20}
21
22pub const LOOPED_CUE_PREFIXES: [&str; 2] = ["AMBIENCE:", "VINTAGE FILTER"];
25
26#[derive(Debug, Default, PartialEq)]
28pub struct Hints {
29 pub clean: String,
31 pub source: Option<String>,
33 pub overrides: IndexMap<String, f64>,
35}
36
37pub fn parse_direction_hint(raw: &str, slug: &str, warn: &mut dyn FnMut(String)) -> Hints {
47 if !raw.contains(" | ") {
48 return Hints {
49 clean: raw.trim().to_string(),
50 source: None,
51 overrides: IndexMap::new(),
52 };
53 }
54
55 let parts: Vec<&str> = raw.split(" | ").collect();
56 let head = parts[0].trim().to_string();
57 let mut source: Option<String> = None;
58 let mut overrides: IndexMap<String, f64> = IndexMap::new();
59 let mut unconsumed: Vec<String> = Vec::new();
60
61 for seg in &parts[1..] {
62 let seg = seg.trim();
63 if seg.ends_with(".mp3") || seg.ends_with(".wav") {
64 if source.is_none() {
65 let prefix = if slug.is_empty() {
66 "SFX".to_string()
67 } else {
68 format!("SFX/{slug}")
69 };
70 source = Some(format!("{prefix}/{seg}"));
71 } else {
72 unconsumed.push(seg.to_string());
73 }
74 continue;
75 }
76 let Some((key, value)) = seg.split_once('=') else {
77 unconsumed.push(seg.to_string());
78 continue;
79 };
80 let field = HINT_ATTRS
81 .iter()
82 .find(|(k, _)| *k == key.trim())
83 .map(|(_, f)| *f);
84 let Some(field) = field else {
85 unconsumed.push(seg.to_string());
86 continue;
87 };
88 match parse_hint_value(field, value.trim(), raw, warn) {
89 Some(num) => {
90 overrides.insert(field.to_string(), num);
91 }
92 None => unconsumed.push(seg.to_string()),
93 }
94 }
95
96 let clean = if unconsumed.is_empty() {
97 head
98 } else {
99 let mut all = vec![head];
100 all.extend(unconsumed);
101 all.join(" | ")
102 };
103 Hints {
104 clean,
105 source,
106 overrides,
107 }
108}
109
110fn parse_hint_value(
111 field: &str,
112 value: &str,
113 raw: &str,
114 warn: &mut dyn FnMut(String),
115) -> Option<f64> {
116 let (lo, hi) = hint_range(field)?;
117 let cleaned = value.trim_end_matches('%').trim();
118 let Ok(num) = cleaned.parse::<f64>() else {
119 warn(format!(
120 " Ignoring non-numeric hint value in [{raw}]: {}",
121 py_repr(value)
122 ));
123 return None;
124 };
125 if !(lo..=hi).contains(&num) {
126 warn(format!(
127 " Ignoring out-of-range {field} in [{raw}]: {value} (expected {}–{})",
128 fmt_g(lo),
129 fmt_g(hi)
130 ));
131 return None;
132 }
133 Some(num)
134}
135
136pub fn py_repr(s: &str) -> String {
138 if s.contains('\'') && !s.contains('"') {
139 format!("\"{s}\"")
140 } else {
141 format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"))
142 }
143}
144
145pub fn fmt_g(v: f64) -> String {
147 if v == v.trunc() && v.abs() < 1e16 {
148 return format!("{}", v as i64);
149 }
150 let s = format!("{v:e}");
151 let exp: i32 = s
152 .split_once('e')
153 .map(|(_, e)| e.parse().unwrap_or(0))
154 .unwrap_or(0);
155 if (-5..6).contains(&exp) {
156 let mut out = format!("{v:.*}", (5 - exp).max(0) as usize);
157 if out.contains('.') {
158 out = out.trim_end_matches('0').trim_end_matches('.').to_string();
159 }
160 out
161 } else {
162 format!("{v:e}")
163 }
164}
165
166pub fn format_hint_attr(field: &str, value: f64) -> String {
169 let name = HINT_ATTRS
170 .iter()
171 .find(|(_, f)| *f == field)
172 .map(|(k, _)| *k)
173 .unwrap_or(field);
174 format!("{name}={}%", fmt_g(value))
175}
176
177pub fn filter_sfx_overrides(
180 key_text: &str,
181 entry_is_silence: bool,
182 overrides: &IndexMap<String, f64>,
183 warn: Option<&mut dyn FnMut(String)>,
184) -> IndexMap<String, f64> {
185 if overrides.is_empty() || entry_is_silence {
186 return IndexMap::new();
187 }
188 let mut allowed = overrides.clone();
189 if allowed.contains_key("play_duration")
190 && LOOPED_CUE_PREFIXES.iter().any(|p| key_text.starts_with(p))
191 {
192 allowed.shift_remove("play_duration");
193 if let Some(w) = warn {
194 w(format!(
195 " Ignoring play_duration_pct on looped cue [{key_text}]"
196 ));
197 }
198 }
199 allowed
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 fn hints(raw: &str, slug: &str) -> (Hints, Vec<String>) {
207 let mut warns = Vec::new();
208 let h = parse_direction_hint(raw, slug, &mut |m| warns.push(m));
209 (h, warns)
210 }
211
212 #[test]
213 fn no_pipe_means_no_hints() {
214 let (h, w) = hints(" SFX: DOOR OPENS ", "the413");
215 assert_eq!(
216 h,
217 Hints {
218 clean: "SFX: DOOR OPENS".into(),
219 source: None,
220 overrides: IndexMap::new()
221 }
222 );
223 assert!(w.is_empty());
224 }
225
226 #[test]
227 fn source_and_attributes_are_order_free() {
228 let (h, _) = hints(
229 "OUTRO MUSIC | play_volume_pct=20% | sundy.mp3 | play_duration_pct=35",
230 "the413",
231 );
232 assert_eq!(h.clean, "OUTRO MUSIC");
233 assert_eq!(h.source.as_deref(), Some("SFX/the413/sundy.mp3"));
234 assert_eq!(h.overrides["volume_percentage"], 20.0);
235 assert_eq!(h.overrides["play_duration"], 35.0);
236 }
237
238 #[test]
239 fn slugless_source_lands_in_the_flat_dir() {
240 let (h, _) = hints("SFX: X | a.wav", "");
241 assert_eq!(h.source.as_deref(), Some("SFX/a.wav"));
242 }
243
244 #[test]
245 fn unknown_segments_are_rejoined_not_swallowed() {
246 let (h, _) = hints(
247 "SFX: DOOR | some notes | second.mp3 | first.mp3 | nope=1",
248 "s",
249 );
250 assert_eq!(
251 h.source.as_deref(),
252 Some("SFX/s/second.mp3"),
253 "first filename wins"
254 );
255 assert_eq!(h.clean, "SFX: DOOR | some notes | first.mp3 | nope=1");
256 }
257
258 #[test]
259 fn bad_values_warn_and_are_kept_as_text() {
260 let (h, w) = hints("SFX: X | play_volume_pct=abc", "s");
261 assert_eq!(h.clean, "SFX: X | play_volume_pct=abc");
262 assert_eq!(
263 w,
264 vec![" Ignoring non-numeric hint value in [SFX: X | play_volume_pct=abc]: 'abc'"]
265 );
266
267 let (h, w) = hints("SFX: X | play_volume_pct=500", "s");
268 assert_eq!(h.clean, "SFX: X | play_volume_pct=500");
269 assert_eq!(w[0], " Ignoring out-of-range volume_percentage in [SFX: X | play_volume_pct=500]: 500 (expected 0–200)");
270 }
271
272 #[test]
273 fn looped_cues_drop_play_duration_only() {
274 let mut o = IndexMap::new();
275 o.insert("play_duration".to_string(), 50.0);
276 o.insert("volume_percentage".to_string(), 30.0);
277 let mut warns = Vec::new();
278 let kept = filter_sfx_overrides("AMBIENCE: RAIN", false, &o, Some(&mut |m| warns.push(m)));
279 assert_eq!(kept.len(), 1);
280 assert_eq!(kept["volume_percentage"], 30.0);
281 assert_eq!(
282 warns,
283 vec![" Ignoring play_duration_pct on looped cue [AMBIENCE: RAIN]"]
284 );
285 assert!(
286 filter_sfx_overrides("BEAT", true, &o, None).is_empty(),
287 "silence takes none"
288 );
289 assert_eq!(filter_sfx_overrides("SFX: X", false, &o, None).len(), 2);
290 }
291
292 #[test]
293 fn hint_attr_round_trips_without_trailing_zeros() {
294 assert_eq!(
295 format_hint_attr("volume_percentage", 20.0),
296 "play_volume_pct=20%"
297 );
298 assert_eq!(
299 format_hint_attr("play_duration", 35.5),
300 "play_duration_pct=35.5%"
301 );
302 assert_eq!(fmt_g(0.5), "0.5");
303 assert_eq!(fmt_g(100.0), "100");
304 }
305}