1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7pub const META_SCHEMA_VERSION: u32 = 1;
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))]
16pub struct Meta {
17 pub version: u32,
19 #[serde(default)]
20 pub markers: BTreeMap<String, f64>,
22 #[serde(default)]
23 pub eases: BTreeMap<String, [f64; 4]>,
25 #[serde(default)]
26 pub tunables: BTreeMap<String, f64>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub audio: Option<AudioMeta>,
31 #[serde(flatten)]
32 #[cfg_attr(feature = "ts", ts(skip))]
33 pub extra: BTreeMap<String, Value>,
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))]
40pub struct AudioMeta {
41 pub file: String,
43 #[serde(default)]
44 pub offset: f64,
46 #[serde(flatten)]
47 #[cfg_attr(feature = "ts", ts(skip))]
48 pub extra: BTreeMap<String, Value>,
50}
51
52impl Default for Meta {
53 fn default() -> Self {
54 Self {
55 version: META_SCHEMA_VERSION,
56 markers: BTreeMap::new(),
57 eases: BTreeMap::new(),
58 tunables: BTreeMap::new(),
59 audio: None,
60 extra: BTreeMap::new(),
61 }
62 }
63}
64
65#[derive(Debug)]
67pub enum MetaError {
68 Json(serde_json::Error),
70 UnsupportedVersion(u32),
72 InvalidName {
74 section: &'static str,
76 name: String,
78 },
79}
80
81impl fmt::Display for MetaError {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 match self {
84 Self::Json(e) => write!(f, "invalid meta.json: {e}"),
85 Self::UnsupportedVersion(v) => write!(
86 f,
87 "meta.json schema version {v} is not supported (expected {META_SCHEMA_VERSION})"
88 ),
89 Self::InvalidName { section, name } => write!(
90 f,
91 "invalid name {name:?} in `{section}`: names must match [a-z0-9_-]+"
92 ),
93 }
94 }
95}
96
97impl std::error::Error for MetaError {
98 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
99 match self {
100 Self::Json(e) => Some(e),
101 _ => None,
102 }
103 }
104}
105
106pub fn is_valid_name(name: &str) -> bool {
108 !name.is_empty()
109 && name
110 .bytes()
111 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
112}
113
114impl Meta {
115 pub fn parse(text: &str) -> Result<Self, MetaError> {
122 let meta: Meta = serde_json::from_str(text).map_err(MetaError::Json)?;
123 if meta.version != META_SCHEMA_VERSION {
124 return Err(MetaError::UnsupportedVersion(meta.version));
125 }
126 meta.validate()?;
127 Ok(meta)
128 }
129
130 pub fn validate(&self) -> Result<(), MetaError> {
136 let sections: [(&'static str, Vec<&String>); 3] = [
137 ("markers", self.markers.keys().collect()),
138 ("eases", self.eases.keys().collect()),
139 ("tunables", self.tunables.keys().collect()),
140 ];
141 for (section, names) in sections {
142 if let Some(bad) = names.into_iter().find(|n| !is_valid_name(n)) {
143 return Err(MetaError::InvalidName {
144 section,
145 name: bad.clone(),
146 });
147 }
148 }
149 Ok(())
150 }
151
152 pub fn to_pretty_string(&self) -> String {
155 let value = serde_json::to_value(self).expect("Meta is always serializable");
158 let mut out = serde_json::to_string_pretty(&value).expect("Value is always serializable");
159 out.push('\n');
160 out
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 const SAMPLE: &str = r#"{
169 "version": 1,
170 "markers": { "beat": 1.25 },
171 "eases": { "slide": [0.65, 0, 0.35, 1] },
172 "tunables": { "speed": 1.4 },
173 "audio": { "file": "assets/voice.mp3", "offset": 0.0 }
174}"#;
175
176 #[test]
177 fn parses_spec_sample() {
178 let meta = Meta::parse(SAMPLE).unwrap();
179 assert_eq!(meta.markers["beat"], 1.25);
180 assert_eq!(meta.eases["slide"], [0.65, 0.0, 0.35, 1.0]);
181 assert_eq!(meta.tunables["speed"], 1.4);
182 assert_eq!(meta.audio.unwrap().file, "assets/voice.mp3");
183 }
184
185 #[test]
186 fn preserves_unknown_keys_and_sorts() {
187 let text =
188 r#"{"zeta": {"b": 1, "a": 2}, "version": 1, "audio": {"file": "a.mp3", "gain": 0.5}}"#;
189 let meta = Meta::parse(text).unwrap();
190 let out = meta.to_pretty_string();
191 let expected = r#"{
192 "audio": {
193 "file": "a.mp3",
194 "gain": 0.5,
195 "offset": 0.0
196 },
197 "eases": {},
198 "markers": {},
199 "tunables": {},
200 "version": 1,
201 "zeta": {
202 "a": 2,
203 "b": 1
204 }
205}
206"#;
207 assert_eq!(out, expected);
208 assert_eq!(Meta::parse(&out).unwrap(), meta);
209 }
210
211 #[test]
212 fn rejects_bad_names_and_versions() {
213 assert!(matches!(
214 Meta::parse(r#"{"version": 1, "markers": {"Beat": 1}}"#),
215 Err(MetaError::InvalidName { .. })
216 ));
217 assert!(matches!(
218 Meta::parse(r#"{"version": 2}"#),
219 Err(MetaError::UnsupportedVersion(2))
220 ));
221 }
222}