Skip to main content

apcore_toolkit/output/
verifiers.rs

1// Built-in verifiers for output writers.
2//
3// Each verifier implements the Verifier trait and checks a specific
4// aspect of a written artifact.
5
6use std::fs;
7use std::panic::{catch_unwind, AssertUnwindSafe};
8
9use crate::output::types::{Verifier, VerifyResult};
10
11/// Verify that a Rust source file has valid syntax.
12///
13/// Uses the `syn` crate to parse the file as a Rust source file.
14/// Analogous to `SyntaxVerifier` in Python (which uses `ast.parse`)
15/// and TypeScript (which does a basic readability check).
16pub struct SyntaxVerifier;
17
18impl Verifier for SyntaxVerifier {
19    fn verify(&self, path: &str, _module_id: &str) -> VerifyResult {
20        if path.is_empty() {
21            return VerifyResult::ok();
22        }
23        let content = match fs::read_to_string(path) {
24            Ok(c) => c,
25            Err(e) => return VerifyResult::fail(format!("Cannot read file: {e}")),
26        };
27
28        if content.trim().is_empty() {
29            return VerifyResult::fail("File is empty".into());
30        }
31
32        match syn::parse_file(&content) {
33            Ok(_) => VerifyResult::ok(),
34            Err(e) => VerifyResult::fail(format!("Invalid Rust syntax: {e}")),
35        }
36    }
37}
38
39/// Verify that a YAML binding file is parseable and contains required fields.
40pub struct YAMLVerifier;
41
42impl Verifier for YAMLVerifier {
43    fn verify(&self, path: &str, _module_id: &str) -> VerifyResult {
44        if path.is_empty() {
45            return VerifyResult::ok();
46        }
47        let content = match fs::read_to_string(path) {
48            Ok(c) => c,
49            Err(e) => return VerifyResult::fail(format!("Cannot read file: {e}")),
50        };
51
52        let parsed: serde_yaml_ng::Value = match serde_yaml_ng::from_str(&content) {
53            Ok(v) => v,
54            Err(e) => return VerifyResult::fail(format!("Invalid YAML: {e}")),
55        };
56
57        let bindings = match parsed.get("bindings") {
58            Some(b) => b,
59            None => return VerifyResult::fail("Missing or empty 'bindings' list".into()),
60        };
61
62        let bindings_seq = match bindings.as_sequence() {
63            Some(s) if !s.is_empty() => s,
64            _ => return VerifyResult::fail("Missing or empty 'bindings' list".into()),
65        };
66
67        for (i, entry) in bindings_seq.iter().enumerate() {
68            for field in &["module_id", "target"] {
69                match entry.get(*field) {
70                    Some(v) if !v.is_null() => {}
71                    _ => {
72                        return VerifyResult::fail(format!(
73                            "Missing required field '{field}' in binding at index {i}"
74                        ))
75                    }
76                }
77            }
78        }
79
80        VerifyResult::ok()
81    }
82}
83
84/// Verify that a file contains valid JSON, with optional schema validation.
85pub struct JSONVerifier {
86    // Schema validation is omitted (would require jsonschema crate).
87    // Only checks that the file is valid JSON.
88}
89
90impl JSONVerifier {
91    /// Create a new JSONVerifier.
92    pub fn new() -> Self {
93        Self {}
94    }
95}
96
97impl Default for JSONVerifier {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103impl Verifier for JSONVerifier {
104    fn verify(&self, path: &str, _module_id: &str) -> VerifyResult {
105        if path.is_empty() {
106            return VerifyResult::ok();
107        }
108        let content = match fs::read_to_string(path) {
109            Ok(c) => c,
110            Err(e) => return VerifyResult::fail(format!("Cannot read file: {e}")),
111        };
112
113        match serde_json::from_str::<serde_json::Value>(&content) {
114            Ok(_) => VerifyResult::ok(),
115            Err(e) => VerifyResult::fail(format!("Invalid JSON: {e}")),
116        }
117    }
118}
119
120/// Verify that a file starts with expected magic bytes.
121pub struct MagicBytesVerifier {
122    expected: Vec<u8>,
123}
124
125impl MagicBytesVerifier {
126    /// Create a new MagicBytesVerifier with the expected byte sequence.
127    pub fn new(expected: Vec<u8>) -> Self {
128        Self { expected }
129    }
130}
131
132impl Verifier for MagicBytesVerifier {
133    fn verify(&self, path: &str, _module_id: &str) -> VerifyResult {
134        if path.is_empty() {
135            return VerifyResult::ok();
136        }
137        let content = match fs::read(path) {
138            Ok(c) => c,
139            Err(e) => return VerifyResult::fail(format!("Cannot read file: {e}")),
140        };
141
142        if content.len() < self.expected.len() {
143            return VerifyResult::fail(format!(
144                "File too short: expected at least {} bytes, got {}",
145                self.expected.len(),
146                content.len()
147            ));
148        }
149
150        let header = &content[..self.expected.len()];
151        if header != self.expected.as_slice() {
152            return VerifyResult::fail(format!(
153                "Magic bytes mismatch: expected {:?}, got {:?}",
154                self.expected, header
155            ));
156        }
157
158        VerifyResult::ok()
159    }
160}
161
162/// Verify that a module is registered and retrievable from a registry.
163pub struct RegistryVerifier<'a> {
164    registry: &'a apcore::Registry,
165}
166
167impl<'a> RegistryVerifier<'a> {
168    /// Create a new RegistryVerifier checking against the given registry.
169    pub fn new(registry: &'a apcore::Registry) -> Self {
170        Self { registry }
171    }
172}
173
174impl Verifier for RegistryVerifier<'_> {
175    fn verify(&self, _path: &str, module_id: &str) -> VerifyResult {
176        if self.registry.has(module_id) {
177            VerifyResult::ok()
178        } else {
179            VerifyResult::fail(format!(
180                "Module '{module_id}' not found in registry after registration"
181            ))
182        }
183    }
184}
185
186/// Run verifiers in order; stop on first failure.
187///
188/// Each verifier call is wrapped in `catch_unwind` so that a panicking
189/// verifier does not crash the caller. A panic is reported as a
190/// `VerifyResult::fail` with a descriptive message.
191pub fn run_verifier_chain(
192    verifiers: &[&dyn Verifier],
193    path: &str,
194    module_id: &str,
195) -> VerifyResult {
196    for verifier in verifiers {
197        let verifier = AssertUnwindSafe(verifier);
198        let path = path.to_string();
199        let module_id = module_id.to_string();
200        let outcome = catch_unwind(move || verifier.verify(&path, &module_id));
201        match outcome {
202            Ok(result) if !result.ok => return result,
203            Ok(_) => {} // passed, continue
204            Err(panic_info) => {
205                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
206                    (*s).to_string()
207                } else if let Some(s) = panic_info.downcast_ref::<String>() {
208                    s.clone()
209                } else {
210                    "unknown panic".to_string()
211                };
212                return VerifyResult::fail(format!("Verifier crashed: {msg}"));
213            }
214        }
215    }
216    VerifyResult::ok()
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use std::io::Write;
223    use tempfile::NamedTempFile;
224
225    // ---- empty-path contract (RegistryWriter compatibility) ----
226
227    #[test]
228    fn test_syntax_verifier_empty_path_passes() {
229        assert!(SyntaxVerifier.verify("", "mod").ok);
230    }
231
232    #[test]
233    fn test_yaml_verifier_empty_path_passes() {
234        assert!(YAMLVerifier.verify("", "mod").ok);
235    }
236
237    #[test]
238    fn test_json_verifier_empty_path_passes() {
239        assert!(JSONVerifier::new().verify("", "mod").ok);
240    }
241
242    #[test]
243    fn test_magic_bytes_verifier_empty_path_passes() {
244        let v = MagicBytesVerifier::new(b"PNG".to_vec());
245        assert!(v.verify("", "mod").ok);
246    }
247
248    #[test]
249    fn test_yaml_verifier_valid() {
250        let mut f = NamedTempFile::new().unwrap();
251        writeln!(f, "bindings:\n  - module_id: test\n    target: app:func").unwrap();
252        let result = YAMLVerifier.verify(f.path().to_str().unwrap(), "test");
253        assert!(result.ok);
254    }
255
256    #[test]
257    fn test_yaml_verifier_invalid_yaml() {
258        let mut f = NamedTempFile::new().unwrap();
259        writeln!(f, "{{invalid: yaml: [}}").unwrap();
260        let result = YAMLVerifier.verify(f.path().to_str().unwrap(), "test");
261        assert!(!result.ok);
262        assert!(result.error.unwrap().contains("Invalid YAML"));
263    }
264
265    #[test]
266    fn test_yaml_verifier_missing_bindings() {
267        let mut f = NamedTempFile::new().unwrap();
268        writeln!(f, "other_key: value").unwrap();
269        let result = YAMLVerifier.verify(f.path().to_str().unwrap(), "test");
270        assert!(!result.ok);
271    }
272
273    #[test]
274    fn test_yaml_verifier_multi_binding_second_entry_missing_target() {
275        let mut f = NamedTempFile::new().unwrap();
276        writeln!(
277            f,
278            "bindings:\n  - module_id: first\n    target: app:fn1\n  - module_id: second"
279        )
280        .unwrap();
281        let result = YAMLVerifier.verify(f.path().to_str().unwrap(), "test");
282        assert!(!result.ok, "should fail when second entry lacks 'target'");
283        assert!(
284            result.error.unwrap().contains("target"),
285            "error should mention missing field"
286        );
287    }
288
289    #[test]
290    fn test_yaml_verifier_multi_binding_all_valid() {
291        let mut f = NamedTempFile::new().unwrap();
292        writeln!(
293            f,
294            "bindings:\n  - module_id: first\n    target: app:fn1\n  - module_id: second\n    target: app:fn2"
295        )
296        .unwrap();
297        let result = YAMLVerifier.verify(f.path().to_str().unwrap(), "test");
298        assert!(result.ok, "should pass when all entries are valid");
299    }
300
301    #[test]
302    fn test_yaml_verifier_missing_required_field() {
303        let mut f = NamedTempFile::new().unwrap();
304        writeln!(f, "bindings:\n  - module_id: test").unwrap();
305        let result = YAMLVerifier.verify(f.path().to_str().unwrap(), "test");
306        assert!(!result.ok);
307        assert!(result.error.unwrap().contains("target"));
308    }
309
310    #[test]
311    fn test_json_verifier_valid() {
312        let mut f = NamedTempFile::new().unwrap();
313        writeln!(f, r#"{{"key": "value"}}"#).unwrap();
314        let result = JSONVerifier::new().verify(f.path().to_str().unwrap(), "test");
315        assert!(result.ok);
316    }
317
318    #[test]
319    fn test_json_verifier_invalid() {
320        let mut f = NamedTempFile::new().unwrap();
321        writeln!(f, "not json").unwrap();
322        let result = JSONVerifier::new().verify(f.path().to_str().unwrap(), "test");
323        assert!(!result.ok);
324    }
325
326    #[test]
327    fn test_magic_bytes_verifier_match() {
328        let mut f = NamedTempFile::new().unwrap();
329        f.write_all(b"\x89PNG\r\n\x1a\nrest of file").unwrap();
330        let verifier = MagicBytesVerifier::new(b"\x89PNG\r\n\x1a\n".to_vec());
331        let result = verifier.verify(f.path().to_str().unwrap(), "test");
332        assert!(result.ok);
333    }
334
335    #[test]
336    fn test_magic_bytes_verifier_mismatch() {
337        let mut f = NamedTempFile::new().unwrap();
338        f.write_all(b"NOT PNG").unwrap();
339        let verifier = MagicBytesVerifier::new(b"\x89PNG".to_vec());
340        let result = verifier.verify(f.path().to_str().unwrap(), "test");
341        assert!(!result.ok);
342        assert!(result.error.unwrap().contains("mismatch"));
343    }
344
345    #[test]
346    fn test_run_verifier_chain_all_pass() {
347        let v1 = JSONVerifier::new();
348        let mut f = NamedTempFile::new().unwrap();
349        writeln!(f, r#"{{"ok": true}}"#).unwrap();
350        let verifiers: Vec<&dyn Verifier> = vec![&v1];
351        let result = run_verifier_chain(&verifiers, f.path().to_str().unwrap(), "test");
352        assert!(result.ok);
353    }
354
355    #[test]
356    fn test_run_verifier_chain_stops_on_failure() {
357        let v1 = JSONVerifier::new();
358        let mut f = NamedTempFile::new().unwrap();
359        writeln!(f, "not json").unwrap();
360        let verifiers: Vec<&dyn Verifier> = vec![&v1];
361        let result = run_verifier_chain(&verifiers, f.path().to_str().unwrap(), "test");
362        assert!(!result.ok);
363    }
364
365    #[test]
366    fn test_run_verifier_chain_empty() {
367        let verifiers: Vec<&dyn Verifier> = vec![];
368        let result = run_verifier_chain(&verifiers, "", "test");
369        assert!(result.ok);
370    }
371
372    #[test]
373    fn test_yaml_verifier_nonexistent_file() {
374        let result = YAMLVerifier.verify("/tmp/nonexistent_file_abc123.yaml", "test");
375        assert!(!result.ok);
376        assert!(result.error.unwrap().contains("Cannot read file"));
377    }
378
379    #[test]
380    fn test_json_verifier_nonexistent_file() {
381        let result = JSONVerifier::new().verify("/tmp/nonexistent_file_abc123.json", "test");
382        assert!(!result.ok);
383        assert!(result.error.unwrap().contains("Cannot read file"));
384    }
385
386    #[test]
387    fn test_magic_bytes_verifier_file_too_short() {
388        let mut f = NamedTempFile::new().unwrap();
389        f.write_all(b"AB").unwrap();
390        let verifier = MagicBytesVerifier::new(b"ABCDEF".to_vec());
391        let result = verifier.verify(f.path().to_str().unwrap(), "test");
392        assert!(!result.ok);
393        let err = result.error.unwrap();
394        assert!(err.contains("File too short"), "got: {err}");
395        assert!(err.contains("6"), "should mention expected length");
396        assert!(err.contains("2"), "should mention actual length");
397    }
398
399    /// A verifier that panics, used to test catch_unwind in the chain.
400    struct PanickingVerifier;
401
402    impl Verifier for PanickingVerifier {
403        fn verify(&self, _path: &str, _module_id: &str) -> VerifyResult {
404            panic!("verifier exploded");
405        }
406    }
407
408    #[test]
409    fn test_run_verifier_chain_panic_caught() {
410        let bad = PanickingVerifier;
411        let verifiers: Vec<&dyn Verifier> = vec![&bad];
412        let result = run_verifier_chain(&verifiers, "/fake", "test");
413        assert!(!result.ok);
414        let err = result.error.unwrap();
415        assert!(
416            err.contains("Verifier crashed"),
417            "expected crash message, got: {err}"
418        );
419        assert!(
420            err.contains("verifier exploded"),
421            "expected panic message, got: {err}"
422        );
423    }
424
425    /// A verifier that always returns an error, simulating a "crash" without panicking.
426    struct AlwaysFailVerifier {
427        message: String,
428    }
429
430    impl AlwaysFailVerifier {
431        fn new(message: &str) -> Self {
432            Self {
433                message: message.to_string(),
434            }
435        }
436    }
437
438    impl Verifier for AlwaysFailVerifier {
439        fn verify(&self, _path: &str, _module_id: &str) -> VerifyResult {
440            VerifyResult::fail(self.message.clone())
441        }
442    }
443
444    /// A verifier that always passes.
445    struct AlwaysPassVerifier;
446
447    impl Verifier for AlwaysPassVerifier {
448        fn verify(&self, _path: &str, _module_id: &str) -> VerifyResult {
449            VerifyResult::ok()
450        }
451    }
452
453    #[test]
454    fn test_run_verifier_chain_crash_caught() {
455        let bad = AlwaysFailVerifier::new("simulated crash");
456        let verifiers: Vec<&dyn Verifier> = vec![&bad];
457        let result = run_verifier_chain(&verifiers, "/fake", "test");
458        assert!(!result.ok);
459        assert_eq!(result.error.as_deref(), Some("simulated crash"));
460    }
461
462    #[test]
463    fn test_run_verifier_chain_first_failure_stops() {
464        // The first verifier fails, so the second should never matter.
465        let fail_v = AlwaysFailVerifier::new("first failed");
466        let pass_v = AlwaysPassVerifier;
467        let verifiers: Vec<&dyn Verifier> = vec![&fail_v, &pass_v];
468        let result = run_verifier_chain(&verifiers, "/fake", "test");
469        assert!(!result.ok);
470        assert_eq!(result.error.as_deref(), Some("first failed"));
471    }
472
473    #[test]
474    fn test_syntax_verifier_valid_rust() {
475        let mut f = NamedTempFile::new().unwrap();
476        writeln!(f, "fn main() {{\n    println!(\"hello\");\n}}").unwrap();
477        let result = SyntaxVerifier.verify(f.path().to_str().unwrap(), "test");
478        assert!(result.ok, "expected ok, got: {:?}", result.error);
479    }
480
481    #[test]
482    fn test_syntax_verifier_invalid_rust() {
483        let mut f = NamedTempFile::new().unwrap();
484        writeln!(f, "fn main() {{{{{{").unwrap();
485        let result = SyntaxVerifier.verify(f.path().to_str().unwrap(), "test");
486        assert!(!result.ok);
487        assert!(result.error.unwrap().contains("Invalid Rust syntax"));
488    }
489
490    #[test]
491    fn test_syntax_verifier_empty_file() {
492        let mut f = NamedTempFile::new().unwrap();
493        write!(f, "").unwrap();
494        let result = SyntaxVerifier.verify(f.path().to_str().unwrap(), "test");
495        assert!(!result.ok);
496        assert!(result.error.unwrap().contains("File is empty"));
497    }
498
499    #[test]
500    fn test_syntax_verifier_nonexistent_file() {
501        let result = SyntaxVerifier.verify("/tmp/nonexistent_rs_file_abc123.rs", "test");
502        assert!(!result.ok);
503        assert!(result.error.unwrap().contains("Cannot read file"));
504    }
505
506    #[test]
507    fn test_syntax_verifier_whitespace_only() {
508        let mut f = NamedTempFile::new().unwrap();
509        write!(f, "   \n\n  \t  ").unwrap();
510        let result = SyntaxVerifier.verify(f.path().to_str().unwrap(), "test");
511        assert!(!result.ok);
512        assert!(result.error.unwrap().contains("File is empty"));
513    }
514
515    #[test]
516    fn test_syntax_verifier_complex_valid() {
517        let mut f = NamedTempFile::new().unwrap();
518        writeln!(
519            f,
520            "use std::collections::HashMap;\n\
521             \n\
522             pub struct Foo {{\n\
523                 pub name: String,\n\
524                 pub values: HashMap<String, i32>,\n\
525             }}\n\
526             \n\
527             impl Foo {{\n\
528                 pub fn new(name: String) -> Self {{\n\
529                     Self {{ name, values: HashMap::new() }}\n\
530                 }}\n\
531             }}"
532        )
533        .unwrap();
534        let result = SyntaxVerifier.verify(f.path().to_str().unwrap(), "test");
535        assert!(result.ok, "expected ok, got: {:?}", result.error);
536    }
537}