1#![allow(dead_code)]
18
19use std::collections::BTreeMap;
20
21use serde::Deserialize;
22use thiserror::Error;
23
24pub const TEMPLATE: &str = include_str!("../templates/manifest-template.md");
28
29#[derive(Debug, Clone)]
35pub struct Vars {
36 pub volume: String,
37 pub distro: String,
38 pub distro_version: String,
39 pub created: String,
40 pub kernel_source: String,
41 pub distro_source: String,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
49pub struct ManifestFrontmatter {
50 pub volume: String,
51 pub distro: String,
52 #[serde(rename = "distro-version")]
53 pub distro_version: String,
54 pub created: String,
55 #[serde(rename = "kernel-source")]
56 pub kernel_source: String,
57 #[serde(rename = "distro-source")]
58 pub distro_source: String,
59}
60
61#[derive(Debug, Error)]
65pub enum Error {
66 #[error("MANIFEST.md is missing its `---...---` YAML frontmatter fences")]
67 MissingFrontmatter,
68
69 #[error("MANIFEST.md frontmatter is missing required field: {field}")]
70 MissingField { field: String },
71
72 #[error("MANIFEST.md frontmatter is not valid YAML: {0}")]
73 Yaml(#[from] serde_yml::Error),
74
75 #[error("invalid source format '{value}' in MANIFEST.md — expected org/repo")]
76 InvalidSourceFormat { value: String },
77}
78
79pub fn stamp(vars: &Vars) -> String {
86 TEMPLATE
87 .replace("{{volume}}", &vars.volume)
88 .replace("{{distro}}", &vars.distro)
89 .replace("{{distro-version}}", &vars.distro_version)
90 .replace("{{created}}", &vars.created)
91 .replace("{{kernel-source}}", &vars.kernel_source)
92 .replace("{{distro-source}}", &vars.distro_source)
93}
94
95pub fn parse_frontmatter(md: &str) -> Result<ManifestFrontmatter, Error> {
104 let yaml_body = extract_frontmatter_block(md)?;
105
106 let mut map: BTreeMap<String, String> = serde_yml::from_str(&yaml_body)?;
112
113 fn take(map: &mut BTreeMap<String, String>, key: &str) -> Result<String, Error> {
114 map.remove(key).ok_or_else(|| Error::MissingField {
115 field: key.to_string(),
116 })
117 }
118
119 let volume = take(&mut map, "volume")?;
122 let distro = take(&mut map, "distro")?;
123 let distro_version = take(&mut map, "distro-version")?;
124 let created = take(&mut map, "created")?;
125 let kernel_source = take(&mut map, "kernel-source")?;
126 let distro_source = take(&mut map, "distro-source")?;
127
128 Ok(ManifestFrontmatter {
129 volume,
130 distro,
131 distro_version,
132 created,
133 kernel_source,
134 distro_source,
135 })
136}
137
138fn extract_frontmatter_block(md: &str) -> Result<String, Error> {
147 let mut lines = md.lines();
148
149 match lines.next() {
150 Some("---") => {}
151 _ => return Err(Error::MissingFrontmatter),
152 }
153
154 let mut body = String::new();
155 let mut closed = false;
156 for line in lines {
157 if line == "---" {
158 closed = true;
159 break;
160 }
161 body.push_str(line);
162 body.push('\n');
163 }
164
165 if !closed {
166 return Err(Error::MissingFrontmatter);
167 }
168
169 Ok(body)
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 fn sample_vars() -> Vars {
177 Vars {
178 volume: "my-app".to_string(),
179 distro: "omne-faber".to_string(),
180 distro_version: "1.0.0".to_string(),
181 created: "2026-04-09".to_string(),
182 kernel_source: "omne-org/omne".to_string(),
183 distro_source: "omne-org/omne-faber".to_string(),
184 }
185 }
186
187 #[test]
190 fn template_contains_all_placeholders() {
191 for placeholder in [
192 "{{volume}}",
193 "{{distro}}",
194 "{{distro-version}}",
195 "{{created}}",
196 "{{kernel-source}}",
197 "{{distro-source}}",
198 ] {
199 assert!(
200 TEMPLATE.contains(placeholder),
201 "template must contain {placeholder}, template was:\n{TEMPLATE}",
202 );
203 }
204 }
205
206 #[test]
209 fn stamp_replaces_all_placeholders() {
210 let out = stamp(&sample_vars());
211 assert!(
212 !out.contains("{{"),
213 "stamped output still contains `{{{{`:\n{out}",
214 );
215 assert!(
216 !out.contains("}}"),
217 "stamped output still contains `}}}}`:\n{out}",
218 );
219 }
220
221 #[test]
222 fn stamp_contains_volume_name() {
223 let out = stamp(&sample_vars());
224 assert!(out.contains("my-app"), "missing volume name:\n{out}");
225 }
226
227 #[test]
228 fn stamp_contains_distro_name() {
229 let out = stamp(&sample_vars());
230 assert!(out.contains("omne-faber"), "missing distro name:\n{out}");
231 }
232
233 #[test]
234 fn stamp_contains_distro_version() {
235 let out = stamp(&sample_vars());
236 assert!(out.contains("1.0.0"), "missing distro version:\n{out}");
237 }
238
239 #[test]
240 fn stamp_contains_created_date() {
241 let out = stamp(&sample_vars());
242 assert!(out.contains("2026-04-09"), "missing created date:\n{out}");
243 }
244
245 #[test]
246 fn stamp_contains_kernel_source() {
247 let out = stamp(&sample_vars());
249 assert!(
250 out.contains("kernel-source: omne-org/omne"),
251 "missing `kernel-source` frontmatter line:\n{out}",
252 );
253 }
254
255 #[test]
256 fn stamp_contains_distro_source() {
257 let out = stamp(&sample_vars());
259 assert!(
260 out.contains("distro-source: omne-org/omne-faber"),
261 "missing `distro-source` frontmatter line:\n{out}",
262 );
263 }
264
265 #[test]
266 fn stamp_output_starts_with_yaml_fence() {
267 let out = stamp(&sample_vars());
268 assert!(
269 out.starts_with("---\n") || out.starts_with("---\r\n"),
270 "stamped output should begin with `---` fence, got: {:?}",
271 &out.chars().take(10).collect::<String>(),
272 );
273 }
274
275 #[test]
278 fn parse_frontmatter_round_trips_with_stamp() {
279 let vars = sample_vars();
280 let stamped = stamp(&vars);
281 let parsed =
282 parse_frontmatter(&stamped).expect("parsing a freshly-stamped manifest should succeed");
283
284 assert_eq!(parsed.volume, vars.volume);
285 assert_eq!(parsed.distro, vars.distro);
286 assert_eq!(parsed.distro_version, vars.distro_version);
287 assert_eq!(parsed.created, vars.created);
288 assert_eq!(parsed.kernel_source, vars.kernel_source);
289 assert_eq!(parsed.distro_source, vars.distro_source);
290 }
291
292 #[test]
293 fn parse_frontmatter_errors_on_no_fences() {
294 let md = "# MANIFEST\n\nNo frontmatter here.\n";
295 match parse_frontmatter(md) {
296 Err(Error::MissingFrontmatter) => {}
297 other => panic!("expected MissingFrontmatter, got {other:?}"),
298 }
299 }
300
301 #[test]
302 fn parse_frontmatter_errors_on_unclosed_fence() {
303 let md = "---\nvolume: x\ndistro: y\n\n# body without closing fence\n";
304 match parse_frontmatter(md) {
305 Err(Error::MissingFrontmatter) => {}
306 other => panic!("expected MissingFrontmatter on unclosed fence, got {other:?}"),
307 }
308 }
309
310 #[test]
311 fn parse_frontmatter_errors_on_missing_kernel_source() {
312 let md = "---\n\
316 volume: my-app\n\
317 distro: omne-faber\n\
318 distro-version: 1.0.0\n\
319 created: 2026-04-09\n\
320 distro-source: omne-org/omne-faber\n\
321 ---\n\
322 \n\
323 # body\n";
324 match parse_frontmatter(md) {
325 Err(Error::MissingField { field }) => {
326 assert_eq!(field, "kernel-source");
327 }
328 other => panic!("expected MissingField kernel-source, got {other:?}"),
329 }
330 }
331
332 #[test]
333 fn parse_frontmatter_errors_on_missing_distro_source() {
334 let md = "---\n\
335 volume: my-app\n\
336 distro: omne-faber\n\
337 distro-version: 1.0.0\n\
338 created: 2026-04-09\n\
339 kernel-source: omne-org/omne\n\
340 ---\n\
341 \n\
342 # body\n";
343 match parse_frontmatter(md) {
344 Err(Error::MissingField { field }) => {
345 assert_eq!(field, "distro-source");
346 }
347 other => panic!("expected MissingField distro-source, got {other:?}"),
348 }
349 }
350}