1use 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#[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#[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#[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 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 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#[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#[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 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#[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 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#[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 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 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 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 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 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}