1pub const PRESET_FILE_EXT: &str = "trucepreset";
29
30const PRESET_MAGIC: &[u8; 4] = b"TRPS";
31const PRESET_FORMAT_VERSION: u32 = 1;
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
41pub struct PresetMeta {
42 pub uuid: String,
43 pub name: String,
44 pub category: String,
45 pub author: String,
46 pub comment: String,
47 pub tags: Vec<String>,
48 pub default: bool,
52}
53
54fn push_meta_line(buf: &mut String, key: &str, value: &str) {
55 if value.is_empty() {
56 return;
57 }
58 buf.push_str(key);
59 buf.push('=');
60 buf.extend(value.chars().filter(|c| *c != '\n' && *c != '\r'));
63 buf.push('\n');
64}
65
66#[must_use]
68pub fn write_preset_file(meta: &PresetMeta, state_blob: &[u8]) -> Vec<u8> {
69 let mut meta_block = String::new();
70 push_meta_line(&mut meta_block, "uuid", &meta.uuid);
71 push_meta_line(&mut meta_block, "name", &meta.name);
72 push_meta_line(&mut meta_block, "category", &meta.category);
73 push_meta_line(&mut meta_block, "author", &meta.author);
74 push_meta_line(&mut meta_block, "comment", &meta.comment);
75 let tags = meta
76 .tags
77 .iter()
78 .map(|t| t.replace(',', ""))
79 .collect::<Vec<_>>()
80 .join(",");
81 push_meta_line(&mut meta_block, "tags", &tags);
82 if meta.default {
83 push_meta_line(&mut meta_block, "default", "1");
84 }
85
86 let mut out = Vec::with_capacity(16 + meta_block.len() + state_blob.len() + 8);
87 out.extend_from_slice(PRESET_MAGIC);
88 out.extend_from_slice(&PRESET_FORMAT_VERSION.to_le_bytes());
89 out.extend_from_slice(&crate::cast::len_u32(meta_block.len()).to_le_bytes());
90 out.extend_from_slice(meta_block.as_bytes());
91 out.extend_from_slice(&(state_blob.len() as u64).to_le_bytes());
92 out.extend_from_slice(state_blob);
93 out
94}
95
96#[must_use]
99pub fn parse_preset_meta(data: &[u8]) -> Option<PresetMeta> {
100 parse_sections(data).map(|(meta, _)| meta)
101}
102
103#[must_use]
105pub fn parse_preset_file(data: &[u8]) -> Option<(PresetMeta, Vec<u8>)> {
106 parse_sections(data).map(|(meta, blob)| (meta, blob.to_vec()))
107}
108
109fn parse_sections(data: &[u8]) -> Option<(PresetMeta, &[u8])> {
110 if data.len() < 12 || &data[0..4] != PRESET_MAGIC {
111 return None;
112 }
113 let version = u32::from_le_bytes(data[4..8].try_into().ok()?);
114 if version != PRESET_FORMAT_VERSION {
115 return None;
116 }
117 let meta_len = u32::from_le_bytes(data[8..12].try_into().ok()?) as usize;
118 let meta_end = 12usize.checked_add(meta_len)?;
119 if meta_end + 8 > data.len() {
120 return None;
121 }
122 let meta_text = std::str::from_utf8(&data[12..meta_end]).ok()?;
123
124 let mut meta = PresetMeta::default();
125 for line in meta_text.lines() {
126 let Some((key, value)) = line.split_once('=') else {
127 continue;
128 };
129 match key {
130 "uuid" => meta.uuid = value.to_string(),
131 "name" => meta.name = value.to_string(),
132 "category" => meta.category = value.to_string(),
133 "author" => meta.author = value.to_string(),
134 "comment" => meta.comment = value.to_string(),
135 "tags" => {
136 meta.tags = value
137 .split(',')
138 .filter(|t| !t.is_empty())
139 .map(str::to_string)
140 .collect();
141 }
142 "default" => meta.default = value == "1",
143 _ => {}
145 }
146 }
147
148 #[allow(clippy::cast_possible_truncation)]
152 let blob_len = u64::from_le_bytes(data[meta_end..meta_end + 8].try_into().ok()?) as usize;
153 let blob_start = meta_end + 8;
154 let blob_end = blob_start.checked_add(blob_len)?;
155 if blob_end > data.len() {
156 return None;
157 }
158 Some((meta, &data[blob_start..blob_end]))
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 fn sample_meta() -> PresetMeta {
166 PresetMeta {
167 uuid: "9a2f6c1e-3b44-4f1d-9b7a-1de0c4a51b22".into(),
168 name: "Bright Saw".into(),
169 category: "Lead".into(),
170 author: "JK".into(),
171 comment: "Classic bright lead".into(),
172 tags: vec!["analog".into(), "lead".into()],
173 default: true,
174 }
175 }
176
177 #[test]
178 fn round_trips() {
179 let blob = vec![1u8, 2, 3, 4, 5];
180 let file = write_preset_file(&sample_meta(), &blob);
181 let (meta, parsed_blob) = parse_preset_file(&file).unwrap();
182 assert_eq!(meta, sample_meta());
183 assert_eq!(parsed_blob, blob);
184 assert_eq!(parse_preset_meta(&file).unwrap(), sample_meta());
185 }
186
187 #[test]
188 fn empty_fields_round_trip() {
189 let meta = PresetMeta {
190 name: "Init".into(),
191 ..PresetMeta::default()
192 };
193 let file = write_preset_file(&meta, &[]);
194 let (parsed, blob) = parse_preset_file(&file).unwrap();
195 assert_eq!(parsed, meta);
196 assert!(blob.is_empty());
197 }
198
199 #[test]
200 fn newlines_in_values_are_stripped() {
201 let meta = PresetMeta {
202 name: "Two\nLines".into(),
203 ..PresetMeta::default()
204 };
205 let file = write_preset_file(&meta, &[]);
206 assert_eq!(parse_preset_meta(&file).unwrap().name, "TwoLines");
207 }
208
209 #[test]
210 fn rejects_malformed() {
211 assert!(parse_preset_meta(b"nope").is_none());
212 let mut file = write_preset_file(&sample_meta(), &[1, 2, 3]);
213 file.truncate(file.len() - 1);
214 assert!(parse_preset_file(&file).is_none());
215 file[0] = b'X';
216 assert!(parse_preset_meta(&file).is_none());
217 }
218}