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#[derive(Debug)]
17pub struct Pack {
18 pub root: PathBuf,
19 pub manifest: Manifest,
20}
21
22impl Pack {
23 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 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 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 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
167fn 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 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}