Skip to main content

concinnity_dev/docs/
mod.rs

1// `cn docs`: write the asset reference pages under docs/assets/.
2//
3// The prose is rustdoc, serde keys, and `Default` literals, none of which
4// survive compilation, so the reference is read from the engine's own asset
5// sources each time this runs: `schema` parses them, `reference` joins the two
6// trees over the authoring registry and renders each body, `page` assembles the
7// pages. That makes this a command for a checkout of the engine, which is the
8// only place the pages are regenerated.
9//
10// The pages are committed to the repository; `committed_pages_are_current` fails
11// when they drift from the sources.
12
13mod page;
14mod reference;
15mod render;
16mod schema;
17
18use page::{AUTOGEN_MARKER, IndexEntry, render_index, render_page};
19use reference::AssetDoc;
20
21use std::collections::BTreeMap;
22use std::fs;
23use std::io;
24use std::path::{Path, PathBuf};
25
26// Where the pages land, relative to the directory given on the command line.
27const PAGES_DIR: &str = "docs/assets";
28
29// The whole reference as markdown, keyed by page file name (`Prop.md`,
30// `index.md`).
31fn pages(docs: &[AssetDoc]) -> BTreeMap<String, String> {
32    let mut out = BTreeMap::new();
33    for d in docs {
34        out.insert(
35            format!("{}.md", d.type_name),
36            render_page(&d.type_name, &d.full_doc),
37        );
38    }
39
40    let index = |reference_types: bool| -> Vec<IndexEntry> {
41        docs.iter()
42            .filter(|d| d.is_reference_type == reference_types)
43            .map(|d| IndexEntry {
44                name: d.type_name.clone(),
45                summary: d.summary.clone(),
46            })
47            .collect()
48    };
49    out.insert(
50        "index.md".to_string(),
51        render_index(&index(false), &index(true)),
52    );
53    out
54}
55
56// Write the pages under `<root>/docs/assets`, defaulting to the current
57// directory. `<root>` is also the engine checkout the prose is read from.
58// Unchanged pages are left alone, so running this on an up-to-date tree touches
59// nothing.
60/// Regenerate the asset reference pages under `docs/assets/`, read out of the
61/// engine's own schema sources.
62///
63/// `root` is the engine checkout to read from; `None` uses the working
64/// directory.
65pub fn docs(root: Option<&str>) -> io::Result<()> {
66    let engine_root = PathBuf::from(root.unwrap_or("."));
67    let pages = pages(&reference::build(&engine_root)?);
68    let dir = engine_root.join(PAGES_DIR);
69    let (written, removed) = write_pages(&dir, &pages)?;
70
71    println!(
72        "{} asset pages in {} ({written} written, {removed} removed)",
73        pages.len(),
74        dir.display()
75    );
76    Ok(())
77}
78
79// Put `pages` on disk in `dir`, pruning the generated pages no longer among
80// them. Returns how many were written and how many pruned. Unchanged pages are
81// left alone, so running this on an up-to-date tree touches nothing.
82fn write_pages(dir: &Path, pages: &BTreeMap<String, String>) -> io::Result<(usize, usize)> {
83    fs::create_dir_all(dir)?;
84
85    let mut written = 0usize;
86    for (file, content) in pages {
87        let path = dir.join(file);
88        if fs::read_to_string(&path).ok().as_deref() == Some(content.as_str()) {
89            continue;
90        }
91        fs::write(&path, content)?;
92        written += 1;
93    }
94    Ok((written, remove_stale_pages(dir, pages)?))
95}
96
97// Drop generated pages no longer in the reference (a renamed or deleted asset).
98// Only files carrying the auto-generated marker are removed, so a hand-authored
99// page dropped in the directory survives.
100fn remove_stale_pages(dir: &Path, keep: &BTreeMap<String, String>) -> io::Result<usize> {
101    let mut removed = 0;
102    for entry in fs::read_dir(dir)? {
103        let path = entry?.path();
104        if path.extension().and_then(|e| e.to_str()) != Some("md") {
105            continue;
106        }
107        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
108            continue;
109        };
110        if keep.contains_key(name) {
111            continue;
112        }
113        if fs::read_to_string(&path).is_ok_and(|s| s.starts_with(AUTOGEN_MARKER)) {
114            fs::remove_file(&path)?;
115            removed += 1;
116        }
117    }
118    Ok(removed)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    // The repository root, two levels above this crate: the engine checkout the
126    // reference is read out of.
127    fn repo_root() -> PathBuf {
128        Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
129    }
130
131    fn reference_pages() -> BTreeMap<String, String> {
132        pages(&reference::build(&repo_root()).expect("read the asset sources"))
133    }
134
135    // The committed pages are what the asset sources render to. A failure means
136    // an asset's rustdoc or args changed without a `cn docs` run.
137    #[test]
138    fn committed_pages_are_current() {
139        let dir = repo_root().join(PAGES_DIR);
140        for (file, expected) in &reference_pages() {
141            let path = dir.join(file);
142            let on_disk = fs::read_to_string(&path)
143                .unwrap_or_else(|e| panic!("read {}: {e}; run `cn docs`", path.display()));
144            assert_eq!(
145                &on_disk, expected,
146                "{PAGES_DIR}/{file} is out of date; run `cn docs`"
147            );
148        }
149    }
150
151    // No generated page lingers for a type the reference no longer covers.
152    #[test]
153    fn no_generated_page_is_orphaned() {
154        let pages = reference_pages();
155        for entry in fs::read_dir(repo_root().join(PAGES_DIR)).expect("read the pages directory") {
156            let path = entry.expect("directory entry").path();
157            if path.extension().and_then(|e| e.to_str()) != Some("md") {
158                continue;
159            }
160            let name = path
161                .file_name()
162                .and_then(|n| n.to_str())
163                .unwrap()
164                .to_string();
165            let generated = fs::read_to_string(&path).is_ok_and(|s| s.starts_with(AUTOGEN_MARKER));
166            assert!(
167                !generated || pages.contains_key(&name),
168                "{PAGES_DIR}/{name} is generated but no longer in the reference; run `cn docs`"
169            );
170        }
171    }
172
173    // Writing into a fresh directory produces the whole page set; a stale
174    // generated page is pruned on the next run and a hand-authored one is not.
175    #[test]
176    fn writing_is_complete_and_prunes_only_generated_pages() {
177        let dir = std::env::temp_dir().join("cn-docs-write-test");
178        fs::remove_dir_all(&dir).ok();
179
180        let pages: BTreeMap<String, String> = ["Prop", "Texture"]
181            .iter()
182            .map(|n| (format!("{n}.md"), render_page(n, "A body.")))
183            .collect();
184        assert_eq!(write_pages(&dir, &pages).expect("first run"), (2, 0));
185        for (file, content) in &pages {
186            assert_eq!(
187                &fs::read_to_string(dir.join(file)).expect("written"),
188                content
189            );
190        }
191
192        // A second run over an unchanged tree touches nothing.
193        assert_eq!(write_pages(&dir, &pages).expect("second run"), (0, 0));
194
195        fs::write(dir.join("Gone.md"), format!("{AUTOGEN_MARKER}\n\n# Gone\n")).unwrap();
196        fs::write(dir.join("notes.md"), "hand written\n").unwrap();
197        // Non-Markdown neighbours are skipped outright, marker or not.
198        fs::write(dir.join("diagram.png"), format!("{AUTOGEN_MARKER}\n")).unwrap();
199        assert_eq!(write_pages(&dir, &pages).expect("third run"), (0, 1));
200        assert!(!dir.join("Gone.md").exists(), "stale page should be pruned");
201        assert!(
202            dir.join("notes.md").exists(),
203            "hand-authored page should stay"
204        );
205        assert!(dir.join("diagram.png").exists(), "non-page should stay");
206
207        fs::remove_dir_all(&dir).ok();
208    }
209
210    fn describe<'a>(docs: &'a [AssetDoc], type_name: &str) -> Option<&'a AssetDoc> {
211        docs.iter()
212            .find(|d| d.type_name.eq_ignore_ascii_case(type_name))
213    }
214
215    #[test]
216    fn every_documented_type_is_found_by_name() {
217        let docs = reference::build(&repo_root()).expect("read the asset sources");
218        let d = describe(&docs, "Texture").expect("Texture should be documented");
219        assert_eq!(d.type_name, "Texture");
220        assert!(d.full_doc.contains(&d.summary));
221        assert!(describe(&docs, "texture").is_some());
222        assert!(describe(&docs, "TEXTURE").is_some());
223        assert!(describe(&docs, "NotARealAsset").is_none());
224
225        // A nested value type an asset embeds (Prop.collider) is documented in
226        // its own right, not just inlined into the asset that embeds it.
227        let d = describe(&docs, "PropCollider").expect("PropCollider should be documented");
228        assert!(d.is_reference_type);
229    }
230
231    // The extraction resolved real prose for every type. An empty summary means
232    // the asset sources went unread, which would otherwise surface as a page set
233    // of bare titles.
234    #[test]
235    fn every_type_resolved_documentation() {
236        let docs = reference::build(&repo_root()).expect("read the asset sources");
237        assert!(docs.len() > 50, "suspiciously small reference");
238        for d in &docs {
239            assert!(!d.summary.is_empty(), "{} has no summary", d.type_name);
240            assert!(
241                !d.summary.contains('\n'),
242                "{}'s summary spans multiple lines: {:?}",
243                d.type_name,
244                d.summary
245            );
246        }
247    }
248
249    #[test]
250    fn pages_cover_every_type_plus_the_index() {
251        let docs = reference::build(&repo_root()).expect("read the asset sources");
252        let pages = pages(&docs);
253        assert_eq!(pages.len(), docs.len() + 1);
254        assert!(pages.contains_key("index.md"));
255        for d in &docs {
256            let page = &pages[&format!("{}.md", d.type_name)];
257            assert!(page.starts_with(AUTOGEN_MARKER));
258            assert!(page.contains(&format!("# {}", d.type_name)));
259        }
260    }
261
262    // No `](#anchor)` cross-reference survives into a page's prose: every one is
263    // rewritten to a relative `Name.md` link. Code spans and fenced blocks are
264    // exempt, since they never render as links and a doc may legitimately show
265    // anchor syntax verbatim (StoryImport documents its own Markdown dialect).
266    #[test]
267    fn no_in_page_anchor_links_remain() {
268        for d in &reference::build(&repo_root()).expect("read the asset sources") {
269            assert!(
270                !prose_only(&d.full_doc).contains("](#"),
271                "{} still has an in-page anchor link outside code: {:?}",
272                d.type_name,
273                d.full_doc
274            );
275        }
276    }
277
278    // Strip fenced code blocks and inline code spans, leaving the prose that
279    // renders as markdown.
280    fn prose_only(doc: &str) -> String {
281        let mut out = String::new();
282        let mut in_fence = false;
283        for line in doc.lines() {
284            if line.trim_start().starts_with("```") {
285                in_fence = !in_fence;
286                continue;
287            }
288            if in_fence {
289                continue;
290            }
291            // Drop the content of `...` spans; an unpaired backtick keeps the
292            // rest of the line, which errs toward checking more, not less.
293            let mut parts = line.split('`');
294            out.push_str(parts.next().unwrap_or(""));
295            while let (Some(_code), Some(prose)) = (parts.next(), parts.next()) {
296                out.push_str(prose);
297            }
298            out.push('\n');
299        }
300        out
301    }
302}