use std::fmt::{self, Display, Formatter};
use super::{Field, fields};
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.
";
#[must_use]
pub fn markdown() -> String {
Page(&fields(&crate::schema::schema())).to_string()
}
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 §ions {
writeln!(md, "- [{}](#{})", heading(title), slug(&heading(title)))?;
}
for (title, members) in §ions {
write!(md, "\n## {}\n", heading(title))?;
for f in members {
entry(md, f)?;
}
}
Ok(())
}
}
fn heading(section: &str) -> String {
if section.is_empty() { "Top level".to_owned() } else { format!("`{section}`") }
}
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
}
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() }
}
fn is_inside(path: &str, parent: &str) -> bool {
path.strip_prefix(parent).is_some_and(|rest| rest.starts_with('.') || rest.starts_with('['))
}
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(())
}
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(())
}
#[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`"
);
}
}