concinnity_dev/docs/
mod.rs1mod 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
26const PAGES_DIR: &str = "docs/assets";
28
29fn 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
56pub 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
79fn 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
97fn 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 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 #[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 #[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 #[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 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 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 let d = describe(&docs, "PropCollider").expect("PropCollider should be documented");
228 assert!(d.is_reference_type);
229 }
230
231 #[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 #[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 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 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}