omne-cli 0.2.1

CLI for managing omne volumes: init, upgrade, and validate kernel and distro releases
Documentation
//! `.omne/omne.md` README template embedding, stamping, and
//! frontmatter parsing.
//!
//! The template lives at `omne-cli/templates/omne-readme-template.md`
//! and is embedded into the binary at compile time via `include_str!`,
//! so the shipped binary carries no companion files. The v2 design
//! demoted the old `MANIFEST.md` bootloader hop to a non-loaded README
//! at `.omne/omne.md`; this module stamps that file. Stamping is a
//! pure placeholder-replacement operation. Parsing splits the file on
//! `---` frontmatter fences, extracts the YAML block, deserializes
//! via `serde_yml`, and returns a strongly-typed
//! `ManifestFrontmatter` struct. Missing required fields surface as
//! `Error::MissingField { field }` rather than generic YAML errors so
//! `upgrade` can print an actionable remediation hint.

// Items below are first used when Unit 8a wires this module into
// `init::run` for manifest stamping. Until then, only the inline test
// module constructs them. Silencing the dead-code lint at the module
// level keeps the Unit 2 commit free of incidental `#[allow]` attributes.
#![allow(dead_code)]

use std::collections::BTreeMap;

use serde::Deserialize;
use thiserror::Error;

/// The `.omne/omne.md` README template at
/// `omne-cli/templates/omne-readme-template.md`, embedded at compile
/// time so the published binary is fully self-contained. Kept at
/// module scope as a `const` so both `stamp()` and the test module
/// can reference it.
pub const TEMPLATE: &str = include_str!("../templates/omne-readme-template.md");

/// Every `{{placeholder}}` in the template must be represented by a field
/// on this struct. The frontmatter block in
/// `templates/manifest-template.md` is the source of truth for which
/// placeholders exist; the test `template_contains_all_placeholders`
/// guards that contract.
#[derive(Debug, Clone)]
pub struct Vars {
    pub volume: String,
    pub distro: String,
    pub distro_version: String,
    pub created: String,
    pub kernel_source: String,
    pub distro_source: String,
}

/// Strongly-typed view of MANIFEST.md's YAML frontmatter. Mirrors the
/// fields produced by `stamp()` so that `parse_frontmatter(stamp(&vars))`
/// round-trips cleanly. The `PartialEq` derive enables round-trip
/// assertions in tests.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ManifestFrontmatter {
    pub volume: String,
    pub distro: String,
    #[serde(rename = "distro-version")]
    pub distro_version: String,
    pub created: String,
    #[serde(rename = "kernel-source")]
    pub kernel_source: String,
    #[serde(rename = "distro-source")]
    pub distro_source: String,
}

/// Errors produced by this module. Wrapped at the top level via a
/// `CliError::Manifest(#[from] manifest::Error)` variant which lands
/// when Unit 9's `upgrade` command first needs it.
#[derive(Debug, Error)]
pub enum Error {
    #[error(".omne/omne.md is missing its `---...---` YAML frontmatter fences")]
    MissingFrontmatter,

    #[error(".omne/omne.md frontmatter is missing required field: {field}")]
    MissingField { field: String },

    #[error(".omne/omne.md frontmatter is not valid YAML: {0}")]
    Yaml(#[from] serde_yml::Error),

    #[error("invalid source format '{value}' in .omne/omne.md — expected org/repo")]
    InvalidSourceFormat { value: String },
}

/// Render the embedded template with the provided variables.
///
/// Uses `str::replace` per placeholder — identical semantics to the
/// Python implementation in `cli/lib/manifest.py`. Any `{{...}}` in the
/// template without a matching field on `Vars` is left untouched, which
/// is the behavior tested by `stamp_replaces_all_placeholders`.
pub fn stamp(vars: &Vars) -> String {
    TEMPLATE
        .replace("{{volume}}", &vars.volume)
        .replace("{{distro}}", &vars.distro)
        .replace("{{distro-version}}", &vars.distro_version)
        .replace("{{created}}", &vars.created)
        .replace("{{kernel-source}}", &vars.kernel_source)
        .replace("{{distro-source}}", &vars.distro_source)
}

/// Parse the `---...---` YAML frontmatter at the top of a MANIFEST.md
/// document into a strongly-typed struct.
///
/// Returns `Error::MissingFrontmatter` if the document does not begin
/// with a `---` line or the closing fence is absent, and
/// `Error::MissingField { field }` if the YAML parses but is missing
/// one of the six required keys (`volume`, `distro`, `distro-version`,
/// `created`, `kernel-source`, `distro-source`).
pub fn parse_frontmatter(md: &str) -> Result<ManifestFrontmatter, Error> {
    let yaml_body = extract_frontmatter_block(md)?;

    // Deserialize into a loose map first. This lets us produce
    // per-field `MissingField` errors rather than the opaque YAML
    // error serde would emit if we went directly to the struct —
    // the command layer (Unit 9 upgrade) wants to name the missing
    // field in its remediation hint.
    let mut map: BTreeMap<String, String> = serde_yml::from_str(&yaml_body)?;

    fn take(map: &mut BTreeMap<String, String>, key: &str) -> Result<String, Error> {
        map.remove(key).ok_or_else(|| Error::MissingField {
            field: key.to_string(),
        })
    }

    // Extract in the same order as the struct field declaration so
    // that errors fire deterministically for the missing-field tests.
    let volume = take(&mut map, "volume")?;
    let distro = take(&mut map, "distro")?;
    let distro_version = take(&mut map, "distro-version")?;
    let created = take(&mut map, "created")?;
    let kernel_source = take(&mut map, "kernel-source")?;
    let distro_source = take(&mut map, "distro-source")?;

    Ok(ManifestFrontmatter {
        volume,
        distro,
        distro_version,
        created,
        kernel_source,
        distro_source,
    })
}

/// Extract the YAML block between the opening and closing `---` fences.
///
/// `str::lines()` splits on both `\n` and `\r\n`, so the returned body
/// is stripped of line-ending variation. A document with no opening
/// fence, no closing fence, or an opening-but-not-closing fence all
/// return `Error::MissingFrontmatter` — the caller cannot distinguish
/// these cases from the error alone, which matches the Python validator's
/// "missing frontmatter" grain of error reporting.
///
/// Exposed `pub(crate)` so `commands::validate` can reuse the same
/// fence-parsing logic instead of carrying a parallel copy that would
/// drift on edge cases (CRLF endings, BOM, trailing whitespace).
pub(crate) fn extract_frontmatter_block(md: &str) -> Result<String, Error> {
    let mut lines = md.lines();

    match lines.next() {
        Some("---") => {}
        _ => return Err(Error::MissingFrontmatter),
    }

    let mut body = String::new();
    let mut closed = false;
    for line in lines {
        if line == "---" {
            closed = true;
            break;
        }
        body.push_str(line);
        body.push('\n');
    }

    if !closed {
        return Err(Error::MissingFrontmatter);
    }

    Ok(body)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample_vars() -> Vars {
        Vars {
            volume: "my-app".to_string(),
            distro: "omne-faber".to_string(),
            distro_version: "1.0.0".to_string(),
            created: "2026-04-09".to_string(),
            kernel_source: "omne-org/omne".to_string(),
            distro_source: "omne-org/omne-faber".to_string(),
        }
    }

    // ----- Template contract (Unit 2 R1, R14) -----

    #[test]
    fn template_contains_all_placeholders() {
        for placeholder in [
            "{{volume}}",
            "{{distro}}",
            "{{distro-version}}",
            "{{created}}",
            "{{kernel-source}}",
            "{{distro-source}}",
        ] {
            assert!(
                TEMPLATE.contains(placeholder),
                "template must contain {placeholder}, template was:\n{TEMPLATE}",
            );
        }
    }

    // ----- stamp() -----

    #[test]
    fn stamp_replaces_all_placeholders() {
        let out = stamp(&sample_vars());
        assert!(
            !out.contains("{{"),
            "stamped output still contains `{{{{`:\n{out}",
        );
        assert!(
            !out.contains("}}"),
            "stamped output still contains `}}}}`:\n{out}",
        );
    }

    #[test]
    fn stamp_contains_volume_name() {
        let out = stamp(&sample_vars());
        assert!(out.contains("my-app"), "missing volume name:\n{out}");
    }

    #[test]
    fn stamp_contains_distro_name() {
        let out = stamp(&sample_vars());
        assert!(out.contains("omne-faber"), "missing distro name:\n{out}");
    }

    #[test]
    fn stamp_contains_distro_version() {
        let out = stamp(&sample_vars());
        assert!(out.contains("1.0.0"), "missing distro version:\n{out}");
    }

    #[test]
    fn stamp_contains_created_date() {
        let out = stamp(&sample_vars());
        assert!(out.contains("2026-04-09"), "missing created date:\n{out}");
    }

    #[test]
    fn stamp_contains_kernel_source() {
        // R1: kernel-source is a new required frontmatter field.
        let out = stamp(&sample_vars());
        assert!(
            out.contains("kernel-source: omne-org/omne"),
            "missing `kernel-source` frontmatter line:\n{out}",
        );
    }

    #[test]
    fn stamp_contains_distro_source() {
        // R1: distro-source is a new required frontmatter field.
        let out = stamp(&sample_vars());
        assert!(
            out.contains("distro-source: omne-org/omne-faber"),
            "missing `distro-source` frontmatter line:\n{out}",
        );
    }

    #[test]
    fn stamp_output_starts_with_yaml_fence() {
        let out = stamp(&sample_vars());
        assert!(
            out.starts_with("---\n") || out.starts_with("---\r\n"),
            "stamped output should begin with `---` fence, got: {:?}",
            &out.chars().take(10).collect::<String>(),
        );
    }

    // ----- parse_frontmatter() -----

    #[test]
    fn parse_frontmatter_round_trips_with_stamp() {
        let vars = sample_vars();
        let stamped = stamp(&vars);
        let parsed =
            parse_frontmatter(&stamped).expect("parsing a freshly-stamped manifest should succeed");

        assert_eq!(parsed.volume, vars.volume);
        assert_eq!(parsed.distro, vars.distro);
        assert_eq!(parsed.distro_version, vars.distro_version);
        assert_eq!(parsed.created, vars.created);
        assert_eq!(parsed.kernel_source, vars.kernel_source);
        assert_eq!(parsed.distro_source, vars.distro_source);
    }

    #[test]
    fn parse_frontmatter_errors_on_no_fences() {
        let md = "# MANIFEST\n\nNo frontmatter here.\n";
        match parse_frontmatter(md) {
            Err(Error::MissingFrontmatter) => {}
            other => panic!("expected MissingFrontmatter, got {other:?}"),
        }
    }

    #[test]
    fn parse_frontmatter_errors_on_unclosed_fence() {
        let md = "---\nvolume: x\ndistro: y\n\n# body without closing fence\n";
        match parse_frontmatter(md) {
            Err(Error::MissingFrontmatter) => {}
            other => panic!("expected MissingFrontmatter on unclosed fence, got {other:?}"),
        }
    }

    #[test]
    fn parse_frontmatter_errors_on_missing_kernel_source() {
        // Valid fences, valid YAML, but missing the new `kernel-source`
        // field that R1 mandates. The caller (Unit 9 upgrade) will
        // surface this as an actionable re-init message.
        let md = "---\n\
                  volume: my-app\n\
                  distro: omne-faber\n\
                  distro-version: 1.0.0\n\
                  created: 2026-04-09\n\
                  distro-source: omne-org/omne-faber\n\
                  ---\n\
                  \n\
                  # body\n";
        match parse_frontmatter(md) {
            Err(Error::MissingField { field }) => {
                assert_eq!(field, "kernel-source");
            }
            other => panic!("expected MissingField kernel-source, got {other:?}"),
        }
    }

    #[test]
    fn parse_frontmatter_errors_on_missing_distro_source() {
        let md = "---\n\
                  volume: my-app\n\
                  distro: omne-faber\n\
                  distro-version: 1.0.0\n\
                  created: 2026-04-09\n\
                  kernel-source: omne-org/omne\n\
                  ---\n\
                  \n\
                  # body\n";
        match parse_frontmatter(md) {
            Err(Error::MissingField { field }) => {
                assert_eq!(field, "distro-source");
            }
            other => panic!("expected MissingField distro-source, got {other:?}"),
        }
    }
}