Skip to main content

xchecker_engine/
example_generators.rs

1//! Example generators for schema validation
2//!
3//! This module provides constructors for generating minimal and full examples
4//! of xchecker's JSON output formats (receipt, status, doctor). These examples
5//! are used for schema validation and documentation.
6//!
7//! All examples use:
8//! - Fixed timestamps for deterministic output
9//! - `BTreeMap` for deterministic key ordering
10//! - Sorted arrays (by path for outputs/artifacts, by name for checks)
11//! - Pinned tool versions for byte-identical assertions
12
13use chrono::{DateTime, Utc};
14use serde_json::Value as JsonValue;
15use std::collections::{BTreeMap, HashMap};
16
17use crate::doctor::{CheckStatus, DoctorCheck, DoctorOutput};
18use crate::types::{
19    ArtifactInfo, ConfigSource, ConfigValue, DriftPair, FileEvidence, FileHash, LlmInfo, LockDrift,
20    PacketEvidence, PipelineInfo, Priority, Receipt, StatusOutput,
21};
22
23/// Fixed timestamp for deterministic examples
24/// Only available in test builds to avoid accidental use in production
25#[must_use]
26pub fn fixed_now() -> DateTime<Utc> {
27    DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z")
28        .unwrap()
29        .with_timezone(&Utc)
30}
31
32/// Generate minimal receipt example (required fields only)
33/// Uses fixed timestamp for deterministic output
34#[must_use]
35pub fn make_example_receipt_minimal() -> Receipt {
36    Receipt {
37        schema_version: "1".to_string(),
38        emitted_at: fixed_now(),
39        spec_id: "example-spec".to_string(),
40        phase: "requirements".to_string(),
41        xchecker_version: "0.1.0".to_string(),
42        claude_cli_version: "0.8.1".to_string(),
43        model_full_name: "haiku".to_string(),
44        model_alias: None,
45        canonicalization_version: "yaml-v1,md-v1".to_string(),
46        canonicalization_backend: "jcs-rfc8785".to_string(),
47        flags: HashMap::new(),
48        runner: "native".to_string(),
49        runner_distro: None,
50        packet: PacketEvidence {
51            files: vec![],
52            max_bytes: 100000,
53            max_lines: 5000,
54        },
55        outputs: vec![],
56        exit_code: 0,
57        error_kind: None,
58        error_reason: None,
59        stderr_tail: None,
60        stderr_redacted: None,
61        warnings: vec![],
62        fallback_used: None,
63        diff_context: None,
64        llm: None,
65        pipeline: None,
66    }
67}
68
69/// Generate full receipt example (all fields populated)
70/// Uses `BTreeMap` for flags and sorts arrays for deterministic output
71#[must_use]
72pub fn make_example_receipt_full() -> Receipt {
73    let mut flags = HashMap::new();
74    flags.insert("dry_run".to_string(), "true".to_string());
75    flags.insert("strict_lock".to_string(), "false".to_string());
76
77    let mut outputs = vec![
78        FileHash {
79            path: "artifacts/10-design.md".to_string(),
80            blake3_canonicalized:
81                "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210".to_string(),
82        },
83        FileHash {
84            path: "artifacts/00-requirements.md".to_string(),
85            blake3_canonicalized:
86                "abc1234567890abcabc1234567890abcabc1234567890abcabc1234567890abc".to_string(),
87        },
88    ];
89    // Sort by path for deterministic output
90    outputs.sort_by(|a, b| a.path.cmp(&b.path));
91
92    let mut packet_files = vec![
93        FileEvidence {
94            path: "specs/example-spec/requirements.md".to_string(),
95            range: Some("L1-L80".to_string()),
96            blake3_pre_redaction:
97                "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
98            priority: Priority::High,
99        },
100        FileEvidence {
101            path: "README.md".to_string(),
102            range: None,
103            blake3_pre_redaction:
104                "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(),
105            priority: Priority::Medium,
106        },
107    ];
108    // Sort by path for deterministic output
109    packet_files.sort_by(|a, b| a.path.cmp(&b.path));
110
111    Receipt {
112        schema_version: "1".to_string(),
113        emitted_at: fixed_now(),
114        spec_id: "example-spec".to_string(),
115        phase: "design".to_string(),
116        xchecker_version: "0.1.0".to_string(),
117        claude_cli_version: "0.8.1".to_string(),
118        model_full_name: "haiku".to_string(),
119        model_alias: Some("sonnet".to_string()),
120        canonicalization_version: "yaml-v1,md-v1".to_string(),
121        canonicalization_backend: "jcs-rfc8785".to_string(),
122        flags,
123        runner: "wsl".to_string(),
124        runner_distro: Some("Ubuntu-22.04".to_string()),
125        packet: PacketEvidence {
126            files: packet_files,
127            max_bytes: 100000,
128            max_lines: 5000,
129        },
130        outputs,
131        exit_code: 0,
132        error_kind: None,
133        error_reason: Some("Warning: large packet".to_string()),
134        stderr_tail: Some("Warning: packet size approaching limit".to_string()),
135        stderr_redacted: Some(
136            "Warning: packet size approaching limit (secrets redacted)".to_string(),
137        ),
138        warnings: vec!["rename_retry_count: 2".to_string()],
139        fallback_used: Some(true),
140        diff_context: Some(3),
141        llm: Some(LlmInfo {
142            provider: Some("claude-cli".to_string()),
143            model_used: Some("haiku".to_string()),
144            tokens_input: Some(1234),
145            tokens_output: Some(567),
146            timed_out: Some(false),
147            timeout_seconds: Some(600),
148            budget_exhausted: None,
149        }),
150        pipeline: Some(PipelineInfo {
151            execution_strategy: Some("controlled".to_string()),
152        }),
153    }
154}
155
156/// Generate minimal status example (required fields only)
157#[must_use]
158pub fn make_example_status_minimal() -> StatusOutput {
159    StatusOutput {
160        schema_version: "1".to_string(),
161        emitted_at: fixed_now(),
162        runner: "native".to_string(),
163        runner_distro: None,
164        fallback_used: false,
165        canonicalization_version: "yaml-v1,md-v1".to_string(),
166        canonicalization_backend: "jcs-rfc8785".to_string(),
167        artifacts: vec![],
168        last_receipt_path: ".xchecker/receipts/example-spec/requirements.json".to_string(),
169        effective_config: BTreeMap::new(),
170        lock_drift: None,
171        pending_fixups: None,
172    }
173}
174
175/// Generate full status example (all fields populated)
176#[must_use]
177pub fn make_example_status_full() -> StatusOutput {
178    let mut artifacts = vec![
179        ArtifactInfo {
180            path: "artifacts/10-design.md".to_string(),
181            blake3_first8: "fedcba98".to_string(),
182        },
183        ArtifactInfo {
184            path: "artifacts/00-requirements.md".to_string(),
185            blake3_first8: "abc12345".to_string(),
186        },
187    ];
188    // Sort by path for deterministic output
189    artifacts.sort_by(|a, b| a.path.cmp(&b.path));
190
191    let mut effective_config = BTreeMap::new();
192    effective_config.insert(
193        "max_packet_bytes".to_string(),
194        ConfigValue {
195            value: JsonValue::Number(100000.into()),
196            source: ConfigSource::Default,
197        },
198    );
199    effective_config.insert(
200        "model".to_string(),
201        ConfigValue {
202            value: JsonValue::String("haiku".to_string()),
203            source: ConfigSource::Config,
204        },
205    );
206    effective_config.insert(
207        "strict_lock".to_string(),
208        ConfigValue {
209            value: JsonValue::Bool(true),
210            source: ConfigSource::Cli,
211        },
212    );
213
214    StatusOutput {
215        schema_version: "1".to_string(),
216        emitted_at: fixed_now(),
217        runner: "wsl".to_string(),
218        runner_distro: Some("Ubuntu-22.04".to_string()),
219        fallback_used: true,
220        canonicalization_version: "yaml-v1,md-v1".to_string(),
221        canonicalization_backend: "jcs-rfc8785".to_string(),
222        artifacts,
223        last_receipt_path: ".xchecker/receipts/example-spec/design.json".to_string(),
224        effective_config,
225        lock_drift: Some(LockDrift {
226            model_full_name: Some(DriftPair {
227                locked: "haiku".to_string(),
228                current: "sonnet".to_string(),
229            }),
230            claude_cli_version: Some(DriftPair {
231                locked: "0.8.1".to_string(),
232                current: "0.9.0".to_string(),
233            }),
234            schema_version: None,
235        }),
236        pending_fixups: Some(crate::types::PendingFixupsSummary {
237            targets: 3,
238            est_added: 42,
239            est_removed: 15,
240        }),
241    }
242}
243
244/// Generate minimal doctor example (basic checks)
245#[must_use]
246pub fn make_example_doctor_minimal() -> DoctorOutput {
247    let mut checks = vec![
248        DoctorCheck {
249            name: "claude_path".to_string(),
250            status: CheckStatus::Pass,
251            details: "Found claude at /usr/local/bin/claude".to_string(),
252        },
253        DoctorCheck {
254            name: "config_parse".to_string(),
255            status: CheckStatus::Pass,
256            details: "Configuration parsed and validated successfully".to_string(),
257        },
258    ];
259    // Sort by name for deterministic output
260    checks.sort_by(|a, b| a.name.cmp(&b.name));
261
262    DoctorOutput {
263        schema_version: "1".to_string(),
264        emitted_at: fixed_now(),
265        ok: true,
266        checks,
267        cache_stats: None,
268    }
269}
270
271/// Generate full doctor example (all check types)
272#[must_use]
273pub fn make_example_doctor_full() -> DoctorOutput {
274    let mut checks = vec![
275        DoctorCheck {
276            name: "claude_path".to_string(),
277            status: CheckStatus::Pass,
278            details: "Found claude at /usr/local/bin/claude".to_string(),
279        },
280        DoctorCheck {
281            name: "claude_version".to_string(),
282            status: CheckStatus::Pass,
283            details: "0.8.1".to_string(),
284        },
285        DoctorCheck {
286            name: "runner_selection".to_string(),
287            status: CheckStatus::Pass,
288            details: "Runner mode: native (spawn claude directly)".to_string(),
289        },
290        DoctorCheck {
291            name: "wsl_availability".to_string(),
292            status: CheckStatus::Warn,
293            details: "WSL not installed or not available".to_string(),
294        },
295        DoctorCheck {
296            name: "wsl_default_distro".to_string(),
297            status: CheckStatus::Pass,
298            details: "Default WSL distro: Ubuntu-22.04".to_string(),
299        },
300        DoctorCheck {
301            name: "write_permissions".to_string(),
302            status: CheckStatus::Pass,
303            details: ".xchecker directory is writable".to_string(),
304        },
305        DoctorCheck {
306            name: "atomic_rename".to_string(),
307            status: CheckStatus::Pass,
308            details: "Atomic rename works on same volume".to_string(),
309        },
310        DoctorCheck {
311            name: "config_parse".to_string(),
312            status: CheckStatus::Pass,
313            details: "Configuration parsed and validated successfully".to_string(),
314        },
315    ];
316    // Sort by name for deterministic output
317    checks.sort_by(|a, b| a.name.cmp(&b.name));
318
319    DoctorOutput {
320        schema_version: "1".to_string(),
321        emitted_at: fixed_now(),
322        ok: true,
323        checks,
324        cache_stats: None,
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn test_fixed_now_returns_consistent_timestamp() {
334        let ts1 = fixed_now();
335        let ts2 = fixed_now();
336        assert_eq!(ts1, ts2);
337        assert_eq!(ts1.to_rfc3339(), "2025-01-01T00:00:00+00:00");
338    }
339
340    #[test]
341    fn test_receipt_minimal_has_required_fields() {
342        let receipt = make_example_receipt_minimal();
343        assert_eq!(receipt.schema_version, "1");
344        assert_eq!(receipt.spec_id, "example-spec");
345        assert_eq!(receipt.phase, "requirements");
346        assert_eq!(receipt.xchecker_version, "0.1.0");
347        assert_eq!(receipt.claude_cli_version, "0.8.1");
348        assert_eq!(receipt.runner, "native");
349        assert_eq!(receipt.exit_code, 0);
350        assert!(receipt.model_alias.is_none());
351        assert!(receipt.runner_distro.is_none());
352        assert!(receipt.error_kind.is_none());
353        assert!(receipt.error_reason.is_none());
354        assert!(receipt.stderr_tail.is_none());
355        assert!(receipt.fallback_used.is_none());
356    }
357
358    #[test]
359    fn test_receipt_full_has_all_fields() {
360        let receipt = make_example_receipt_full();
361        assert_eq!(receipt.schema_version, "1");
362        assert!(receipt.model_alias.is_some());
363        assert!(receipt.runner_distro.is_some());
364        assert!(receipt.error_reason.is_some());
365        assert!(receipt.stderr_tail.is_some());
366        assert!(receipt.fallback_used.is_some());
367        assert!(!receipt.flags.is_empty());
368        assert!(!receipt.outputs.is_empty());
369        assert!(!receipt.warnings.is_empty());
370        assert!(!receipt.packet.files.is_empty());
371    }
372
373    #[test]
374    fn test_receipt_outputs_sorted_by_path() {
375        let receipt = make_example_receipt_full();
376        let paths: Vec<&str> = receipt.outputs.iter().map(|o| o.path.as_str()).collect();
377        let mut sorted_paths = paths.clone();
378        sorted_paths.sort();
379        assert_eq!(paths, sorted_paths, "Outputs should be sorted by path");
380    }
381
382    #[test]
383    fn test_receipt_packet_files_sorted_by_path() {
384        let receipt = make_example_receipt_full();
385        let paths: Vec<&str> = receipt
386            .packet
387            .files
388            .iter()
389            .map(|f| f.path.as_str())
390            .collect();
391        let mut sorted_paths = paths.clone();
392        sorted_paths.sort();
393        assert_eq!(paths, sorted_paths, "Packet files should be sorted by path");
394    }
395
396    #[test]
397    fn test_receipt_blake3_format() {
398        let receipt = make_example_receipt_full();
399        // Check that blake3_canonicalized is 64 hex chars
400        for output in &receipt.outputs {
401            assert_eq!(
402                output.blake3_canonicalized.len(),
403                64,
404                "blake3_canonicalized should be 64 chars"
405            );
406            assert!(
407                output
408                    .blake3_canonicalized
409                    .chars()
410                    .all(|c| c.is_ascii_hexdigit()),
411                "blake3_canonicalized should be hex"
412            );
413        }
414    }
415
416    #[test]
417    fn test_status_minimal_has_required_fields() {
418        let status = make_example_status_minimal();
419        assert_eq!(status.schema_version, "1");
420        assert_eq!(status.runner, "native");
421        assert!(!status.fallback_used);
422        assert!(status.runner_distro.is_none());
423        assert!(status.lock_drift.is_none());
424        assert!(status.artifacts.is_empty());
425        assert!(status.effective_config.is_empty());
426    }
427
428    #[test]
429    fn test_status_full_has_all_fields() {
430        let status = make_example_status_full();
431        assert_eq!(status.schema_version, "1");
432        assert!(status.runner_distro.is_some());
433        assert!(status.fallback_used);
434        assert!(status.lock_drift.is_some());
435        assert!(!status.artifacts.is_empty());
436        assert!(!status.effective_config.is_empty());
437    }
438
439    #[test]
440    fn test_status_artifacts_sorted_by_path() {
441        let status = make_example_status_full();
442        let paths: Vec<&str> = status.artifacts.iter().map(|a| a.path.as_str()).collect();
443        let mut sorted_paths = paths.clone();
444        sorted_paths.sort();
445        assert_eq!(paths, sorted_paths, "Artifacts should be sorted by path");
446    }
447
448    #[test]
449    fn test_status_blake3_first8_format() {
450        let status = make_example_status_full();
451        // Check that blake3_first8 is 8 hex chars
452        for artifact in &status.artifacts {
453            assert_eq!(
454                artifact.blake3_first8.len(),
455                8,
456                "blake3_first8 should be 8 chars"
457            );
458            assert!(
459                artifact
460                    .blake3_first8
461                    .chars()
462                    .all(|c| c.is_ascii_hexdigit()),
463                "blake3_first8 should be hex"
464            );
465        }
466    }
467
468    #[test]
469    fn test_status_effective_config_uses_btreemap() {
470        let status = make_example_status_full();
471        // BTreeMap ensures deterministic key order
472        let keys: Vec<&String> = status.effective_config.keys().collect();
473        let mut sorted_keys = keys.clone();
474        sorted_keys.sort();
475        assert_eq!(keys, sorted_keys, "Config keys should be sorted (BTreeMap)");
476    }
477
478    #[test]
479    fn test_doctor_minimal_has_required_fields() {
480        let doctor = make_example_doctor_minimal();
481        assert_eq!(doctor.schema_version, "1");
482        assert!(doctor.ok);
483        assert!(!doctor.checks.is_empty());
484    }
485
486    #[test]
487    fn test_doctor_full_has_all_check_types() {
488        let doctor = make_example_doctor_full();
489        assert_eq!(doctor.schema_version, "1");
490        assert!(doctor.ok);
491        assert!(doctor.checks.len() >= 5, "Should have multiple checks");
492
493        // Verify we have different status types
494        let has_pass = doctor.checks.iter().any(|c| c.status == CheckStatus::Pass);
495        let has_warn = doctor.checks.iter().any(|c| c.status == CheckStatus::Warn);
496        assert!(has_pass, "Should have at least one Pass check");
497        assert!(has_warn, "Should have at least one Warn check");
498    }
499
500    #[test]
501    fn test_doctor_checks_sorted_by_name() {
502        let doctor = make_example_doctor_full();
503        let names: Vec<&str> = doctor.checks.iter().map(|c| c.name.as_str()).collect();
504        let mut sorted_names = names.clone();
505        sorted_names.sort();
506        assert_eq!(names, sorted_names, "Checks should be sorted by name");
507    }
508
509    #[test]
510    fn test_all_examples_use_fixed_timestamp() {
511        let receipt = make_example_receipt_minimal();
512        let status = make_example_status_minimal();
513        let doctor = make_example_doctor_minimal();
514
515        assert_eq!(receipt.emitted_at, fixed_now());
516        assert_eq!(status.emitted_at, fixed_now());
517        assert_eq!(doctor.emitted_at, fixed_now());
518    }
519
520    #[test]
521    fn test_pinned_tool_versions() {
522        let receipt = make_example_receipt_minimal();
523        assert_eq!(receipt.xchecker_version, "0.1.0");
524        assert_eq!(receipt.claude_cli_version, "0.8.1");
525    }
526}