isoloom_spec/reference/
markdown.rs1use std::fmt::{self, Display, Formatter};
5
6use super::{Field, fields};
7
8const INTRO: &str = "# Spec reference
10
11<!-- Generated by `isoloom docs --spec`. Edit the model's doc comments (the schema), then regenerate. -->
12
13Every field of `isoloom.yml`, read from its JSON schema (`isoloom schema`, which editors use for
14completion and hover). In a path, `*` stands for a name you choose (a network, a machine) and `[]`
15for each item of a list.
16";
17
18#[must_use]
20pub fn markdown() -> String {
21 Page(&fields(&crate::schema::schema())).to_string()
22}
23
24struct Page<'a>(&'a [Field]);
26
27impl Display for Page<'_> {
28 fn fmt(&self, md: &mut Formatter<'_>) -> fmt::Result {
29 let sections = sections(self.0);
30 md.write_str(INTRO)?;
31 md.write_str("\n## Sections\n\n")?;
32 for (title, _) in §ions {
33 writeln!(md, "- [{}](#{})", heading(title), slug(&heading(title)))?;
34 }
35 for (title, members) in §ions {
36 write!(md, "\n## {}\n", heading(title))?;
37 for f in members {
38 entry(md, f)?;
39 }
40 }
41 Ok(())
42 }
43}
44
45fn heading(section: &str) -> String {
47 if section.is_empty() { "Top level".to_owned() } else { format!("`{section}`") }
48}
49
50fn sections(all: &[Field]) -> Vec<(String, Vec<&Field>)> {
54 let mut out: Vec<(String, Vec<&Field>)> = vec![(String::new(), Vec::new())];
55 for f in all {
56 let key = section_of(&f.path, all);
57 match out.iter_mut().find(|(k, _)| *k == key) {
58 Some((_, members)) => members.push(f),
59 None => out.push((key, vec![f])),
60 }
61 }
62 out.retain(|(_, members)| !members.is_empty());
63 out
64}
65
66fn section_of(path: &str, all: &[Field]) -> String {
68 for part in ["docker", "vm", "external"] {
69 let sub = format!("machines.*.{part}");
70 if path == sub || path.starts_with(&format!("{sub}.")) {
71 return sub;
72 }
73 }
74 let top = path.split(['.', '[']).next().unwrap_or(path);
75 let nested = all.iter().any(|f| is_inside(&f.path, top));
76 if nested { top.to_owned() } else { String::new() }
77}
78
79fn is_inside(path: &str, parent: &str) -> bool {
81 path.strip_prefix(parent).is_some_and(|rest| rest.starts_with('.') || rest.starts_with('['))
82}
83
84fn entry(md: &mut Formatter<'_>, f: &Field) -> fmt::Result {
86 write!(md, "\n### `{}`\n\n", f.path)?;
87 if !f.description.is_empty() {
88 write!(md, "{}\n\n", f.description)?;
89 }
90 writeln!(md, "- **Type:** {}", f.kind)?;
91 if let Some(d) = &f.default {
92 writeln!(md, "- **Default:** `{d}`")?;
93 }
94 allowed(md, &f.allowed)?;
95 if !f.limits.is_empty() {
96 writeln!(md, "- **Limits:** {}", f.limits.join("; "))?;
97 }
98 if !f.members.is_empty() {
99 let names: Vec<String> = f.members.iter().map(|m| format!("`{m}`")).collect();
100 writeln!(md, "- **Holds:** {}, each as on a machine", names.join(", "))?;
101 }
102 for e in &f.examples {
103 writeln!(md, "- **Example:** `{e}`")?;
104 }
105 Ok(())
106}
107
108fn allowed(md: &mut Formatter<'_>, values: &[(String, String)]) -> fmt::Result {
110 if values.is_empty() {
111 return Ok(());
112 }
113 if values.iter().all(|(_, meaning)| meaning.is_empty()) {
114 let list: Vec<String> = values.iter().map(|(v, _)| format!("`{v}`")).collect();
115 return writeln!(md, "- **Allowed:** {}", list.join(", "));
116 }
117 md.write_str("- **Allowed:**\n")?;
118 for (v, meaning) in values {
119 writeln!(md, " - `{v}`: {}", meaning.replace('\n', " "))?;
120 }
121 Ok(())
122}
123
124#[must_use]
126pub fn slug(heading: &str) -> String {
127 heading
128 .to_lowercase()
129 .chars()
130 .filter_map(|c| match c {
131 ' ' => Some('-'),
132 c if c.is_alphanumeric() || c == '-' || c == '_' => Some(c),
133 _ => None,
134 })
135 .collect()
136}
137
138#[cfg(test)]
139mod tests {
140 use serde_json::json;
141
142 use super::*;
143
144 #[test]
145 fn a_field_with_fields_of_its_own_and_each_machine_implementation_open_a_section() {
146 let all = fields(&json!({
147 "properties": {
148 "version": { "type": "integer" },
149 "versions": { "type": "array", "items": { "type": "integer" } },
150 "machines": {
151 "type": "object",
152 "additionalProperties": {
153 "properties": { "os": { "type": "string" }, "docker": { "properties": { "image": { "type": "string" } } } }
154 }
155 }
156 }
157 }));
158 let read: Vec<(String, Vec<&str>)> = sections(&all)
159 .into_iter()
160 .map(|(title, members)| (title, members.iter().map(|f| f.path.as_str()).collect()))
161 .collect();
162 assert_eq!(
163 read,
164 [
165 (String::new(), vec!["version", "versions"]),
166 ("machines".to_owned(), vec!["machines", "machines.*.os"]),
167 ("machines.*.docker".to_owned(), vec!["machines.*.docker", "machines.*.docker.image"]),
168 ],
169 "`versions` isn't inside `version`"
170 );
171 }
172}