isoloom-spec 0.11.1

The Isoloom spec: model, parsing, normalization and validation.
Documentation
//! The reference as Markdown: a section per part of the spec, an entry per field with its type,
//! default, allowed values, limits and the schema's examples.

use std::fmt::{self, Display, Formatter};

use super::{Field, fields};

/// The page's opening: what it is, where it comes from, how to read a path.
const INTRO: &str = "# Spec reference

<!-- Generated by `isoloom docs --spec`. Edit the model's doc comments (the schema), then regenerate. -->

Every field of `isoloom.yml`, read from its JSON schema (`isoloom schema`, which editors use for
completion and hover). In a path, `*` stands for a name you choose (a network, a machine) and `[]`
for each item of a list.
";

/// The whole page.
#[must_use]
pub fn markdown() -> String {
    Page(&fields(&crate::schema::schema())).to_string()
}

/// The page of every field, written section by section.
struct Page<'a>(&'a [Field]);

impl Display for Page<'_> {
    fn fmt(&self, md: &mut Formatter<'_>) -> fmt::Result {
        let sections = sections(self.0);
        md.write_str(INTRO)?;
        md.write_str("\n## Sections\n\n")?;
        for (title, _) in &sections {
            writeln!(md, "- [{}](#{})", heading(title), slug(&heading(title)))?;
        }
        for (title, members) in &sections {
            write!(md, "\n## {}\n", heading(title))?;
            for f in members {
                entry(md, f)?;
            }
        }
        Ok(())
    }
}

/// A section's heading: the part of the spec it covers.
fn heading(section: &str) -> String {
    if section.is_empty() { "Top level".to_owned() } else { format!("`{section}`") }
}

/// The fields by section, in the schema's order: a top-level field with fields of its own
/// opens a section (and a machine's `docker`, `vm` and `external` each one), the others are
/// the top level's.
fn sections(all: &[Field]) -> Vec<(String, Vec<&Field>)> {
    let mut out: Vec<(String, Vec<&Field>)> = vec![(String::new(), Vec::new())];
    for f in all {
        let key = section_of(&f.path, all);
        match out.iter_mut().find(|(k, _)| *k == key) {
            Some((_, members)) => members.push(f),
            None => out.push((key, vec![f])),
        }
    }
    out.retain(|(_, members)| !members.is_empty());
    out
}

/// The section a field belongs to.
fn section_of(path: &str, all: &[Field]) -> String {
    for part in ["docker", "vm", "external"] {
        let sub = format!("machines.*.{part}");
        if path == sub || path.starts_with(&format!("{sub}.")) {
            return sub;
        }
    }
    let top = path.split(['.', '[']).next().unwrap_or(path);
    let nested = all.iter().any(|f| is_inside(&f.path, top));
    if nested { top.to_owned() } else { String::new() }
}

/// Whether `path` is a field inside `parent`.
fn is_inside(path: &str, parent: &str) -> bool {
    path.strip_prefix(parent).is_some_and(|rest| rest.starts_with('.') || rest.starts_with('['))
}

/// One field's entry.
fn entry(md: &mut Formatter<'_>, f: &Field) -> fmt::Result {
    write!(md, "\n### `{}`\n\n", f.path)?;
    if !f.description.is_empty() {
        write!(md, "{}\n\n", f.description)?;
    }
    writeln!(md, "- **Type:** {}", f.kind)?;
    if let Some(d) = &f.default {
        writeln!(md, "- **Default:** `{d}`")?;
    }
    allowed(md, &f.allowed)?;
    if !f.limits.is_empty() {
        writeln!(md, "- **Limits:** {}", f.limits.join("; "))?;
    }
    if !f.members.is_empty() {
        let names: Vec<String> = f.members.iter().map(|m| format!("`{m}`")).collect();
        writeln!(md, "- **Holds:** {}, each as on a machine", names.join(", "))?;
    }
    for e in &f.examples {
        writeln!(md, "- **Example:** `{e}`")?;
    }
    Ok(())
}

/// The allowed values: inline when they need no explanation, else one per line.
fn allowed(md: &mut Formatter<'_>, values: &[(String, String)]) -> fmt::Result {
    if values.is_empty() {
        return Ok(());
    }
    if values.iter().all(|(_, meaning)| meaning.is_empty()) {
        let list: Vec<String> = values.iter().map(|(v, _)| format!("`{v}`")).collect();
        return writeln!(md, "- **Allowed:** {}", list.join(", "));
    }
    md.write_str("- **Allowed:**\n")?;
    for (v, meaning) in values {
        writeln!(md, "  - `{v}`: {}", meaning.replace('\n', " "))?;
    }
    Ok(())
}

/// The anchor GitHub gives a heading: lower case, spaces as `-`, other punctuation dropped.
#[must_use]
pub fn slug(heading: &str) -> String {
    heading
        .to_lowercase()
        .chars()
        .filter_map(|c| match c {
            ' ' => Some('-'),
            c if c.is_alphanumeric() || c == '-' || c == '_' => Some(c),
            _ => None,
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;

    #[test]
    fn a_field_with_fields_of_its_own_and_each_machine_implementation_open_a_section() {
        let all = fields(&json!({
            "properties": {
                "version": { "type": "integer" },
                "versions": { "type": "array", "items": { "type": "integer" } },
                "machines": {
                    "type": "object",
                    "additionalProperties": {
                        "properties": { "os": { "type": "string" }, "docker": { "properties": { "image": { "type": "string" } } } }
                    }
                }
            }
        }));
        let read: Vec<(String, Vec<&str>)> = sections(&all)
            .into_iter()
            .map(|(title, members)| (title, members.iter().map(|f| f.path.as_str()).collect()))
            .collect();
        assert_eq!(
            read,
            [
                (String::new(), vec!["version", "versions"]),
                ("machines".to_owned(), vec!["machines", "machines.*.os"]),
                ("machines.*.docker".to_owned(), vec!["machines.*.docker", "machines.*.docker.image"]),
            ],
            "`versions` isn't inside `version`"
        );
    }
}