Skip to main content

dkp_core/okf/
exporter.rs

1use std::path::Path;
2
3use crate::{error::DkpResult, pack::loader::Pack};
4
5/// Generate the `okf/` layer from the `machine/` layer assets.
6///
7/// Each asset type maps to a subdirectory of concept Markdown files with
8/// the required DKP OKF frontmatter fields.
9pub fn export_okf(pack: &Pack, output_dir: &Path) -> DkpResult<ExportStats> {
10    let mut stats = ExportStats::default();
11
12    // terms/ from glossary.json
13    if let Some(gf) = pack.load_glossary()? {
14        let dir = output_dir.join("terms");
15        std::fs::create_dir_all(&dir)?;
16        for t in &gf.terms {
17            let mut fm = String::from("---\n");
18            fm.push_str(&format!("id: {}\n", t.id));
19            fm.push_str("type: term\n");
20            fm.push_str(&format!("term: \"{}\"\n", escape_yaml(&t.term)));
21            if !t.aliases.is_empty() {
22                fm.push_str(&format!(
23                    "aliases: [{}]\n",
24                    t.aliases
25                        .iter()
26                        .map(|a| format!("\"{}\"", escape_yaml(a)))
27                        .collect::<Vec<_>>()
28                        .join(", ")
29                ));
30            }
31            if !t.tags.is_empty() {
32                fm.push_str(&format!(
33                    "tags: [{}]\n",
34                    t.tags
35                        .iter()
36                        .map(|t| format!("\"{}\"", escape_yaml(t)))
37                        .collect::<Vec<_>>()
38                        .join(", ")
39                ));
40            }
41            if let Some(s) = &t.stability {
42                fm.push_str(&format!("stability: {s}\n"));
43            }
44            if let Some(sr) = &t.source_ref {
45                fm.push_str(&format!("source_ref: {sr}\n"));
46            }
47            fm.push_str("---\n\n");
48            fm.push_str(&format!("# {}\n\n", t.term));
49            fm.push_str(&t.definition);
50            fm.push('\n');
51
52            std::fs::write(dir.join(format!("{}.md", t.id)), &fm)?;
53            stats.terms_written += 1;
54        }
55    }
56
57    // rules/ from rules.json
58    if let Some(rf) = pack.load_rules()? {
59        let dir = output_dir.join("rules");
60        std::fs::create_dir_all(&dir)?;
61        for r in &rf.rules {
62            let polarity = format!("{:?}", r.polarity).to_lowercase();
63            let mut fm = String::from("---\n");
64            fm.push_str(&format!("id: {}\n", r.id));
65            fm.push_str("type: rule\n");
66            fm.push_str(&format!("title: \"{}\"\n", escape_yaml(&r.title)));
67            fm.push_str(&format!("polarity: {polarity}\n"));
68            if let Some(c) = r.confidence {
69                fm.push_str(&format!("confidence: {c}\n"));
70            }
71            if let Some(s) = &r.stability {
72                fm.push_str(&format!("stability: {s}\n"));
73            }
74            if let Some(sr) = &r.source_ref {
75                fm.push_str(&format!("source_ref: {sr}\n"));
76            }
77            if !r.tags.is_empty() {
78                fm.push_str(&format!(
79                    "tags: [{}]\n",
80                    r.tags
81                        .iter()
82                        .map(|t| format!("\"{}\"", escape_yaml(t)))
83                        .collect::<Vec<_>>()
84                        .join(", ")
85                ));
86            }
87            fm.push_str("---\n\n");
88            fm.push_str(&format!("# {}\n\n", r.title));
89            fm.push_str(&r.description);
90            fm.push('\n');
91
92            std::fs::write(dir.join(format!("{}.md", r.id)), &fm)?;
93            stats.rules_written += 1;
94        }
95    }
96
97    // constraints/ from constraints.json
98    if let Some(cf) = pack.load_constraints()? {
99        let dir = output_dir.join("constraints");
100        std::fs::create_dir_all(&dir)?;
101        for c in cf.all_constraints() {
102            let mut fm = String::from("---\n");
103            fm.push_str(&format!("id: {}\n", c.id));
104            fm.push_str("type: constraint\n");
105            fm.push_str(&format!("title: \"{}\"\n", escape_yaml(&c.title)));
106            if let Some(s) = &c.stability {
107                fm.push_str(&format!("stability: {s}\n"));
108            }
109            if let Some(sr) = &c.source_ref {
110                fm.push_str(&format!("source_ref: {sr}\n"));
111            }
112            if !c.tags.is_empty() {
113                fm.push_str(&format!(
114                    "tags: [{}]\n",
115                    c.tags
116                        .iter()
117                        .map(|t| format!("\"{}\"", escape_yaml(t)))
118                        .collect::<Vec<_>>()
119                        .join(", ")
120                ));
121            }
122            fm.push_str("---\n\n");
123            fm.push_str(&format!("# {}\n\n", c.title));
124            fm.push_str(&c.description);
125            fm.push('\n');
126
127            std::fs::write(dir.join(format!("{}.md", c.id)), &fm)?;
128            stats.constraints_written += 1;
129        }
130    }
131
132    // chunks/ from retrieval_chunks.jsonl
133    {
134        let chunks = pack.load_chunks()?;
135        if !chunks.is_empty() {
136            let dir = output_dir.join("chunks");
137            std::fs::create_dir_all(&dir)?;
138            for c in &chunks {
139                let mut fm = String::from("---\n");
140                fm.push_str(&format!("id: {}\n", c.id));
141                fm.push_str("type: chunk\n");
142                fm.push_str(&format!("title: \"{}\"\n", escape_yaml(&c.title)));
143                fm.push_str(&format!("source_ref: {}\n", c.source_ref));
144                if let Some(conf) = c.confidence {
145                    fm.push_str(&format!("confidence: {conf}\n"));
146                }
147                if !c.tags.is_empty() {
148                    fm.push_str(&format!(
149                        "tags: [{}]\n",
150                        c.tags
151                            .iter()
152                            .map(|t| format!("\"{}\"", escape_yaml(t)))
153                            .collect::<Vec<_>>()
154                            .join(", ")
155                    ));
156                }
157                fm.push_str("---\n\n");
158                fm.push_str(&format!("# {}\n\n", c.title));
159                fm.push_str(&c.chunk_text);
160                fm.push('\n');
161
162                std::fs::write(dir.join(format!("{}.md", c.id)), &fm)?;
163                stats.chunks_written += 1;
164            }
165        }
166    }
167
168    // ontology/ from ontology.json
169    if let Some(of) = pack.load_ontology()? {
170        let dir = output_dir.join("ontology");
171        std::fs::create_dir_all(&dir)?;
172        for e in &of.entity_types {
173            let mut fm = String::from("---\n");
174            fm.push_str(&format!("id: {}\n", e.id));
175            fm.push_str("type: entity\n");
176            fm.push_str(&format!("name: \"{}\"\n", escape_yaml(&e.name)));
177            if let Some(st) = &e.schema_org_type {
178                fm.push_str(&format!("schema_org_type: {st}\n"));
179            }
180            if !e.attributes.is_empty() {
181                fm.push_str(&format!(
182                    "attributes: [{}]\n",
183                    e.attributes
184                        .iter()
185                        .map(|a| format!("\"{}\"", escape_yaml(a)))
186                        .collect::<Vec<_>>()
187                        .join(", ")
188                ));
189            }
190            fm.push_str("---\n\n");
191            fm.push_str(&format!("# {}\n\n", e.name));
192            fm.push_str(&e.description);
193            fm.push('\n');
194
195            std::fs::write(dir.join(format!("{}.md", e.id)), &fm)?;
196            stats.ontology_written += 1;
197        }
198    }
199
200    Ok(stats)
201}
202
203fn escape_yaml(s: &str) -> String {
204    s.replace('"', "\\\"")
205}
206
207#[derive(Debug, Default)]
208pub struct ExportStats {
209    pub terms_written: usize,
210    pub rules_written: usize,
211    pub constraints_written: usize,
212    pub chunks_written: usize,
213    pub ontology_written: usize,
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::okf::parser::parse_okf_dir;
220    use tempfile::TempDir;
221
222    fn open_pack_with_glossary(tmp: &TempDir) -> Pack {
223        std::fs::write(
224            tmp.path().join("manifest.json"),
225            r#"{
226                "spec": "1.0.0",
227                "name": "test-pack",
228                "version": "1.0.0",
229                "domain": "testing",
230                "audience": "internal",
231                "intended_use": "unit tests",
232                "known_limitations": "none",
233                "update_date": "2026-01-01"
234            }"#,
235        )
236        .unwrap();
237        let pack = Pack::open(tmp.path()).unwrap();
238        std::fs::create_dir_all(pack.machine_dir()).unwrap();
239        std::fs::write(
240            pack.machine_file("glossary.json"),
241            r#"{"terms": [{"id": "t1", "term": "Widget \"Pro\"", "definition": "A useful thing.", "tags": ["core"]}]}"#,
242        )
243        .unwrap();
244        pack
245    }
246
247    #[test]
248    fn export_then_parse_round_trip_preserves_content() {
249        let tmp = TempDir::new().unwrap();
250        let pack = open_pack_with_glossary(&tmp);
251        let out_dir = tmp.path().join("okf-out");
252
253        let stats = export_okf(&pack, &out_dir).unwrap();
254        assert_eq!(stats.terms_written, 1);
255
256        let concepts = parse_okf_dir(&out_dir).unwrap();
257        assert_eq!(concepts.len(), 1);
258        let concept = &concepts[0];
259        assert_eq!(
260            concept.frontmatter.get("type").and_then(|v| v.as_str()),
261            Some("term")
262        );
263        assert_eq!(
264            concept.frontmatter.get("id").and_then(|v| v.as_str()),
265            Some("t1")
266        );
267        assert!(concept.body.contains("A useful thing."));
268    }
269
270    #[test]
271    fn export_okf_empty_pack_writes_nothing() {
272        let tmp = TempDir::new().unwrap();
273        std::fs::write(
274            tmp.path().join("manifest.json"),
275            r#"{
276                "spec": "1.0.0",
277                "name": "test-pack",
278                "version": "1.0.0",
279                "domain": "testing",
280                "audience": "internal",
281                "intended_use": "unit tests",
282                "known_limitations": "none",
283                "update_date": "2026-01-01"
284            }"#,
285        )
286        .unwrap();
287        let pack = Pack::open(tmp.path()).unwrap();
288        let out_dir = tmp.path().join("okf-out");
289
290        let stats = export_okf(&pack, &out_dir).unwrap();
291        assert_eq!(stats.terms_written, 0);
292        assert_eq!(stats.rules_written, 0);
293        assert_eq!(stats.chunks_written, 0);
294    }
295}