Skip to main content

ontocore_docs/
lib.rs

1//! Documentation export for indexed ontology workspaces.
2
3use minijinja::{context, AutoEscape, Environment};
4use ontocore_catalog::OntologyCatalog;
5use ontocore_core::{document_for_entity, document_matches_ontology_id, EntityKind};
6use std::collections::{BTreeMap, BTreeSet};
7use std::fs;
8use std::path::PathBuf;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ExportFormat {
12    Markdown,
13    Html,
14}
15
16#[derive(Debug, Clone)]
17pub struct ExportOptions {
18    pub output_dir: PathBuf,
19    pub format: ExportFormat,
20    pub ontology_id: Option<String>,
21}
22
23impl ExportOptions {
24    pub fn markdown(output_dir: impl Into<PathBuf>) -> Self {
25        Self { output_dir: output_dir.into(), format: ExportFormat::Markdown, ontology_id: None }
26    }
27
28    pub fn html(output_dir: impl Into<PathBuf>) -> Self {
29        Self { output_dir: output_dir.into(), format: ExportFormat::Html, ontology_id: None }
30    }
31
32    pub fn with_ontology_id(mut self, id: impl Into<String>) -> Self {
33        self.ontology_id = Some(id.into());
34        self
35    }
36}
37
38#[derive(Debug, thiserror::Error)]
39pub enum ExportError {
40    #[error("IO error: {0}")]
41    Io(#[from] std::io::Error),
42    #[error("template error: {0}")]
43    Template(#[from] minijinja::Error),
44}
45
46pub type Result<T> = std::result::Result<T, ExportError>;
47
48#[derive(Debug, Clone, serde::Serialize)]
49struct EntityDoc {
50    iri: String,
51    short_name: String,
52    kind: String,
53    labels: Vec<String>,
54    comments: Vec<String>,
55    parents: Vec<String>,
56}
57
58#[derive(Debug, Clone, serde::Serialize)]
59struct OntologyDoc {
60    id: String,
61    slug: String,
62    path: String,
63    imports: Vec<String>,
64    entities: Vec<EntityDoc>,
65}
66
67pub fn export_workspace(catalog: &OntologyCatalog, options: ExportOptions) -> Result<()> {
68    fs::create_dir_all(&options.output_dir)?;
69
70    let hierarchy = catalog.class_hierarchy();
71    let mut ontologies: Vec<OntologyDoc> = Vec::new();
72    let mut used_slugs = BTreeSet::new();
73
74    for doc in &catalog.data().documents {
75        if let Some(filter) = &options.ontology_id {
76            if !document_matches_ontology_id(filter, doc) {
77                continue;
78            }
79        }
80        let mut entities = Vec::new();
81        for entity in &catalog.data().entities {
82            if document_for_entity(&catalog.data().documents, entity).is_none_or(|d| d.id != doc.id)
83            {
84                continue;
85            }
86            let parents = hierarchy.parents.get(&entity.iri).cloned().unwrap_or_default();
87            entities.push(EntityDoc {
88                iri: entity.iri.clone(),
89                short_name: entity.short_name.clone(),
90                kind: entity.kind.as_str().to_string(),
91                labels: entity.labels.clone(),
92                comments: entity.comments.clone(),
93                parents,
94            });
95        }
96        entities.sort_by(|a, b| a.short_name.cmp(&b.short_name));
97        ontologies.push(OntologyDoc {
98            id: doc.id.clone(),
99            slug: allocate_slug(&doc.id, &mut used_slugs),
100            path: doc.path.display().to_string(),
101            imports: doc.imports.clone(),
102            entities,
103        });
104    }
105
106    let index_name = match options.format {
107        ExportFormat::Markdown => "index.md",
108        ExportFormat::Html => "index.html",
109    };
110    let index_path = options.output_dir.join(index_name);
111    let index_body = render_index(&ontologies, &hierarchy, catalog, options.format)?;
112    fs::write(index_path, index_body)?;
113
114    for ont in &ontologies {
115        let file_name = match options.format {
116            ExportFormat::Markdown => format!("{}.md", ont.slug),
117            ExportFormat::Html => format!("{}.html", ont.slug),
118        };
119        let body = render_ontology(ont, options.format)?;
120        fs::write(options.output_dir.join(file_name), body)?;
121    }
122
123    Ok(())
124}
125
126fn slugify(iri: &str) -> String {
127    let slug: String = iri
128        .chars()
129        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
130        .collect::<String>()
131        .trim_matches('_')
132        .chars()
133        .take(80)
134        .collect();
135    if slug.is_empty() {
136        "ontology".to_string()
137    } else {
138        slug
139    }
140}
141
142/// Stable 8-hex FNV-1a tag so colliding readable slugs stay unique (#24).
143fn iri_tag(iri: &str) -> String {
144    let mut hash: u64 = 0xcbf29ce484222325;
145    for byte in iri.as_bytes() {
146        hash ^= u64::from(*byte);
147        hash = hash.wrapping_mul(0x100000001b3);
148    }
149    format!("{hash:016x}")[..8].to_string()
150}
151
152/// Allocate a filesystem-safe slug unique within one export run.
153fn allocate_slug(iri: &str, used: &mut BTreeSet<String>) -> String {
154    let base = slugify(iri);
155    if used.insert(base.clone()) {
156        return base;
157    }
158    let tag = iri_tag(iri);
159    let truncated: String = base.chars().take(71).collect();
160    let mut candidate = format!("{truncated}_{tag}");
161    let mut n = 2u32;
162    while !used.insert(candidate.clone()) {
163        candidate = format!("{truncated}_{tag}_{n}");
164        n = n.saturating_add(1);
165    }
166    candidate
167}
168
169fn render_index(
170    ontologies: &[OntologyDoc],
171    hierarchy: &ontocore_catalog::ClassHierarchy,
172    catalog: &OntologyCatalog,
173    format: ExportFormat,
174) -> Result<String> {
175    match format {
176        ExportFormat::Markdown => {
177            let mut md = String::from("# Ontology documentation\n\n");
178            for ont in ontologies {
179                md.push_str(&format!(
180                    "- [{}]({}.md) — {} entities, {} imports\n",
181                    ont.id,
182                    ont.slug,
183                    ont.entities.len(),
184                    ont.imports.len()
185                ));
186            }
187            md.push_str("\n## Class hierarchy\n\n");
188            md.push_str(&render_class_hierarchy(catalog, hierarchy));
189            md.push_str("\n## Property index\n\n");
190            md.push_str(&render_property_index(catalog));
191            Ok(md)
192        }
193        ExportFormat::Html => {
194            let env = html_env()?;
195            let tmpl = env.get_template("index.html")?;
196            Ok(tmpl.render(context! { ontologies => ontologies })?)
197        }
198    }
199}
200
201pub fn render_class_hierarchy(
202    catalog: &OntologyCatalog,
203    hierarchy: &ontocore_catalog::ClassHierarchy,
204) -> String {
205    let mut nodes: BTreeSet<&str> = BTreeSet::new();
206    for iri in hierarchy.parents.keys().chain(hierarchy.children.keys()) {
207        nodes.insert(iri.as_str());
208    }
209    let mut roots: Vec<&str> = nodes
210        .into_iter()
211        .filter(|iri| hierarchy.parents.get(*iri).map(|p| p.is_empty()).unwrap_or(true))
212        .collect();
213    roots.sort();
214    let mut out = String::new();
215    for root in roots {
216        render_hierarchy_node(catalog, hierarchy, root, 0, &mut out);
217    }
218    if out.is_empty() {
219        out.push_str("_No class hierarchy available._\n");
220    }
221    out
222}
223
224fn render_hierarchy_node(
225    catalog: &OntologyCatalog,
226    hierarchy: &ontocore_catalog::ClassHierarchy,
227    iri: &str,
228    depth: usize,
229    out: &mut String,
230) {
231    let label = catalog
232        .data()
233        .entities
234        .iter()
235        .find(|e| e.iri == iri)
236        .and_then(|e| e.labels.first())
237        .map(|s| s.as_str())
238        .unwrap_or(iri);
239    out.push_str(&format!("{}{} (`{}`)\n", "  ".repeat(depth), label, iri));
240    let mut children: Vec<&str> = hierarchy
241        .children
242        .get(iri)
243        .map(|v| v.iter().map(|s| s.as_str()).collect())
244        .unwrap_or_default();
245    children.sort();
246    for child in children {
247        render_hierarchy_node(catalog, hierarchy, child, depth + 1, out);
248    }
249}
250
251pub fn render_property_index(catalog: &OntologyCatalog) -> String {
252    let mut object_props = Vec::new();
253    let mut data_props = Vec::new();
254    let mut annotation_props = Vec::new();
255    for entity in &catalog.data().entities {
256        match entity.kind {
257            EntityKind::ObjectProperty => object_props.push(entity),
258            EntityKind::DataProperty => data_props.push(entity),
259            EntityKind::AnnotationProperty => annotation_props.push(entity),
260            _ => {}
261        }
262    }
263    let mut out = String::new();
264    for (title, props) in [
265        ("Object properties", &object_props),
266        ("Data properties", &data_props),
267        ("Annotation properties", &annotation_props),
268    ] {
269        if props.is_empty() {
270            continue;
271        }
272        out.push_str(&format!("### {title}\n\n"));
273        let mut sorted = props.clone();
274        sorted.sort_by(|a, b| a.short_name.cmp(&b.short_name));
275        for prop in sorted {
276            let label = prop.labels.first().map(|s| s.as_str()).unwrap_or(&prop.short_name);
277            out.push_str(&format!("- **{label}** — `{}`\n", prop.iri));
278        }
279        out.push('\n');
280    }
281    if out.is_empty() {
282        out.push_str("_No properties indexed._\n");
283    }
284    out
285}
286
287fn render_ontology(ont: &OntologyDoc, format: ExportFormat) -> Result<String> {
288    match format {
289        ExportFormat::Markdown => {
290            let mut md = format!("# {}\n\n", ont.id);
291            md.push_str(&format!("Source: `{}`\n\n", ont.path));
292            if !ont.imports.is_empty() {
293                md.push_str("## Imports\n\n");
294                for imp in &ont.imports {
295                    md.push_str(&format!("- <{imp}>\n"));
296                }
297                md.push('\n');
298            }
299            md.push_str("## Entities\n\n");
300            for entity in &ont.entities {
301                let label = entity.labels.first().map(|s| s.as_str()).unwrap_or(&entity.short_name);
302                md.push_str(&format!("### {label}\n\n"));
303                md.push_str(&format!("- IRI: `{}`\n", entity.iri));
304                md.push_str(&format!("- Kind: {}\n", entity.kind));
305                if !entity.comments.is_empty() {
306                    md.push_str(&format!("- Comment: {}\n", entity.comments.join("; ")));
307                }
308                if !entity.parents.is_empty() {
309                    md.push_str(&format!("- Parents: {}\n", entity.parents.join(", ")));
310                }
311                md.push('\n');
312            }
313            Ok(md)
314        }
315        ExportFormat::Html => {
316            let env = html_env()?;
317            let tmpl = env.get_template("ontology.html")?;
318            Ok(tmpl.render(context! { ont => ont })?)
319        }
320    }
321}
322
323fn html_env() -> Result<Environment<'static>> {
324    let mut env = Environment::new();
325    env.set_auto_escape_callback(|name| {
326        if name.ends_with(".html") {
327            AutoEscape::Html
328        } else {
329            AutoEscape::None
330        }
331    });
332    env.add_template(
333        "index.html",
334        r#"<!DOCTYPE html>
335<html><head><meta charset="utf-8"><title>Ontology docs</title></head>
336<body><h1>Ontology documentation</h1><ul>
337{% for ont in ontologies %}
338<li><a href="{{ ont.slug }}.html">{{ ont.id }}</a>
339 — {{ ont.entities | length }} entities</li>
340{% endfor %}
341</ul></body></html>"#,
342    )?;
343    env.add_template(
344        "ontology.html",
345        r#"<!DOCTYPE html>
346<html><head><meta charset="utf-8"><title>{{ ont.id }}</title></head>
347<body>
348<h1>{{ ont.id }}</h1>
349<p>Source: <code>{{ ont.path }}</code></p>
350{% if ont.imports %}<h2>Imports</h2><ul>{% for imp in ont.imports %}<li>{{ imp }}</li>{% endfor %}</ul>{% endif %}
351<h2>Entities</h2>
352<table border="1"><tr><th>Name</th><th>Kind</th><th>IRI</th></tr>
353{% for e in ont.entities %}
354<tr><td>{{ e.labels[0] if e.labels else e.short_name }}</td><td>{{ e.kind }}</td><td>{{ e.iri }}</td></tr>
355{% endfor %}
356</table>
357</body></html>"#,
358    )?;
359    Ok(env)
360}
361
362pub fn entity_kind_counts(catalog: &OntologyCatalog) -> BTreeMap<String, usize> {
363    let mut counts = BTreeMap::new();
364    for entity in &catalog.data().entities {
365        *counts.entry(entity.kind.as_str().to_string()).or_default() += 1;
366    }
367    counts
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use ontocore_catalog::IndexBuilder;
374    use std::path::Path;
375
376    #[test]
377    fn exports_markdown_for_fixtures() {
378        let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
379        let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
380        let dir = tempfile::tempdir().unwrap();
381        export_workspace(&catalog, ExportOptions::markdown(dir.path())).expect("export");
382        assert!(dir.path().join("index.md").exists());
383    }
384
385    #[test]
386    fn exports_entities_for_owl_ontology_declarations() {
387        let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
388        let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
389        let example = catalog
390            .data()
391            .documents
392            .iter()
393            .find(|d| d.path.file_name().and_then(|n| n.to_str()) == Some("example.ttl"))
394            .expect("example.ttl indexed");
395        let entity_count = catalog
396            .data()
397            .entities
398            .iter()
399            .filter(|e| {
400                document_for_entity(&catalog.data().documents, e)
401                    .is_some_and(|d| d.id == example.id)
402            })
403            .count();
404        assert!(entity_count > 0, "fixture entities should match example.ttl via ontology IRI");
405
406        let dir = tempfile::tempdir().unwrap();
407        export_workspace(&catalog, ExportOptions::markdown(dir.path())).expect("export");
408        let index = fs::read_to_string(dir.path().join("index.md")).expect("index.md");
409        let doc_slug = allocate_slug(&example.id, &mut BTreeSet::new());
410        let detail_path = dir.path().join(format!("{doc_slug}.md"));
411        assert!(detail_path.exists(), "expected per-ontology export file");
412        let detail = fs::read_to_string(detail_path).expect("ontology markdown");
413        assert!(detail.contains("## Entities"), "ontology page should list entities");
414        assert!(!detail.contains("## Entities\n\n\n#"), "entity section should not be empty");
415        assert!(
416            index.contains(&format!("{entity_count} entities")),
417            "index should report exported entity count for example.ttl"
418        );
419    }
420
421    #[test]
422    fn slugify_collisions_are_disambiguated() {
423        assert_eq!(slugify("http://a.com/x/1"), slugify("http://a.com/x_1"));
424        let mut used = BTreeSet::new();
425        let first = allocate_slug("http://a.com/x/1", &mut used);
426        let second = allocate_slug("http://a.com/x_1", &mut used);
427        assert_ne!(first, second);
428        assert_eq!(first, slugify("http://a.com/x/1"));
429        assert!(second.contains('_'), "collision should append a disambiguating tag");
430        assert_eq!(used.len(), 2);
431    }
432
433    #[test]
434    fn export_keeps_both_files_when_ontology_ids_slug_collide() {
435        let dir = tempfile::tempdir().unwrap();
436        let a = dir.path().join("a.ttl");
437        let b = dir.path().join("b.ttl");
438        // Distinct ontology IRIs that collapse to the same slugify() result.
439        fs::write(
440            &a,
441            concat!(
442                "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n",
443                "<http://a.com/x/1> a owl:Ontology .\n",
444                "<http://a.com/x/1#A> a owl:Class .\n",
445            ),
446        )
447        .unwrap();
448        fs::write(
449            &b,
450            concat!(
451                "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n",
452                "<http://a.com/x_1> a owl:Ontology .\n",
453                "<http://a.com/x_1#B> a owl:Class .\n",
454            ),
455        )
456        .unwrap();
457
458        let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("index");
459        let out = tempfile::tempdir().unwrap();
460        export_workspace(&catalog, ExportOptions::markdown(out.path())).expect("export");
461
462        let md_files: Vec<_> = fs::read_dir(out.path())
463            .unwrap()
464            .filter_map(|e| e.ok())
465            .map(|e| e.file_name().to_string_lossy().into_owned())
466            .filter(|n| n.ends_with(".md") && n != "index.md")
467            .collect();
468        assert_eq!(
469            md_files.len(),
470            2,
471            "both colliding ontology IRIs must produce distinct export files, got {md_files:?}"
472        );
473
474        let bodies: Vec<_> = md_files
475            .iter()
476            .map(|name| fs::read_to_string(out.path().join(name)).expect("read"))
477            .collect();
478        assert!(bodies.iter().any(|b| b.contains("http://a.com/x/1")));
479        assert!(bodies.iter().any(|b| b.contains("http://a.com/x_1")));
480    }
481
482    #[test]
483    fn markdown_index_includes_hierarchy_and_property_sections() {
484        let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
485        let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
486        let dir = tempfile::tempdir().unwrap();
487        export_workspace(&catalog, ExportOptions::markdown(dir.path())).expect("export");
488        let index = fs::read_to_string(dir.path().join("index.md")).expect("index.md");
489        assert!(index.contains("## Class hierarchy"), "index should include hierarchy");
490        assert!(index.contains("## Property index"), "index should include properties");
491    }
492
493    #[test]
494    fn render_class_hierarchy_lists_fixture_classes() {
495        let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
496        let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
497        let hierarchy = catalog.class_hierarchy();
498        let md = render_class_hierarchy(&catalog, &hierarchy);
499        assert!(md.contains("Person") || md.contains("people#Person"));
500    }
501
502    #[test]
503    fn render_property_index_lists_object_properties() {
504        let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
505        let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
506        let md = render_property_index(&catalog);
507        assert!(md.contains("Object properties"));
508        assert!(md.contains("worksFor") || md.contains("works for"));
509    }
510
511    #[test]
512    fn markdown_export_includes_person_entity_page() {
513        let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
514        let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
515        let dir = tempfile::tempdir().unwrap();
516        export_workspace(&catalog, ExportOptions::markdown(dir.path())).expect("export");
517        let mut found_person = false;
518        for entry in fs::read_dir(dir.path()).unwrap() {
519            let path = entry.unwrap().path();
520            if path.extension().and_then(|e| e.to_str()) != Some("md") {
521                continue;
522            }
523            let body = fs::read_to_string(&path).unwrap();
524            if body.contains("Person") && body.contains("http://example.org/people#Person") {
525                found_person = true;
526                break;
527            }
528        }
529        assert!(found_person, "expected a markdown page documenting Person");
530    }
531}