1mod page;
16mod reference;
17mod render;
18mod schema;
19
20use page::{AUTOGEN_MARKER, IndexEntry, render_index, render_page};
21use reference::AssetDoc;
22
23use std::collections::BTreeMap;
24use std::fs;
25use std::io;
26use std::path::{Path, PathBuf};
27
28const PAGES_DIR: &str = "docs/assets";
30
31fn pages(docs: &[AssetDoc]) -> BTreeMap<String, String> {
34 let mut out = BTreeMap::new();
35 for d in docs {
36 out.insert(
37 format!("{}.md", d.type_name),
38 render_page(&d.type_name, &d.full_doc),
39 );
40 }
41
42 let index = |reference_types: bool| -> Vec<IndexEntry> {
43 docs.iter()
44 .filter(|d| d.is_reference_type == reference_types)
45 .map(|d| IndexEntry {
46 name: d.type_name.clone(),
47 summary: d.summary.clone(),
48 })
49 .collect()
50 };
51 out.insert(
52 "index.md".to_string(),
53 render_index(&index(false), &index(true)),
54 );
55 out
56}
57
58pub fn docs(root: Option<&str>) -> io::Result<()> {
68 let engine_root = PathBuf::from(root.unwrap_or("."));
69 let pages = pages(&reference::build(&engine_root)?);
70 let dir = engine_root.join(PAGES_DIR);
71 let (written, removed) = write_pages(&dir, &pages)?;
72
73 println!(
74 "{} asset pages in {} ({written} written, {removed} removed)",
75 pages.len(),
76 dir.display()
77 );
78 Ok(())
79}
80
81fn write_pages(dir: &Path, pages: &BTreeMap<String, String>) -> io::Result<(usize, usize)> {
85 fs::create_dir_all(dir)?;
86
87 let mut written = 0usize;
88 for (file, content) in pages {
89 let path = dir.join(file);
90 if fs::read_to_string(&path).ok().as_deref() == Some(content.as_str()) {
91 continue;
92 }
93 fs::write(&path, content)?;
94 written += 1;
95 }
96 Ok((written, remove_stale_pages(dir, pages)?))
97}
98
99fn remove_stale_pages(dir: &Path, keep: &BTreeMap<String, String>) -> io::Result<usize> {
103 let mut removed = 0;
104 for entry in fs::read_dir(dir)? {
105 let path = entry?.path();
106 if path.extension().and_then(|e| e.to_str()) != Some("md") {
107 continue;
108 }
109 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
110 continue;
111 };
112 if keep.contains_key(name) {
113 continue;
114 }
115 if fs::read_to_string(&path).is_ok_and(|s| s.starts_with(AUTOGEN_MARKER)) {
116 fs::remove_file(&path)?;
117 removed += 1;
118 }
119 }
120 Ok(removed)
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 const SOURCES: &str = r#"
136 /// A widget in the world.
137 ///
138 /// The shape it embeds is a [collider](#widgetcollider).
139 pub struct Widget {
140 /// The mesh to draw.
141 pub mesh: String,
142 /// The shape it collides with.
143 pub collider: Option<WidgetCollider>,
144 }
145 impl Default for Widget {
146 fn default() -> Self {
147 Self { mesh: "cube".to_string(), collider: None }
148 }
149 }
150
151 /// A collider shape a widget embeds.
152 pub struct WidgetCollider {
153 /// Half the box's size on each axis.
154 pub half_extents: [f32; 3],
155 }
156 impl Default for WidgetCollider {
157 fn default() -> Self {
158 Self { half_extents: [0.5, 0.5, 0.5] }
159 }
160 }
161
162 /// A gadget that makes noise.
163 pub struct Gadget {
164 /// How loud, from silent to full.
165 pub volume: f32,
166 }
167 impl Default for Gadget {
168 fn default() -> Self {
169 Self { volume: 1.0 }
170 }
171 }
172
173 /// Engine bookkeeping no world declares.
174 pub struct Internal {
175 /// A counter.
176 pub ticks: u32,
177 }
178 impl Default for Internal {
179 fn default() -> Self {
180 Self { ticks: 0 }
181 }
182 }
183 "#;
184
185 fn synthetic_reference() -> (concinnity_testing::TempTree, Vec<AssetDoc>) {
188 let tree = concinnity_testing::TempTree::new();
189 tree.write("schema/vocabulary.rs", SOURCES);
190 tree.write("schema/notes.md", "not rust");
192
193 let components = [
194 reference::ComponentMeta::pass_through("Widget", "External"),
195 reference::ComponentMeta::pass_through("Gadget", "External"),
196 reference::ComponentMeta::pass_through("Internal", "RuntimeOnly"),
198 ];
199 let docs = reference::build_from(&[tree.join("schema")], &components)
200 .expect("the synthetic sources parse");
201 (tree, docs)
202 }
203
204 #[test]
207 fn writing_is_complete_and_prunes_only_generated_pages() {
208 let tree = concinnity_testing::TempTree::new();
209 let dir = tree.path();
210
211 let pages: BTreeMap<String, String> = ["Prop", "Texture"]
212 .iter()
213 .map(|n| (format!("{n}.md"), render_page(n, "A body.")))
214 .collect();
215 assert_eq!(write_pages(dir, &pages).expect("first run"), (2, 0));
216 for (file, content) in &pages {
217 assert_eq!(
218 &fs::read_to_string(dir.join(file)).expect("written"),
219 content
220 );
221 }
222
223 assert_eq!(write_pages(dir, &pages).expect("second run"), (0, 0));
225
226 fs::write(dir.join("Gone.md"), format!("{AUTOGEN_MARKER}\n\n# Gone\n")).unwrap();
227 fs::write(dir.join("notes.md"), "hand written\n").unwrap();
228 fs::write(dir.join("diagram.png"), format!("{AUTOGEN_MARKER}\n")).unwrap();
230 assert_eq!(write_pages(dir, &pages).expect("third run"), (0, 1));
231 assert!(!dir.join("Gone.md").exists(), "stale page should be pruned");
232 assert!(
233 dir.join("notes.md").exists(),
234 "hand-authored page should stay"
235 );
236 assert!(dir.join("diagram.png").exists(), "non-page should stay");
237 }
238
239 fn describe<'a>(docs: &'a [AssetDoc], type_name: &str) -> Option<&'a AssetDoc> {
240 docs.iter()
241 .find(|d| d.type_name.eq_ignore_ascii_case(type_name))
242 }
243
244 #[test]
248 fn every_documented_type_is_found_by_name() {
249 let (_tree, docs) = synthetic_reference();
250
251 let d = describe(&docs, "Widget").expect("Widget should be documented");
252 assert_eq!(d.type_name, "Widget");
253 assert!(d.full_doc.contains(&d.summary));
254 assert!(describe(&docs, "widget").is_some());
255 assert!(describe(&docs, "WIDGET").is_some());
256 assert!(describe(&docs, "NotARealAsset").is_none());
257
258 let embedded = describe(&docs, "WidgetCollider").expect("the embedded type is documented");
259 assert!(embedded.is_reference_type);
260 assert!(!d.is_reference_type, "an asset is not a reference type");
261
262 assert!(
263 describe(&docs, "Internal").is_none(),
264 "a RuntimeOnly component is engine-internal and gets no page"
265 );
266 }
267
268 #[test]
271 fn every_type_resolved_documentation() {
272 let (_tree, docs) = synthetic_reference();
273 assert_eq!(docs.len(), 3, "two assets and the type they embed");
274
275 for d in &docs {
276 assert!(!d.summary.is_empty(), "{} has no summary", d.type_name);
277 assert!(
278 !d.summary.contains('\n'),
279 "{}'s summary spans multiple lines: {:?}",
280 d.type_name,
281 d.summary
282 );
283 }
284
285 let widget = describe(&docs, "Widget").expect("Widget");
287 assert_eq!(widget.summary, "A widget in the world.");
288
289 assert!(
291 widget.full_doc.contains("The mesh to draw."),
292 "{:?}",
293 widget.full_doc
294 );
295 assert!(widget.full_doc.contains("cube"), "{:?}", widget.full_doc);
296 }
297
298 #[test]
299 fn pages_cover_every_type_plus_the_index() {
300 let (_tree, docs) = synthetic_reference();
301 let pages = pages(&docs);
302
303 assert_eq!(pages.len(), docs.len() + 1);
304 assert!(pages.contains_key("index.md"));
305 for d in &docs {
306 let page = &pages[&format!("{}.md", d.type_name)];
307 assert!(page.starts_with(AUTOGEN_MARKER));
308 assert!(page.contains(&format!("# {}", d.type_name)));
309 }
310 }
311
312 #[test]
320 fn no_in_page_anchor_links_remain() {
321 let (_tree, docs) = synthetic_reference();
322 let widget = describe(&docs, "Widget").expect("Widget");
323
324 assert!(
325 widget.full_doc.contains("](WidgetCollider.md)"),
326 "the anchor was rewritten to a relative page link: {:?}",
327 widget.full_doc
328 );
329 for d in &docs {
330 assert!(
331 !prose_only(&d.full_doc).contains("](#"),
332 "{} still has an in-page anchor link outside code: {:?}",
333 d.type_name,
334 d.full_doc
335 );
336 }
337 }
338
339 fn prose_only(doc: &str) -> String {
342 let mut out = String::new();
343 let mut in_fence = false;
344 for line in doc.lines() {
345 if line.trim_start().starts_with("```") {
346 in_fence = !in_fence;
347 continue;
348 }
349 if in_fence {
350 continue;
351 }
352 let mut parts = line.split('`');
355 out.push_str(parts.next().unwrap_or(""));
356 while let (Some(_code), Some(prose)) = (parts.next(), parts.next()) {
357 out.push_str(prose);
358 }
359 out.push('\n');
360 }
361 out
362 }
363}