1use std::collections::HashSet;
2
3use crate::{
4 pack::loader::Pack,
5 procedures,
6 validate::gates::{CheckResult, GateResult, GateStatus},
7};
8
9const REQUIRED_MACHINE_FILES: &[&str] = &[
10 "system_prompt.md",
11 "glossary.json",
12 "ontology.json",
13 "rules.json",
14 "constraints.json",
15 "retrieval_chunks.jsonl",
16];
17
18pub fn run(pack: &Pack) -> GateResult {
20 let mut checks = Vec::new();
21
22 for file in REQUIRED_MACHINE_FILES {
24 if pack.machine_file(file).exists() {
25 checks.push(CheckResult::pass(format!("machine/{file} present")));
26 } else {
27 checks.push(CheckResult::fail(
28 format!("machine/{file} present"),
29 format!("machine/{file} is required but not found"),
30 ));
31 }
32 }
33
34 checks.push(CheckResult::pass("manifest.json present"));
36
37 for (name, result) in [
39 ("glossary.json", pack.load_glossary().map(|_| ())),
40 ("ontology.json", pack.load_ontology().map(|_| ())),
41 ("rules.json", pack.load_rules().map(|_| ())),
42 ("constraints.json", pack.load_constraints().map(|_| ())),
43 ("retrieval_chunks.jsonl", pack.load_chunks().map(|_| ())),
44 ] {
45 match result {
46 Ok(_) => checks.push(CheckResult::pass(format!("machine/{name} parses"))),
47 Err(e) => checks.push(CheckResult::fail(
48 format!("machine/{name} parses"),
49 e.to_string(),
50 )),
51 }
52 }
53
54 let sources_csv = pack.evidence_file("sources.csv");
56 if sources_csv.exists() {
57 let known_ids: HashSet<String> = std::fs::read_to_string(&sources_csv)
58 .unwrap_or_default()
59 .lines()
60 .skip(1) .filter_map(|line| {
62 line.split(',')
63 .next()
64 .map(|s| s.trim().trim_matches('"').to_string())
65 })
66 .filter(|s| !s.is_empty())
67 .collect();
68
69 let mut unresolved: Vec<String> = Vec::new();
70
71 if let Ok(Some(gf)) = pack.load_glossary() {
72 for t in &gf.terms {
73 if let Some(ref sr) = t.source_ref
74 && sr != "generated"
75 && !known_ids.contains(sr.as_str())
76 {
77 unresolved.push(format!("term/{}: {sr}", t.id));
78 }
79 }
80 }
81 if let Ok(Some(rf)) = pack.load_rules() {
82 for r in &rf.rules {
83 if let Some(ref sr) = r.source_ref
84 && sr != "generated"
85 && !known_ids.contains(sr.as_str())
86 {
87 unresolved.push(format!("rule/{}: {sr}", r.id));
88 }
89 }
90 }
91 if let Ok(Some(cf)) = pack.load_constraints() {
92 for c in cf.all_constraints() {
93 if let Some(ref sr) = c.source_ref
94 && sr != "generated"
95 && !known_ids.contains(sr.as_str())
96 {
97 unresolved.push(format!("constraint/{}: {sr}", c.id));
98 }
99 }
100 }
101
102 if unresolved.is_empty() {
103 checks.push(CheckResult::pass("source_ref resolution"));
104 } else {
105 checks.push(CheckResult::fail(
106 "source_ref resolution",
107 format!("unresolved refs: {}", unresolved.join(", ")),
108 ));
109 }
110 }
111
112 if pack.has_knowledge_graph() {
114 match pack.load_graph() {
115 Ok(Some(graph)) => {
116 let node_ids: HashSet<&str> = graph.nodes.iter().map(|n| n.id.as_str()).collect();
117 let broken: Vec<String> = graph
118 .edges
119 .iter()
120 .filter_map(|e| {
121 let src_ok = node_ids.contains(e.source.as_str());
122 let tgt_ok = node_ids.contains(e.target.as_str());
123 if !src_ok || !tgt_ok {
124 Some(format!("{}->{}", e.source, e.target))
125 } else {
126 None
127 }
128 })
129 .collect();
130 if broken.is_empty() {
131 checks.push(CheckResult::pass("knowledge_graph edge resolution"));
132 } else {
133 checks.push(CheckResult::fail(
134 "knowledge_graph edge resolution",
135 format!("broken edges: {}", broken.join(", ")),
136 ));
137 }
138 }
139 Ok(None) => checks.push(CheckResult::skip("knowledge_graph.json (not present)")),
140 Err(e) => checks.push(CheckResult::fail(
141 "knowledge_graph.json parses",
142 e.to_string(),
143 )),
144 }
145 }
146
147 if pack.has_procedures() {
149 match procedures::validate_all(pack) {
150 Ok(errors) if errors.is_empty() => {
151 checks.push(CheckResult::pass("machine/procedures/ completeness"));
152 }
153 Ok(errors) => {
154 for e in &errors {
155 checks.push(CheckResult::fail(
156 "machine/procedures/ completeness",
157 e.clone(),
158 ));
159 }
160 }
161 Err(e) => {
162 checks.push(CheckResult::fail(
163 "machine/procedures/ completeness",
164 e.to_string(),
165 ));
166 }
167 }
168 }
169
170 if pack.manifest.mcp.is_some() {
172 if pack.machine_file("mcp_manifest.json").exists() {
173 checks.push(CheckResult::pass(
174 "machine/mcp_manifest.json present (mcp configured)",
175 ));
176 } else {
177 checks.push(CheckResult::fail(
178 "machine/mcp_manifest.json present (mcp configured)",
179 "manifest.mcp is set but machine/mcp_manifest.json is missing",
180 ));
181 }
182 }
183
184 let failed = checks.iter().any(|c| c.status == GateStatus::Fail);
185 GateResult {
186 gate: 4,
187 status: if failed {
188 GateStatus::Fail
189 } else {
190 GateStatus::Pass
191 },
192 checks,
193 message: None,
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use tempfile::TempDir;
201
202 fn minimal_manifest_json() -> &'static str {
203 r#"{
204 "spec": "1.0.0",
205 "name": "test-pack",
206 "version": "1.0.0",
207 "domain": "testing",
208 "audience": "internal",
209 "intended_use": "unit tests",
210 "known_limitations": "none",
211 "update_date": "2026-01-01"
212 }"#
213 }
214
215 fn complete_pack(tmp: &TempDir) -> Pack {
218 std::fs::write(tmp.path().join("manifest.json"), minimal_manifest_json()).unwrap();
219 let machine = tmp.path().join("machine");
220 std::fs::create_dir_all(&machine).unwrap();
221 std::fs::write(machine.join("system_prompt.md"), "Be helpful.").unwrap();
222 std::fs::write(machine.join("glossary.json"), r#"{"terms": []}"#).unwrap();
223 std::fs::write(machine.join("ontology.json"), r#"{"entity_types": []}"#).unwrap();
224 std::fs::write(machine.join("rules.json"), r#"{"rules": []}"#).unwrap();
225 std::fs::write(
226 machine.join("constraints.json"),
227 r#"{"edge_cases": [], "anti_patterns": [], "hard_limits": []}"#,
228 )
229 .unwrap();
230 std::fs::write(machine.join("retrieval_chunks.jsonl"), "").unwrap();
231 Pack::open(tmp.path()).unwrap()
232 }
233
234 #[test]
235 fn all_required_files_present_and_valid_passes() {
236 let tmp = TempDir::new().unwrap();
237 let pack = complete_pack(&tmp);
238 let result = run(&pack);
239 assert_eq!(result.status, GateStatus::Pass);
240 assert_eq!(result.gate, 4);
241 }
242
243 #[test]
244 fn missing_required_file_fails() {
245 let tmp = TempDir::new().unwrap();
246 let pack = complete_pack(&tmp);
247 std::fs::remove_file(pack.machine_file("glossary.json")).unwrap();
248
249 let result = run(&pack);
250 assert_eq!(result.status, GateStatus::Fail);
251 assert!(
252 result
253 .checks
254 .iter()
255 .any(|c| c.description.contains("glossary.json") && c.status == GateStatus::Fail)
256 );
257 }
258
259 #[test]
260 fn invalid_json_in_machine_file_fails() {
261 let tmp = TempDir::new().unwrap();
262 let pack = complete_pack(&tmp);
263 std::fs::write(pack.machine_file("rules.json"), "{ not valid").unwrap();
264
265 let result = run(&pack);
266 assert_eq!(result.status, GateStatus::Fail);
267 assert!(
268 result
269 .checks
270 .iter()
271 .any(|c| c.description.contains("rules.json") && c.status == GateStatus::Fail)
272 );
273 }
274
275 #[test]
276 fn sources_csv_absent_skips_source_ref_check() {
277 let tmp = TempDir::new().unwrap();
278 let pack = complete_pack(&tmp);
279 let result = run(&pack);
280 assert!(
281 !result
282 .checks
283 .iter()
284 .any(|c| c.description == "source_ref resolution")
285 );
286 }
287
288 #[test]
289 fn unresolved_source_ref_fails() {
290 let tmp = TempDir::new().unwrap();
291 let pack = complete_pack(&tmp);
292 std::fs::write(
293 pack.machine_file("glossary.json"),
294 r#"{"terms": [{"id": "t1", "term": "Term", "definition": "def", "source_ref": "src-1"}]}"#,
295 )
296 .unwrap();
297 std::fs::create_dir_all(pack.evidence_dir()).unwrap();
298 std::fs::write(pack.evidence_file("sources.csv"), "id,title\n").unwrap();
299
300 let result = run(&pack);
301 assert_eq!(result.status, GateStatus::Fail);
302 assert!(
303 result
304 .checks
305 .iter()
306 .any(|c| c.description == "source_ref resolution" && c.status == GateStatus::Fail)
307 );
308 }
309
310 #[test]
311 fn source_ref_generated_is_always_allowed() {
312 let tmp = TempDir::new().unwrap();
313 let pack = complete_pack(&tmp);
314 std::fs::write(
315 pack.machine_file("glossary.json"),
316 r#"{"terms": [{"id": "t1", "term": "Term", "definition": "def", "source_ref": "generated"}]}"#,
317 )
318 .unwrap();
319 std::fs::create_dir_all(pack.evidence_dir()).unwrap();
320 std::fs::write(pack.evidence_file("sources.csv"), "id,title\n").unwrap();
321
322 let result = run(&pack);
323 assert_eq!(result.status, GateStatus::Pass);
324 }
325
326 #[test]
327 fn resolved_source_ref_passes() {
328 let tmp = TempDir::new().unwrap();
329 let pack = complete_pack(&tmp);
330 std::fs::write(
331 pack.machine_file("glossary.json"),
332 r#"{"terms": [{"id": "t1", "term": "Term", "definition": "def", "source_ref": "src-1"}]}"#,
333 )
334 .unwrap();
335 std::fs::create_dir_all(pack.evidence_dir()).unwrap();
336 std::fs::write(
337 pack.evidence_file("sources.csv"),
338 "id,title\nsrc-1,Some Source\n",
339 )
340 .unwrap();
341
342 let result = run(&pack);
343 assert_eq!(result.status, GateStatus::Pass);
344 }
345
346 #[test]
347 fn knowledge_graph_broken_edge_fails() {
348 let tmp = TempDir::new().unwrap();
349 let pack = complete_pack(&tmp);
350 std::fs::write(
351 pack.machine_file("knowledge_graph.json"),
352 r#"{"nodes": [{"id": "n1", "node_type": "concept", "label": "N1"}], "edges": [{"source": "n1", "relation": "see-also", "target": "missing"}]}"#,
353 )
354 .unwrap();
355
356 let result = run(&pack);
357 assert_eq!(result.status, GateStatus::Fail);
358 assert!(result.checks.iter().any(|c| {
359 c.description.contains("knowledge_graph edge resolution")
360 && c.status == GateStatus::Fail
361 }));
362 }
363
364 #[test]
365 fn knowledge_graph_resolved_edges_pass() {
366 let tmp = TempDir::new().unwrap();
367 let pack = complete_pack(&tmp);
368 std::fs::write(
369 pack.machine_file("knowledge_graph.json"),
370 r#"{"nodes": [{"id": "n1", "node_type": "concept", "label": "N1"}, {"id": "n2", "node_type": "concept", "label": "N2"}], "edges": [{"source": "n1", "relation": "see-also", "target": "n2"}]}"#,
371 )
372 .unwrap();
373
374 let result = run(&pack);
375 assert_eq!(result.status, GateStatus::Pass);
376 }
377}