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_matches_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
73    for doc in &catalog.data().documents {
74        if let Some(filter) = &options.ontology_id {
75            if !document_matches_ontology_id(filter, doc) {
76                continue;
77            }
78        }
79        let mut entities = Vec::new();
80        for entity in &catalog.data().entities {
81            if !document_matches_entity(entity, doc) {
82                continue;
83            }
84            let parents = hierarchy.parents.get(&entity.iri).cloned().unwrap_or_default();
85            entities.push(EntityDoc {
86                iri: entity.iri.clone(),
87                short_name: entity.short_name.clone(),
88                kind: entity.kind.as_str().to_string(),
89                labels: entity.labels.clone(),
90                comments: entity.comments.clone(),
91                parents,
92            });
93        }
94        entities.sort_by(|a, b| a.short_name.cmp(&b.short_name));
95        ontologies.push(OntologyDoc {
96            id: doc.id.clone(),
97            slug: slugify(&doc.id),
98            path: doc.path.display().to_string(),
99            imports: doc.imports.clone(),
100            entities,
101        });
102    }
103
104    let index_name = match options.format {
105        ExportFormat::Markdown => "index.md",
106        ExportFormat::Html => "index.html",
107    };
108    let index_path = options.output_dir.join(index_name);
109    let index_body = render_index(&ontologies, &hierarchy, catalog, options.format)?;
110    fs::write(index_path, index_body)?;
111
112    for ont in &ontologies {
113        let file_name = match options.format {
114            ExportFormat::Markdown => format!("{}.md", ont.slug),
115            ExportFormat::Html => format!("{}.html", ont.slug),
116        };
117        let body = render_ontology(ont, options.format)?;
118        fs::write(options.output_dir.join(file_name), body)?;
119    }
120
121    Ok(())
122}
123
124fn slugify(iri: &str) -> String {
125    iri.chars()
126        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
127        .collect::<String>()
128        .trim_matches('_')
129        .chars()
130        .take(80)
131        .collect()
132}
133
134fn render_index(
135    ontologies: &[OntologyDoc],
136    hierarchy: &ontocore_catalog::ClassHierarchy,
137    catalog: &OntologyCatalog,
138    format: ExportFormat,
139) -> Result<String> {
140    match format {
141        ExportFormat::Markdown => {
142            let mut md = String::from("# Ontology documentation\n\n");
143            for ont in ontologies {
144                md.push_str(&format!(
145                    "- [{}]({}.md) — {} entities, {} imports\n",
146                    ont.id,
147                    ont.slug,
148                    ont.entities.len(),
149                    ont.imports.len()
150                ));
151            }
152            md.push_str("\n## Class hierarchy\n\n");
153            md.push_str(&render_class_hierarchy(catalog, hierarchy));
154            md.push_str("\n## Property index\n\n");
155            md.push_str(&render_property_index(catalog));
156            Ok(md)
157        }
158        ExportFormat::Html => {
159            let env = html_env()?;
160            let tmpl = env.get_template("index.html")?;
161            Ok(tmpl.render(context! { ontologies => ontologies })?)
162        }
163    }
164}
165
166pub fn render_class_hierarchy(
167    catalog: &OntologyCatalog,
168    hierarchy: &ontocore_catalog::ClassHierarchy,
169) -> String {
170    let mut nodes: BTreeSet<&str> = BTreeSet::new();
171    for iri in hierarchy.parents.keys().chain(hierarchy.children.keys()) {
172        nodes.insert(iri.as_str());
173    }
174    let mut roots: Vec<&str> = nodes
175        .into_iter()
176        .filter(|iri| hierarchy.parents.get(*iri).map(|p| p.is_empty()).unwrap_or(true))
177        .collect();
178    roots.sort();
179    let mut out = String::new();
180    for root in roots {
181        render_hierarchy_node(catalog, hierarchy, root, 0, &mut out);
182    }
183    if out.is_empty() {
184        out.push_str("_No class hierarchy available._\n");
185    }
186    out
187}
188
189fn render_hierarchy_node(
190    catalog: &OntologyCatalog,
191    hierarchy: &ontocore_catalog::ClassHierarchy,
192    iri: &str,
193    depth: usize,
194    out: &mut String,
195) {
196    let label = catalog
197        .data()
198        .entities
199        .iter()
200        .find(|e| e.iri == iri)
201        .and_then(|e| e.labels.first())
202        .map(|s| s.as_str())
203        .unwrap_or(iri);
204    out.push_str(&format!("{}{} (`{}`)\n", "  ".repeat(depth), label, iri));
205    let mut children: Vec<&str> = hierarchy
206        .children
207        .get(iri)
208        .map(|v| v.iter().map(|s| s.as_str()).collect())
209        .unwrap_or_default();
210    children.sort();
211    for child in children {
212        render_hierarchy_node(catalog, hierarchy, child, depth + 1, out);
213    }
214}
215
216pub fn render_property_index(catalog: &OntologyCatalog) -> String {
217    let mut object_props = Vec::new();
218    let mut data_props = Vec::new();
219    let mut annotation_props = Vec::new();
220    for entity in &catalog.data().entities {
221        match entity.kind {
222            EntityKind::ObjectProperty => object_props.push(entity),
223            EntityKind::DataProperty => data_props.push(entity),
224            EntityKind::AnnotationProperty => annotation_props.push(entity),
225            _ => {}
226        }
227    }
228    let mut out = String::new();
229    for (title, props) in [
230        ("Object properties", &object_props),
231        ("Data properties", &data_props),
232        ("Annotation properties", &annotation_props),
233    ] {
234        if props.is_empty() {
235            continue;
236        }
237        out.push_str(&format!("### {title}\n\n"));
238        let mut sorted = props.clone();
239        sorted.sort_by(|a, b| a.short_name.cmp(&b.short_name));
240        for prop in sorted {
241            let label = prop.labels.first().map(|s| s.as_str()).unwrap_or(&prop.short_name);
242            out.push_str(&format!("- **{label}** — `{}`\n", prop.iri));
243        }
244        out.push('\n');
245    }
246    if out.is_empty() {
247        out.push_str("_No properties indexed._\n");
248    }
249    out
250}
251
252fn render_ontology(ont: &OntologyDoc, format: ExportFormat) -> Result<String> {
253    match format {
254        ExportFormat::Markdown => {
255            let mut md = format!("# {}\n\n", ont.id);
256            md.push_str(&format!("Source: `{}`\n\n", ont.path));
257            if !ont.imports.is_empty() {
258                md.push_str("## Imports\n\n");
259                for imp in &ont.imports {
260                    md.push_str(&format!("- <{imp}>\n"));
261                }
262                md.push('\n');
263            }
264            md.push_str("## Entities\n\n");
265            for entity in &ont.entities {
266                let label = entity.labels.first().map(|s| s.as_str()).unwrap_or(&entity.short_name);
267                md.push_str(&format!("### {label}\n\n"));
268                md.push_str(&format!("- IRI: `{}`\n", entity.iri));
269                md.push_str(&format!("- Kind: {}\n", entity.kind));
270                if !entity.comments.is_empty() {
271                    md.push_str(&format!("- Comment: {}\n", entity.comments.join("; ")));
272                }
273                if !entity.parents.is_empty() {
274                    md.push_str(&format!("- Parents: {}\n", entity.parents.join(", ")));
275                }
276                md.push('\n');
277            }
278            Ok(md)
279        }
280        ExportFormat::Html => {
281            let env = html_env()?;
282            let tmpl = env.get_template("ontology.html")?;
283            Ok(tmpl.render(context! { ont => ont })?)
284        }
285    }
286}
287
288fn html_env() -> Result<Environment<'static>> {
289    let mut env = Environment::new();
290    env.set_auto_escape_callback(|name| {
291        if name.ends_with(".html") {
292            AutoEscape::Html
293        } else {
294            AutoEscape::None
295        }
296    });
297    env.add_template(
298        "index.html",
299        r#"<!DOCTYPE html>
300<html><head><meta charset="utf-8"><title>Ontology docs</title></head>
301<body><h1>Ontology documentation</h1><ul>
302{% for ont in ontologies %}
303<li><a href="{{ ont.slug }}.html">{{ ont.id }}</a>
304 — {{ ont.entities | length }} entities</li>
305{% endfor %}
306</ul></body></html>"#,
307    )?;
308    env.add_template(
309        "ontology.html",
310        r#"<!DOCTYPE html>
311<html><head><meta charset="utf-8"><title>{{ ont.id }}</title></head>
312<body>
313<h1>{{ ont.id }}</h1>
314<p>Source: <code>{{ ont.path }}</code></p>
315{% if ont.imports %}<h2>Imports</h2><ul>{% for imp in ont.imports %}<li>{{ imp }}</li>{% endfor %}</ul>{% endif %}
316<h2>Entities</h2>
317<table border="1"><tr><th>Name</th><th>Kind</th><th>IRI</th></tr>
318{% for e in ont.entities %}
319<tr><td>{{ e.labels[0] if e.labels else e.short_name }}</td><td>{{ e.kind }}</td><td>{{ e.iri }}</td></tr>
320{% endfor %}
321</table>
322</body></html>"#,
323    )?;
324    Ok(env)
325}
326
327pub fn entity_kind_counts(catalog: &OntologyCatalog) -> BTreeMap<String, usize> {
328    let mut counts = BTreeMap::new();
329    for entity in &catalog.data().entities {
330        *counts.entry(entity.kind.as_str().to_string()).or_default() += 1;
331    }
332    counts
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use ontocore_catalog::IndexBuilder;
339    use std::path::Path;
340
341    #[test]
342    fn exports_markdown_for_fixtures() {
343        let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
344        let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
345        let dir = tempfile::tempdir().unwrap();
346        export_workspace(&catalog, ExportOptions::markdown(dir.path())).expect("export");
347        assert!(dir.path().join("index.md").exists());
348    }
349
350    #[test]
351    fn exports_entities_for_owl_ontology_declarations() {
352        let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
353        let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
354        let example = catalog
355            .data()
356            .documents
357            .iter()
358            .find(|d| d.path.file_name().and_then(|n| n.to_str()) == Some("example.ttl"))
359            .expect("example.ttl indexed");
360        let entity_count =
361            catalog.data().entities.iter().filter(|e| document_matches_entity(e, example)).count();
362        assert!(entity_count > 0, "fixture entities should match example.ttl via ontology IRI");
363
364        let dir = tempfile::tempdir().unwrap();
365        export_workspace(&catalog, ExportOptions::markdown(dir.path())).expect("export");
366        let index = fs::read_to_string(dir.path().join("index.md")).expect("index.md");
367        let doc_slug = slugify(&example.id);
368        let detail_path = dir.path().join(format!("{doc_slug}.md"));
369        assert!(detail_path.exists(), "expected per-ontology export file");
370        let detail = fs::read_to_string(detail_path).expect("ontology markdown");
371        assert!(detail.contains("## Entities"), "ontology page should list entities");
372        assert!(!detail.contains("## Entities\n\n\n#"), "entity section should not be empty");
373        assert!(
374            index.contains(&format!("{entity_count} entities")),
375            "index should report exported entity count for example.ttl"
376        );
377    }
378
379    #[test]
380    fn markdown_index_includes_hierarchy_and_property_sections() {
381        let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
382        let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
383        let dir = tempfile::tempdir().unwrap();
384        export_workspace(&catalog, ExportOptions::markdown(dir.path())).expect("export");
385        let index = fs::read_to_string(dir.path().join("index.md")).expect("index.md");
386        assert!(index.contains("## Class hierarchy"), "index should include hierarchy");
387        assert!(index.contains("## Property index"), "index should include properties");
388    }
389
390    #[test]
391    fn render_class_hierarchy_lists_fixture_classes() {
392        let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
393        let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
394        let hierarchy = catalog.class_hierarchy();
395        let md = render_class_hierarchy(&catalog, &hierarchy);
396        assert!(md.contains("Person") || md.contains("people#Person"));
397    }
398
399    #[test]
400    fn render_property_index_lists_object_properties() {
401        let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
402        let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
403        let md = render_property_index(&catalog);
404        assert!(md.contains("Object properties"));
405        assert!(md.contains("worksFor") || md.contains("works for"));
406    }
407
408    #[test]
409    fn html_export_escapes_entity_labels() {
410        let ont = OntologyDoc {
411            id: "http://example.org/ex".to_string(),
412            slug: "ex".to_string(),
413            path: "evil.ttl".to_string(),
414            imports: vec![],
415            entities: vec![EntityDoc {
416                iri: "http://example.org/ex#Evil".to_string(),
417                short_name: "Evil".to_string(),
418                kind: "class".to_string(),
419                labels: vec!["<img src=x onerror=alert(1)>".to_string()],
420                comments: vec![],
421                parents: vec![],
422            }],
423        };
424        let html = render_ontology(&ont, ExportFormat::Html).expect("render html");
425        assert!(html.contains("&lt;img"));
426        assert!(!html.contains("<img src=x onerror=alert(1)>"));
427    }
428}