Skip to main content

dkp_core/pack/
loader.rs

1use std::path::{Path, PathBuf};
2
3use crate::{
4    error::{DkpError, DkpResult},
5    types::{
6        chunks::RetrievalChunk, constraints::ConstraintsFile, decision_trees::DecisionTreesFile,
7        eval::EvalCase, glossary::GlossaryFile, graph::KnowledgeGraph, manifest::Manifest,
8        ontology::OntologyFile, rules::RulesFile,
9    },
10};
11
12/// Lightweight handle to an open DKP pack directory.
13///
14/// `Pack::open()` reads only `manifest.json`. All other assets are loaded
15/// lazily by the commands that need them.
16#[derive(Debug)]
17pub struct Pack {
18    pub root: PathBuf,
19    pub manifest: Manifest,
20}
21
22impl Pack {
23    /// Open a pack directory or `.zip` archive. Reads and validates manifest.json.
24    pub fn open(path: impl AsRef<Path>) -> DkpResult<Self> {
25        let root = path.as_ref().to_path_buf();
26
27        if !root.exists() {
28            return Err(DkpError::PackNotFound(root));
29        }
30
31        let manifest_path = root.join("manifest.json");
32        if !manifest_path.exists() {
33            return Err(DkpError::ManifestMissing(root));
34        }
35
36        let bytes = std::fs::read(&manifest_path)?;
37        let manifest: Manifest =
38            serde_json::from_slice(&bytes).map_err(|e| DkpError::ManifestInvalid {
39                reason: e.to_string(),
40            })?;
41
42        validate_required_fields(&manifest)?;
43
44        Ok(Pack { root, manifest })
45    }
46
47    // ── Directory helpers ────────────────────────────────────────────────────
48
49    pub fn machine_dir(&self) -> PathBuf {
50        self.root.join("machine")
51    }
52    pub fn okf_dir(&self) -> PathBuf {
53        self.root.join("okf")
54    }
55    pub fn human_dir(&self) -> PathBuf {
56        self.root.join("human")
57    }
58    pub fn evidence_dir(&self) -> PathBuf {
59        self.root.join("evidence")
60    }
61    pub fn skills_dir(&self) -> PathBuf {
62        self.root.join("skills")
63    }
64    pub fn l10n_dir(&self) -> PathBuf {
65        self.root.join("l10n")
66    }
67    pub fn build_dir(&self) -> PathBuf {
68        self.root.join("build")
69    }
70    pub fn procedures_dir(&self) -> PathBuf {
71        self.machine_dir().join("procedures")
72    }
73    pub fn wasm_procedures_dir(&self) -> PathBuf {
74        self.machine_dir().join("procedures").join("src")
75    }
76
77    pub fn machine_file(&self, name: &str) -> PathBuf {
78        self.machine_dir().join(name)
79    }
80    pub fn evidence_file(&self, name: &str) -> PathBuf {
81        self.evidence_dir().join(name)
82    }
83
84    // ── Presence checks ──────────────────────────────────────────────────────
85
86    pub fn has_okf(&self) -> bool {
87        self.okf_dir().exists()
88    }
89    pub fn has_procedures(&self) -> bool {
90        self.procedures_dir().exists()
91    }
92    pub fn has_bundle_sig(&self) -> bool {
93        self.root.join("bundle.sig").exists()
94    }
95    pub fn has_eval_set(&self) -> bool {
96        self.machine_file("eval_set.jsonl").exists()
97    }
98    pub fn has_knowledge_graph(&self) -> bool {
99        self.machine_file("knowledge_graph.json").exists()
100    }
101    pub fn has_mcp_manifest(&self) -> bool {
102        self.machine_file("mcp_manifest.json").exists()
103    }
104    pub fn has_skills(&self) -> bool {
105        self.skills_dir().exists()
106    }
107    pub fn has_l10n(&self) -> bool {
108        self.l10n_dir().exists()
109    }
110    pub fn has_cross_refs(&self) -> bool {
111        self.machine_file("cross_refs.json").exists()
112    }
113    pub fn has_assets(&self) -> bool {
114        self.machine_file("assets.json").exists()
115    }
116    pub fn has_checksums(&self) -> bool {
117        self.root.join("checksums.json").exists()
118    }
119
120    pub fn mcp_enabled(&self) -> bool {
121        self.manifest.mcp.is_some()
122    }
123
124    // ── Asset loaders ────────────────────────────────────────────────────────
125
126    pub fn load_glossary(&self) -> DkpResult<Option<GlossaryFile>> {
127        load_json_optional(self, "glossary.json")
128    }
129
130    pub fn load_rules(&self) -> DkpResult<Option<RulesFile>> {
131        load_json_optional(self, "rules.json")
132    }
133
134    pub fn load_ontology(&self) -> DkpResult<Option<OntologyFile>> {
135        load_json_optional(self, "ontology.json")
136    }
137
138    pub fn load_constraints(&self) -> DkpResult<Option<ConstraintsFile>> {
139        load_json_optional(self, "constraints.json")
140    }
141
142    pub fn load_decision_trees(&self) -> DkpResult<Option<DecisionTreesFile>> {
143        load_json_optional(self, "decision_trees.json")
144    }
145
146    pub fn load_graph(&self) -> DkpResult<Option<KnowledgeGraph>> {
147        load_json_optional(self, "knowledge_graph.json")
148    }
149
150    pub fn load_chunks(&self) -> DkpResult<Vec<RetrievalChunk>> {
151        load_jsonl(self, "retrieval_chunks.jsonl")
152    }
153
154    pub fn load_eval_set(&self) -> DkpResult<Vec<EvalCase>> {
155        load_jsonl(self, "eval_set.jsonl")
156    }
157
158    pub fn load_system_prompt(&self) -> DkpResult<Option<String>> {
159        let path = self.machine_file("system_prompt.md");
160        if !path.exists() {
161            return Ok(None);
162        }
163        Ok(Some(std::fs::read_to_string(path)?))
164    }
165}
166
167// ── Private helpers ──────────────────────────────────────────────────────────
168
169fn validate_required_fields(m: &Manifest) -> DkpResult<()> {
170    macro_rules! require {
171        ($field:expr, $name:literal) => {
172            if $field.trim().is_empty() {
173                return Err(DkpError::ManifestFieldMissing { field: $name });
174            }
175        };
176    }
177    require!(m.spec, "spec");
178    require!(m.name, "name");
179    require!(m.version, "version");
180    require!(m.domain, "domain");
181    require!(m.audience, "audience");
182    require!(m.intended_use, "intended_use");
183    require!(m.known_limitations, "known_limitations");
184    require!(m.update_date, "update_date");
185
186    if let Err(e) = crate::domain::derive_and_validate_domain_slug(&m.domain) {
187        return Err(DkpError::ManifestDomainInvalid {
188            domain: m.domain.clone(),
189            reason: e.to_string(),
190        });
191    }
192
193    Ok(())
194}
195
196fn load_json_optional<T: serde::de::DeserializeOwned>(
197    pack: &Pack,
198    filename: &str,
199) -> DkpResult<Option<T>> {
200    let path = pack.machine_file(filename);
201    if !path.exists() {
202        return Ok(None);
203    }
204    let bytes = std::fs::read(&path)?;
205    let value = serde_json::from_slice(&bytes).map_err(|e| DkpError::AssetParse {
206        asset: filename.to_string(),
207        source: e,
208    })?;
209    Ok(Some(value))
210}
211
212fn load_jsonl<T: serde::de::DeserializeOwned>(pack: &Pack, filename: &str) -> DkpResult<Vec<T>> {
213    let path = pack.machine_file(filename);
214    if !path.exists() {
215        return Ok(vec![]);
216    }
217
218    let content = std::fs::read_to_string(&path)?;
219    let mut items = Vec::new();
220    let mut errors = Vec::new();
221
222    for (i, line) in content.lines().enumerate() {
223        let line = line.trim();
224        if line.is_empty() {
225            continue;
226        }
227        match serde_json::from_str::<T>(line) {
228            Ok(item) => items.push(item),
229            Err(e) => errors.push(DkpError::JsonlParse {
230                file: filename.to_string(),
231                line: i + 1,
232                reason: e.to_string(),
233            }),
234        }
235    }
236
237    if !errors.is_empty() {
238        return Err(errors.remove(0));
239    }
240
241    Ok(items)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use tempfile::TempDir;
248
249    fn minimal_manifest_json() -> &'static str {
250        r#"{
251            "spec": "1.0.0",
252            "name": "test-pack",
253            "version": "1.0.0",
254            "domain": "testing",
255            "audience": "internal",
256            "intended_use": "unit tests",
257            "known_limitations": "none",
258            "update_date": "2026-01-01"
259        }"#
260    }
261
262    fn write_manifest(dir: &std::path::Path, contents: &str) {
263        std::fs::write(dir.join("manifest.json"), contents).unwrap();
264    }
265
266    #[test]
267    fn open_nonexistent_dir_errors() {
268        let tmp = TempDir::new().unwrap();
269        let missing = tmp.path().join("does-not-exist");
270        let err = Pack::open(&missing).unwrap_err();
271        assert!(matches!(err, DkpError::PackNotFound(_)));
272    }
273
274    #[test]
275    fn open_missing_manifest_errors() {
276        let tmp = TempDir::new().unwrap();
277        let err = Pack::open(tmp.path()).unwrap_err();
278        assert!(matches!(err, DkpError::ManifestMissing(_)));
279    }
280
281    #[test]
282    fn open_invalid_manifest_json_errors() {
283        let tmp = TempDir::new().unwrap();
284        write_manifest(tmp.path(), "{ not valid json");
285        let err = Pack::open(tmp.path()).unwrap_err();
286        assert!(matches!(err, DkpError::ManifestInvalid { .. }));
287    }
288
289    #[test]
290    fn open_manifest_missing_required_field_errors() {
291        let tmp = TempDir::new().unwrap();
292        write_manifest(
293            tmp.path(),
294            r#"{
295                "spec": "1.0.0",
296                "name": "",
297                "version": "1.0.0",
298                "domain": "testing",
299                "audience": "internal",
300                "intended_use": "unit tests",
301                "known_limitations": "none",
302                "update_date": "2026-01-01"
303            }"#,
304        );
305        let err = Pack::open(tmp.path()).unwrap_err();
306        assert!(matches!(
307            err,
308            DkpError::ManifestFieldMissing { field: "name" }
309        ));
310    }
311
312    #[test]
313    fn open_manifest_invalid_domain_errors() {
314        // Reserved-word domains (e.g. "admin") are registry moderation
315        // policy, not a structural error, so `Pack::open` (local/offline)
316        // must accept them. Use a domain that slugifies to empty (too
317        // short) to exercise the structural-validity error path instead.
318        let tmp = TempDir::new().unwrap();
319        write_manifest(
320            tmp.path(),
321            r#"{
322                "spec": "1.0.0",
323                "name": "test-pack",
324                "version": "1.0.0",
325                "domain": "!!!",
326                "audience": "internal",
327                "intended_use": "unit tests",
328                "known_limitations": "none",
329                "update_date": "2026-01-01"
330            }"#,
331        );
332        let err = Pack::open(tmp.path()).unwrap_err();
333        assert!(matches!(err, DkpError::ManifestDomainInvalid { .. }));
334    }
335
336    #[test]
337    fn open_manifest_reserved_word_domain_succeeds_locally() {
338        let tmp = TempDir::new().unwrap();
339        write_manifest(
340            tmp.path(),
341            r#"{
342                "spec": "1.0.0",
343                "name": "test-pack",
344                "version": "1.0.0",
345                "domain": "Admin",
346                "audience": "internal",
347                "intended_use": "unit tests",
348                "known_limitations": "none",
349                "update_date": "2026-01-01"
350            }"#,
351        );
352        Pack::open(tmp.path()).expect("reserved-word domains are allowed for local builds");
353    }
354
355    #[test]
356    fn open_valid_minimal_manifest_succeeds() {
357        let tmp = TempDir::new().unwrap();
358        write_manifest(tmp.path(), minimal_manifest_json());
359        let pack = Pack::open(tmp.path()).unwrap();
360        assert_eq!(pack.manifest.name, "test-pack");
361        assert_eq!(pack.manifest.domain, "testing");
362        assert_eq!(pack.root, tmp.path());
363    }
364
365    #[test]
366    fn has_helpers_reflect_filesystem_state() {
367        let tmp = TempDir::new().unwrap();
368        write_manifest(tmp.path(), minimal_manifest_json());
369        let pack = Pack::open(tmp.path()).unwrap();
370
371        assert!(!pack.has_okf());
372        assert!(!pack.has_procedures());
373        assert!(!pack.has_bundle_sig());
374        assert!(!pack.has_eval_set());
375        assert!(!pack.has_knowledge_graph());
376        assert!(!pack.has_mcp_manifest());
377        assert!(!pack.has_skills());
378        assert!(!pack.has_l10n());
379        assert!(!pack.has_cross_refs());
380        assert!(!pack.has_assets());
381        assert!(!pack.has_checksums());
382        assert!(!pack.mcp_enabled());
383
384        std::fs::create_dir_all(pack.okf_dir()).unwrap();
385        std::fs::create_dir_all(pack.procedures_dir()).unwrap();
386        std::fs::write(pack.root.join("bundle.sig"), "sig").unwrap();
387        std::fs::write(pack.machine_file("eval_set.jsonl"), "").unwrap();
388        std::fs::write(pack.machine_file("knowledge_graph.json"), "{}").unwrap();
389        std::fs::write(pack.machine_file("mcp_manifest.json"), "{}").unwrap();
390        std::fs::create_dir_all(pack.skills_dir()).unwrap();
391        std::fs::create_dir_all(pack.l10n_dir()).unwrap();
392        std::fs::write(pack.machine_file("cross_refs.json"), "{}").unwrap();
393        std::fs::write(pack.machine_file("assets.json"), "{}").unwrap();
394        std::fs::write(pack.root.join("checksums.json"), "{}").unwrap();
395
396        assert!(pack.has_okf());
397        assert!(pack.has_procedures());
398        assert!(pack.has_bundle_sig());
399        assert!(pack.has_eval_set());
400        assert!(pack.has_knowledge_graph());
401        assert!(pack.has_mcp_manifest());
402        assert!(pack.has_skills());
403        assert!(pack.has_l10n());
404        assert!(pack.has_cross_refs());
405        assert!(pack.has_assets());
406        assert!(pack.has_checksums());
407    }
408
409    #[test]
410    fn load_json_optional_wrappers_absent_return_none() {
411        let tmp = TempDir::new().unwrap();
412        write_manifest(tmp.path(), minimal_manifest_json());
413        let pack = Pack::open(tmp.path()).unwrap();
414
415        assert!(pack.load_glossary().unwrap().is_none());
416        assert!(pack.load_rules().unwrap().is_none());
417        assert!(pack.load_ontology().unwrap().is_none());
418        assert!(pack.load_constraints().unwrap().is_none());
419        assert!(pack.load_decision_trees().unwrap().is_none());
420        assert!(pack.load_graph().unwrap().is_none());
421        assert!(pack.load_system_prompt().unwrap().is_none());
422    }
423
424    #[test]
425    fn load_glossary_valid_returns_some() {
426        let tmp = TempDir::new().unwrap();
427        write_manifest(tmp.path(), minimal_manifest_json());
428        let pack = Pack::open(tmp.path()).unwrap();
429        std::fs::create_dir_all(pack.machine_dir()).unwrap();
430        std::fs::write(
431            pack.machine_file("glossary.json"),
432            r#"{"terms": [{"id": "t1", "term": "Term", "definition": "def"}]}"#,
433        )
434        .unwrap();
435
436        let glossary = pack.load_glossary().unwrap().unwrap();
437        assert_eq!(glossary.terms.len(), 1);
438        assert_eq!(glossary.terms[0].id, "t1");
439    }
440
441    #[test]
442    fn load_glossary_invalid_json_errors() {
443        let tmp = TempDir::new().unwrap();
444        write_manifest(tmp.path(), minimal_manifest_json());
445        let pack = Pack::open(tmp.path()).unwrap();
446        std::fs::create_dir_all(pack.machine_dir()).unwrap();
447        std::fs::write(pack.machine_file("glossary.json"), "{ bad json").unwrap();
448
449        let err = pack.load_glossary().unwrap_err();
450        assert!(matches!(err, DkpError::AssetParse { .. }));
451    }
452
453    #[test]
454    fn load_chunks_absent_returns_empty_vec() {
455        let tmp = TempDir::new().unwrap();
456        write_manifest(tmp.path(), minimal_manifest_json());
457        let pack = Pack::open(tmp.path()).unwrap();
458        assert!(pack.load_chunks().unwrap().is_empty());
459    }
460
461    #[test]
462    fn load_chunks_valid_jsonl() {
463        let tmp = TempDir::new().unwrap();
464        write_manifest(tmp.path(), minimal_manifest_json());
465        let pack = Pack::open(tmp.path()).unwrap();
466        std::fs::create_dir_all(pack.machine_dir()).unwrap();
467        let jsonl = concat!(
468            r#"{"id":"c1","title":"T1","chunk_text":"body one","source_ref":"generated"}"#,
469            "\n",
470            r#"{"id":"c2","title":"T2","chunk_text":"body two","source_ref":"generated"}"#,
471            "\n",
472        );
473        std::fs::write(pack.machine_file("retrieval_chunks.jsonl"), jsonl).unwrap();
474
475        let chunks = pack.load_chunks().unwrap();
476        assert_eq!(chunks.len(), 2);
477        assert_eq!(chunks[0].id, "c1");
478        assert_eq!(chunks[1].id, "c2");
479    }
480
481    #[test]
482    fn load_chunks_invalid_line_errors() {
483        let tmp = TempDir::new().unwrap();
484        write_manifest(tmp.path(), minimal_manifest_json());
485        let pack = Pack::open(tmp.path()).unwrap();
486        std::fs::create_dir_all(pack.machine_dir()).unwrap();
487        std::fs::write(pack.machine_file("retrieval_chunks.jsonl"), "not json\n").unwrap();
488
489        let err = pack.load_chunks().unwrap_err();
490        assert!(matches!(err, DkpError::JsonlParse { .. }));
491    }
492
493    #[test]
494    fn load_system_prompt_present() {
495        let tmp = TempDir::new().unwrap();
496        write_manifest(tmp.path(), minimal_manifest_json());
497        let pack = Pack::open(tmp.path()).unwrap();
498        std::fs::create_dir_all(pack.machine_dir()).unwrap();
499        std::fs::write(pack.machine_file("system_prompt.md"), "Be helpful.").unwrap();
500
501        assert_eq!(
502            pack.load_system_prompt().unwrap(),
503            Some("Be helpful.".to_string())
504        );
505    }
506}