Skip to main content

truce_utils/
preset.rs

1//! The `.trucepreset` container - truce's native on-disk preset file.
2//!
3//! One file per preset: a small metadata block followed by the
4//! canonical state envelope ([`crate::state`]). `cargo-truce` writes
5//! these into plugin bundles at install time (factory scope) and the
6//! CLAP wrapper reads them back at host scan / load time, so both the
7//! writer and the parser live in the dependency-free tier.
8//!
9//! Layout (all integers little-endian):
10//!
11//! ```text
12//! "TRPS"  magic           4 bytes
13//! u32     format version  (currently 1)
14//! u32     meta_len
15//! [meta]  UTF-8 `key=value` lines (see below)
16//! u64     blob_len
17//! [blob]  canonical state envelope (crate::state wire format)
18//! ```
19//!
20//! The metadata block is deliberately not TOML: runtime consumers
21//! (format wrappers enumerating presets during a host scan) must not
22//! need a TOML dependency. One `key=value` pair per `\n`-terminated
23//! line, values taken verbatim to end-of-line. The writer strips
24//! newlines from values; `tags` joins entries with `,` (commas inside
25//! a tag are stripped at write).
26
27/// File extension for truce-native preset files, without the dot.
28pub const PRESET_FILE_EXT: &str = "trucepreset";
29
30const PRESET_MAGIC: &[u8; 4] = b"TRPS";
31const PRESET_FORMAT_VERSION: u32 = 1;
32
33/// Preset metadata carried in the container's header block.
34///
35/// `uuid` is the preset's stable identity: generated once when the
36/// preset is authored and never changed afterwards, so renames and
37/// recategorisation don't break host-side references. `category` is
38/// the explicit value only; consumers fall back to the parent
39/// directory name when it's empty.
40#[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    /// The library's "init sound". At most one preset per library
49    /// sets this; consumers order it first where a format has a
50    /// positional default (the AU factory list).
51    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    // Newlines would corrupt the line framing; values are display
61    // strings, so dropping them is acceptable.
62    buf.extend(value.chars().filter(|c| *c != '\n' && *c != '\r'));
63    buf.push('\n');
64}
65
66/// Serialize a preset file from metadata + a canonical state blob.
67#[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/// Parse just the metadata of a preset file. Cheap path for
97/// enumeration during a host scan - the state blob is not copied.
98#[must_use]
99pub fn parse_preset_meta(data: &[u8]) -> Option<PresetMeta> {
100    parse_sections(data).map(|(meta, _)| meta)
101}
102
103/// Parse a preset file into metadata + the contained state blob.
104#[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            // Unknown keys are forward-compatible: skip.
144            _ => {}
145        }
146    }
147
148    // The wire format encodes `blob_len` as `u64`; the checked_add
149    // below validates against the buffer length, which bounds it to
150    // usize on every target.
151    #[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}