Skip to main content

omne_cli/
manifest.rs

1//! `.omne/omne.md` README template embedding, stamping, and
2//! frontmatter parsing.
3//!
4//! The template lives at `omne-cli/templates/omne-readme-template.md`
5//! and is embedded into the binary at compile time via `include_str!`,
6//! so the shipped binary carries no companion files. The v2 design
7//! demoted the old `MANIFEST.md` bootloader hop to a non-loaded README
8//! at `.omne/omne.md`; this module stamps that file. Stamping is a
9//! pure placeholder-replacement operation. Parsing splits the file on
10//! `---` frontmatter fences, extracts the YAML block, deserializes
11//! via `serde_yml`, and returns a strongly-typed
12//! `ManifestFrontmatter` struct. Missing required fields surface as
13//! `Error::MissingField { field }` rather than generic YAML errors so
14//! `upgrade` can print an actionable remediation hint.
15
16// Items below are first used when Unit 8a wires this module into
17// `init::run` for manifest stamping. Until then, only the inline test
18// module constructs them. Silencing the dead-code lint at the module
19// level keeps the Unit 2 commit free of incidental `#[allow]` attributes.
20#![allow(dead_code)]
21
22use std::collections::BTreeMap;
23
24use serde::Deserialize;
25use thiserror::Error;
26
27/// The `.omne/omne.md` README template at
28/// `omne-cli/templates/omne-readme-template.md`, embedded at compile
29/// time so the published binary is fully self-contained. Kept at
30/// module scope as a `const` so both `stamp()` and the test module
31/// can reference it.
32pub const TEMPLATE: &str = include_str!("../templates/omne-readme-template.md");
33
34/// Every `{{placeholder}}` in the template must be represented by a field
35/// on this struct. The frontmatter block in
36/// `templates/manifest-template.md` is the source of truth for which
37/// placeholders exist; the test `template_contains_all_placeholders`
38/// guards that contract.
39#[derive(Debug, Clone)]
40pub struct Vars {
41    pub volume: String,
42    pub distro: String,
43    pub distro_version: String,
44    pub created: String,
45    pub kernel_source: String,
46    pub distro_source: String,
47}
48
49/// Strongly-typed view of MANIFEST.md's YAML frontmatter. Mirrors the
50/// fields produced by `stamp()` so that `parse_frontmatter(stamp(&vars))`
51/// round-trips cleanly. The `PartialEq` derive enables round-trip
52/// assertions in tests.
53#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
54pub struct ManifestFrontmatter {
55    pub volume: String,
56    pub distro: String,
57    #[serde(rename = "distro-version")]
58    pub distro_version: String,
59    pub created: String,
60    #[serde(rename = "kernel-source")]
61    pub kernel_source: String,
62    #[serde(rename = "distro-source")]
63    pub distro_source: String,
64}
65
66/// Errors produced by this module. Wrapped at the top level via a
67/// `CliError::Manifest(#[from] manifest::Error)` variant which lands
68/// when Unit 9's `upgrade` command first needs it.
69#[derive(Debug, Error)]
70pub enum Error {
71    #[error(".omne/omne.md is missing its `---...---` YAML frontmatter fences")]
72    MissingFrontmatter,
73
74    #[error(".omne/omne.md frontmatter is missing required field: {field}")]
75    MissingField { field: String },
76
77    #[error(".omne/omne.md frontmatter is not valid YAML: {0}")]
78    Yaml(#[from] serde_yml::Error),
79
80    #[error("invalid source format '{value}' in .omne/omne.md — expected org/repo")]
81    InvalidSourceFormat { value: String },
82}
83
84/// Render the embedded template with the provided variables.
85///
86/// Uses `str::replace` per placeholder — identical semantics to the
87/// Python implementation in `cli/lib/manifest.py`. Any `{{...}}` in the
88/// template without a matching field on `Vars` is left untouched, which
89/// is the behavior tested by `stamp_replaces_all_placeholders`.
90pub fn stamp(vars: &Vars) -> String {
91    TEMPLATE
92        .replace("{{volume}}", &vars.volume)
93        .replace("{{distro}}", &vars.distro)
94        .replace("{{distro-version}}", &vars.distro_version)
95        .replace("{{created}}", &vars.created)
96        .replace("{{kernel-source}}", &vars.kernel_source)
97        .replace("{{distro-source}}", &vars.distro_source)
98}
99
100/// Parse the `---...---` YAML frontmatter at the top of a MANIFEST.md
101/// document into a strongly-typed struct.
102///
103/// Returns `Error::MissingFrontmatter` if the document does not begin
104/// with a `---` line or the closing fence is absent, and
105/// `Error::MissingField { field }` if the YAML parses but is missing
106/// one of the six required keys (`volume`, `distro`, `distro-version`,
107/// `created`, `kernel-source`, `distro-source`).
108pub fn parse_frontmatter(md: &str) -> Result<ManifestFrontmatter, Error> {
109    let yaml_body = extract_frontmatter_block(md)?;
110
111    // Deserialize into a loose map first. This lets us produce
112    // per-field `MissingField` errors rather than the opaque YAML
113    // error serde would emit if we went directly to the struct —
114    // the command layer (Unit 9 upgrade) wants to name the missing
115    // field in its remediation hint.
116    let mut map: BTreeMap<String, String> = serde_yml::from_str(&yaml_body)?;
117
118    fn take(map: &mut BTreeMap<String, String>, key: &str) -> Result<String, Error> {
119        map.remove(key).ok_or_else(|| Error::MissingField {
120            field: key.to_string(),
121        })
122    }
123
124    // Extract in the same order as the struct field declaration so
125    // that errors fire deterministically for the missing-field tests.
126    let volume = take(&mut map, "volume")?;
127    let distro = take(&mut map, "distro")?;
128    let distro_version = take(&mut map, "distro-version")?;
129    let created = take(&mut map, "created")?;
130    let kernel_source = take(&mut map, "kernel-source")?;
131    let distro_source = take(&mut map, "distro-source")?;
132
133    Ok(ManifestFrontmatter {
134        volume,
135        distro,
136        distro_version,
137        created,
138        kernel_source,
139        distro_source,
140    })
141}
142
143/// Extract the YAML block between the opening and closing `---` fences.
144///
145/// `str::lines()` splits on both `\n` and `\r\n`, so the returned body
146/// is stripped of line-ending variation. A document with no opening
147/// fence, no closing fence, or an opening-but-not-closing fence all
148/// return `Error::MissingFrontmatter` — the caller cannot distinguish
149/// these cases from the error alone, which matches the Python validator's
150/// "missing frontmatter" grain of error reporting.
151///
152/// Exposed `pub(crate)` so `commands::validate` can reuse the same
153/// fence-parsing logic instead of carrying a parallel copy that would
154/// drift on edge cases (CRLF endings, BOM, trailing whitespace).
155pub(crate) fn extract_frontmatter_block(md: &str) -> Result<String, Error> {
156    let mut lines = md.lines();
157
158    match lines.next() {
159        Some("---") => {}
160        _ => return Err(Error::MissingFrontmatter),
161    }
162
163    let mut body = String::new();
164    let mut closed = false;
165    for line in lines {
166        if line == "---" {
167            closed = true;
168            break;
169        }
170        body.push_str(line);
171        body.push('\n');
172    }
173
174    if !closed {
175        return Err(Error::MissingFrontmatter);
176    }
177
178    Ok(body)
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    fn sample_vars() -> Vars {
186        Vars {
187            volume: "my-app".to_string(),
188            distro: "omne-faber".to_string(),
189            distro_version: "1.0.0".to_string(),
190            created: "2026-04-09".to_string(),
191            kernel_source: "omne-org/omne".to_string(),
192            distro_source: "omne-org/omne-faber".to_string(),
193        }
194    }
195
196    // ----- Template contract (Unit 2 R1, R14) -----
197
198    #[test]
199    fn template_contains_all_placeholders() {
200        for placeholder in [
201            "{{volume}}",
202            "{{distro}}",
203            "{{distro-version}}",
204            "{{created}}",
205            "{{kernel-source}}",
206            "{{distro-source}}",
207        ] {
208            assert!(
209                TEMPLATE.contains(placeholder),
210                "template must contain {placeholder}, template was:\n{TEMPLATE}",
211            );
212        }
213    }
214
215    // ----- stamp() -----
216
217    #[test]
218    fn stamp_replaces_all_placeholders() {
219        let out = stamp(&sample_vars());
220        assert!(
221            !out.contains("{{"),
222            "stamped output still contains `{{{{`:\n{out}",
223        );
224        assert!(
225            !out.contains("}}"),
226            "stamped output still contains `}}}}`:\n{out}",
227        );
228    }
229
230    #[test]
231    fn stamp_contains_volume_name() {
232        let out = stamp(&sample_vars());
233        assert!(out.contains("my-app"), "missing volume name:\n{out}");
234    }
235
236    #[test]
237    fn stamp_contains_distro_name() {
238        let out = stamp(&sample_vars());
239        assert!(out.contains("omne-faber"), "missing distro name:\n{out}");
240    }
241
242    #[test]
243    fn stamp_contains_distro_version() {
244        let out = stamp(&sample_vars());
245        assert!(out.contains("1.0.0"), "missing distro version:\n{out}");
246    }
247
248    #[test]
249    fn stamp_contains_created_date() {
250        let out = stamp(&sample_vars());
251        assert!(out.contains("2026-04-09"), "missing created date:\n{out}");
252    }
253
254    #[test]
255    fn stamp_contains_kernel_source() {
256        // R1: kernel-source is a new required frontmatter field.
257        let out = stamp(&sample_vars());
258        assert!(
259            out.contains("kernel-source: omne-org/omne"),
260            "missing `kernel-source` frontmatter line:\n{out}",
261        );
262    }
263
264    #[test]
265    fn stamp_contains_distro_source() {
266        // R1: distro-source is a new required frontmatter field.
267        let out = stamp(&sample_vars());
268        assert!(
269            out.contains("distro-source: omne-org/omne-faber"),
270            "missing `distro-source` frontmatter line:\n{out}",
271        );
272    }
273
274    #[test]
275    fn stamp_output_starts_with_yaml_fence() {
276        let out = stamp(&sample_vars());
277        assert!(
278            out.starts_with("---\n") || out.starts_with("---\r\n"),
279            "stamped output should begin with `---` fence, got: {:?}",
280            &out.chars().take(10).collect::<String>(),
281        );
282    }
283
284    // ----- parse_frontmatter() -----
285
286    #[test]
287    fn parse_frontmatter_round_trips_with_stamp() {
288        let vars = sample_vars();
289        let stamped = stamp(&vars);
290        let parsed =
291            parse_frontmatter(&stamped).expect("parsing a freshly-stamped manifest should succeed");
292
293        assert_eq!(parsed.volume, vars.volume);
294        assert_eq!(parsed.distro, vars.distro);
295        assert_eq!(parsed.distro_version, vars.distro_version);
296        assert_eq!(parsed.created, vars.created);
297        assert_eq!(parsed.kernel_source, vars.kernel_source);
298        assert_eq!(parsed.distro_source, vars.distro_source);
299    }
300
301    #[test]
302    fn parse_frontmatter_errors_on_no_fences() {
303        let md = "# MANIFEST\n\nNo frontmatter here.\n";
304        match parse_frontmatter(md) {
305            Err(Error::MissingFrontmatter) => {}
306            other => panic!("expected MissingFrontmatter, got {other:?}"),
307        }
308    }
309
310    #[test]
311    fn parse_frontmatter_errors_on_unclosed_fence() {
312        let md = "---\nvolume: x\ndistro: y\n\n# body without closing fence\n";
313        match parse_frontmatter(md) {
314            Err(Error::MissingFrontmatter) => {}
315            other => panic!("expected MissingFrontmatter on unclosed fence, got {other:?}"),
316        }
317    }
318
319    #[test]
320    fn parse_frontmatter_errors_on_missing_kernel_source() {
321        // Valid fences, valid YAML, but missing the new `kernel-source`
322        // field that R1 mandates. The caller (Unit 9 upgrade) will
323        // surface this as an actionable re-init message.
324        let md = "---\n\
325                  volume: my-app\n\
326                  distro: omne-faber\n\
327                  distro-version: 1.0.0\n\
328                  created: 2026-04-09\n\
329                  distro-source: omne-org/omne-faber\n\
330                  ---\n\
331                  \n\
332                  # body\n";
333        match parse_frontmatter(md) {
334            Err(Error::MissingField { field }) => {
335                assert_eq!(field, "kernel-source");
336            }
337            other => panic!("expected MissingField kernel-source, got {other:?}"),
338        }
339    }
340
341    #[test]
342    fn parse_frontmatter_errors_on_missing_distro_source() {
343        let md = "---\n\
344                  volume: my-app\n\
345                  distro: omne-faber\n\
346                  distro-version: 1.0.0\n\
347                  created: 2026-04-09\n\
348                  kernel-source: omne-org/omne\n\
349                  ---\n\
350                  \n\
351                  # body\n";
352        match parse_frontmatter(md) {
353            Err(Error::MissingField { field }) => {
354                assert_eq!(field, "distro-source");
355            }
356            other => panic!("expected MissingField distro-source, got {other:?}"),
357        }
358    }
359}