Skip to main content

omne_cli/
manifest.rs

1//! MANIFEST.md template embedding, stamping, and frontmatter parsing.
2//!
3//! The template lives at `omne-cli/templates/manifest-template.md` and is
4//! embedded into the binary at compile time via `include_str!`, so the
5//! shipped binary carries no companion files. Stamping is a pure
6//! placeholder-replacement operation (matching the Python implementation's
7//! `str.replace` semantics). Parsing splits the file on `---` frontmatter
8//! fences, extracts the YAML block, deserializes via `serde_yml`, and
9//! returns a strongly-typed `ManifestFrontmatter` struct. Missing required
10//! fields surface as `Error::MissingField { field }` rather than generic
11//! YAML errors so `upgrade` can print an actionable remediation hint.
12
13// Items below are first used when Unit 8a wires this module into
14// `init::run` for manifest stamping. Until then, only the inline test
15// module constructs them. Silencing the dead-code lint at the module
16// level keeps the Unit 2 commit free of incidental `#[allow]` attributes.
17#![allow(dead_code)]
18
19use std::collections::BTreeMap;
20
21use serde::Deserialize;
22use thiserror::Error;
23
24/// The manifest markdown template, embedded at compile time so the
25/// published binary is fully self-contained. Kept at module scope as a
26/// `const` so both `stamp()` and the test module can reference it.
27pub const TEMPLATE: &str = include_str!("../templates/manifest-template.md");
28
29/// Every `{{placeholder}}` in the template must be represented by a field
30/// on this struct. The frontmatter block in
31/// `templates/manifest-template.md` is the source of truth for which
32/// placeholders exist; the test `template_contains_all_placeholders`
33/// guards that contract.
34#[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/// Strongly-typed view of MANIFEST.md's YAML frontmatter. Mirrors the
45/// fields produced by `stamp()` so that `parse_frontmatter(stamp(&vars))`
46/// round-trips cleanly. The `PartialEq` derive enables round-trip
47/// assertions in tests.
48#[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/// Errors produced by this module. Wrapped at the top level via a
62/// `CliError::Manifest(#[from] manifest::Error)` variant which lands
63/// when Unit 9's `upgrade` command first needs it.
64#[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
79/// Render the embedded template with the provided variables.
80///
81/// Uses `str::replace` per placeholder — identical semantics to the
82/// Python implementation in `cli/lib/manifest.py`. Any `{{...}}` in the
83/// template without a matching field on `Vars` is left untouched, which
84/// is the behavior tested by `stamp_replaces_all_placeholders`.
85pub 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
95/// Parse the `---...---` YAML frontmatter at the top of a MANIFEST.md
96/// document into a strongly-typed struct.
97///
98/// Returns `Error::MissingFrontmatter` if the document does not begin
99/// with a `---` line or the closing fence is absent, and
100/// `Error::MissingField { field }` if the YAML parses but is missing
101/// one of the six required keys (`volume`, `distro`, `distro-version`,
102/// `created`, `kernel-source`, `distro-source`).
103pub fn parse_frontmatter(md: &str) -> Result<ManifestFrontmatter, Error> {
104    let yaml_body = extract_frontmatter_block(md)?;
105
106    // Deserialize into a loose map first. This lets us produce
107    // per-field `MissingField` errors rather than the opaque YAML
108    // error serde would emit if we went directly to the struct —
109    // the command layer (Unit 9 upgrade) wants to name the missing
110    // field in its remediation hint.
111    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    // Extract in the same order as the struct field declaration so
120    // that errors fire deterministically for the missing-field tests.
121    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
138/// Extract the YAML block between the opening and closing `---` fences.
139///
140/// `str::lines()` splits on both `\n` and `\r\n`, so the returned body
141/// is stripped of line-ending variation. A document with no opening
142/// fence, no closing fence, or an opening-but-not-closing fence all
143/// return `Error::MissingFrontmatter` — the caller cannot distinguish
144/// these cases from the error alone, which matches the Python validator's
145/// "MANIFEST.md missing frontmatter" grain of error reporting.
146fn 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    // ----- Template contract (Unit 2 R1, R14) -----
188
189    #[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    // ----- stamp() -----
207
208    #[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        // R1: kernel-source is a new required frontmatter field.
248        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        // R1: distro-source is a new required frontmatter field.
258        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    // ----- parse_frontmatter() -----
276
277    #[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        // Valid fences, valid YAML, but missing the new `kernel-source`
313        // field that R1 mandates. The caller (Unit 9 upgrade) will
314        // surface this as an actionable re-init message.
315        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}