use std::collections::HashMap;
use std::fs;
use tempfile::TempDir;
use xchecker::error::{ClaudeError, ConfigError, PhaseError, RunnerError, XCheckerError};
use xchecker::exit_codes::codes;
use xchecker::lock::LockError;
use xchecker::receipt::ReceiptManager;
use xchecker::types::{ErrorKind, PacketEvidence, PhaseId};
fn create_test_manager() -> (ReceiptManager, TempDir) {
let temp_dir = TempDir::new().unwrap();
let spec_base = camino::Utf8PathBuf::from_path_buf(
temp_dir.path().join(".xchecker/specs/test-error-receipt"),
)
.unwrap();
fs::create_dir_all(&spec_base).unwrap();
let manager = ReceiptManager::new(&spec_base);
(manager, temp_dir)
}
#[test]
fn test_cli_args_error_mapping() {
let (manager, _temp_dir) = create_test_manager();
let error = XCheckerError::Config(ConfigError::InvalidFile("test.toml".to_string()));
let (exit_code, error_kind): (i32, ErrorKind) = (&error).into();
assert_eq!(exit_code, codes::CLI_ARGS);
assert_eq!(error_kind, ErrorKind::CliArgs);
let receipt = manager.create_error_receipt(
"test-cli-args",
PhaseId::Requirements,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
},
None, None, vec![], None, "native", None, None, None, );
assert_eq!(receipt.exit_code, codes::CLI_ARGS);
assert_eq!(receipt.error_kind, Some(ErrorKind::CliArgs));
assert!(receipt.error_reason.is_some());
assert!(receipt.error_reason.as_ref().unwrap().contains("test.toml"));
}
#[test]
fn test_packet_overflow_error_mapping() {
let (manager, _temp_dir) = create_test_manager();
let error = XCheckerError::PacketOverflow {
used_bytes: 100000,
used_lines: 2000,
limit_bytes: 65536,
limit_lines: 1200,
};
let (exit_code, error_kind): (i32, ErrorKind) = (&error).into();
assert_eq!(exit_code, codes::PACKET_OVERFLOW);
assert_eq!(error_kind, ErrorKind::PacketOverflow);
let receipt = manager.create_error_receipt(
"test-packet-overflow",
PhaseId::Requirements,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
},
None, None, vec![], None, "native", None, None, None, );
assert_eq!(receipt.exit_code, codes::PACKET_OVERFLOW);
assert_eq!(receipt.error_kind, Some(ErrorKind::PacketOverflow));
assert!(receipt.error_reason.is_some());
assert!(receipt.error_reason.as_ref().unwrap().contains("100000"));
assert!(receipt.error_reason.as_ref().unwrap().contains("65536"));
}
#[test]
fn test_secret_detected_error_mapping() {
let (manager, _temp_dir) = create_test_manager();
let error = XCheckerError::SecretDetected {
pattern: "ghp_".to_string(),
location: "test.txt:42".to_string(),
};
let (exit_code, error_kind): (i32, ErrorKind) = (&error).into();
assert_eq!(exit_code, codes::SECRET_DETECTED);
assert_eq!(error_kind, ErrorKind::SecretDetected);
let receipt = manager.create_error_receipt(
"test-secret-detected",
PhaseId::Requirements,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
},
None, None, vec![], None, "native", None, None, None, );
assert_eq!(receipt.exit_code, codes::SECRET_DETECTED);
assert_eq!(receipt.error_kind, Some(ErrorKind::SecretDetected));
assert!(receipt.error_reason.is_some());
assert!(receipt.error_reason.as_ref().unwrap().contains("ghp_"));
}
#[test]
fn test_lock_held_error_mapping() {
let (manager, _temp_dir) = create_test_manager();
let error1 = XCheckerError::ConcurrentExecution {
id: "test-spec".to_string(),
};
let (exit_code1, error_kind1): (i32, ErrorKind) = (&error1).into();
assert_eq!(exit_code1, codes::LOCK_HELD);
assert_eq!(error_kind1, ErrorKind::LockHeld);
let error2 = XCheckerError::Lock(LockError::ConcurrentExecution {
spec_id: "test-spec".to_string(),
pid: 12345,
created_ago: "5m".to_string(),
});
let (exit_code2, error_kind2): (i32, ErrorKind) = (&error2).into();
assert_eq!(exit_code2, codes::LOCK_HELD);
assert_eq!(error_kind2, ErrorKind::LockHeld);
let receipt = manager.create_error_receipt(
"test-lock-held",
PhaseId::Requirements,
&error1,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
},
None, None, vec![], None, "native", None, None, None, );
assert_eq!(receipt.exit_code, codes::LOCK_HELD);
assert_eq!(receipt.error_kind, Some(ErrorKind::LockHeld));
assert!(receipt.error_reason.is_some());
}
#[test]
fn test_phase_timeout_error_mapping() {
let (manager, _temp_dir) = create_test_manager();
let error = XCheckerError::Phase(PhaseError::Timeout {
phase: "REQUIREMENTS".to_string(),
timeout_seconds: 600,
});
let (exit_code, error_kind): (i32, ErrorKind) = (&error).into();
assert_eq!(exit_code, codes::PHASE_TIMEOUT);
assert_eq!(error_kind, ErrorKind::PhaseTimeout);
let receipt = manager.create_error_receipt(
"test-phase-timeout",
PhaseId::Requirements,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
},
None, None, vec![], None, "native", None, None, None, );
assert_eq!(receipt.exit_code, codes::PHASE_TIMEOUT);
assert_eq!(receipt.error_kind, Some(ErrorKind::PhaseTimeout));
assert!(receipt.error_reason.is_some());
assert!(receipt.error_reason.as_ref().unwrap().contains("600"));
}
#[test]
fn test_claude_failure_error_mapping() {
let (manager, _temp_dir) = create_test_manager();
let error1 = XCheckerError::Claude(ClaudeError::ExecutionFailed {
stderr: "API error".to_string(),
});
let (exit_code1, error_kind1): (i32, ErrorKind) = (&error1).into();
assert_eq!(exit_code1, codes::CLAUDE_FAILURE);
assert_eq!(error_kind1, ErrorKind::ClaudeFailure);
let error2 = XCheckerError::Runner(RunnerError::NativeExecutionFailed {
reason: "command not found".to_string(),
});
let (exit_code2, error_kind2): (i32, ErrorKind) = (&error2).into();
assert_eq!(exit_code2, codes::CLAUDE_FAILURE);
assert_eq!(error_kind2, ErrorKind::ClaudeFailure);
let receipt = manager.create_error_receipt(
"test-claude-failure",
PhaseId::Requirements,
&error1,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
},
None, None, vec![], None, "native", None, None, None, );
assert_eq!(receipt.exit_code, codes::CLAUDE_FAILURE);
assert_eq!(receipt.error_kind, Some(ErrorKind::ClaudeFailure));
assert!(receipt.error_reason.is_some());
}
#[test]
fn test_unknown_error_mapping() {
let (manager, _temp_dir) = create_test_manager();
let error = XCheckerError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"file not found",
));
let (exit_code, error_kind): (i32, ErrorKind) = (&error).into();
assert_eq!(exit_code, 1);
assert_eq!(error_kind, ErrorKind::Unknown);
let receipt = manager.create_error_receipt(
"test-unknown",
PhaseId::Requirements,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
},
None, None, vec![], None, "native", None, None, None, );
assert_eq!(receipt.exit_code, 1);
assert_eq!(receipt.error_kind, Some(ErrorKind::Unknown));
assert!(receipt.error_reason.is_some());
}
#[test]
fn test_error_receipts_contain_required_fields() {
let (manager, _temp_dir) = create_test_manager();
let test_errors = vec![
(
XCheckerError::PacketOverflow {
used_bytes: 100000,
used_lines: 2000,
limit_bytes: 65536,
limit_lines: 1200,
},
ErrorKind::PacketOverflow,
),
(
XCheckerError::SecretDetected {
pattern: "ghp_".to_string(),
location: "test.txt".to_string(),
},
ErrorKind::SecretDetected,
),
(
XCheckerError::ConcurrentExecution {
id: "test-spec".to_string(),
},
ErrorKind::LockHeld,
),
(
XCheckerError::Phase(PhaseError::Timeout {
phase: "REQUIREMENTS".to_string(),
timeout_seconds: 600,
}),
ErrorKind::PhaseTimeout,
),
];
for (error, expected_kind) in test_errors {
let receipt = manager.create_error_receipt(
"test-error-fields",
PhaseId::Requirements,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
},
None, None, vec![], None, "native", None, None, None, );
assert!(
receipt.error_kind.is_some(),
"Receipt should have error_kind for error: {}",
error
);
assert_eq!(
receipt.error_kind.unwrap(),
expected_kind,
"Receipt error_kind should match expected for error: {}",
error
);
assert!(
receipt.error_reason.is_some(),
"Receipt should have error_reason for error: {}",
error
);
assert!(
!receipt.error_reason.as_ref().unwrap().is_empty(),
"Receipt error_reason should not be empty for error: {}",
error
);
}
}
#[test]
fn test_exit_code_matches_receipt_field() {
let (manager, _temp_dir) = create_test_manager();
let test_cases = vec![
(
XCheckerError::Config(ConfigError::InvalidFile("test".to_string())),
codes::CLI_ARGS,
),
(
XCheckerError::PacketOverflow {
used_bytes: 100000,
used_lines: 2000,
limit_bytes: 65536,
limit_lines: 1200,
},
codes::PACKET_OVERFLOW,
),
(
XCheckerError::SecretDetected {
pattern: "ghp_".to_string(),
location: "test.txt".to_string(),
},
codes::SECRET_DETECTED,
),
(
XCheckerError::ConcurrentExecution {
id: "test-spec".to_string(),
},
codes::LOCK_HELD,
),
(
XCheckerError::Phase(PhaseError::Timeout {
phase: "REQUIREMENTS".to_string(),
timeout_seconds: 600,
}),
codes::PHASE_TIMEOUT,
),
(
XCheckerError::Claude(ClaudeError::ExecutionFailed {
stderr: "error".to_string(),
}),
codes::CLAUDE_FAILURE,
),
(
XCheckerError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"not found",
)),
1, ),
];
for (error, expected_exit_code) in test_cases {
let (exit_code, _): (i32, ErrorKind) = (&error).into();
assert_eq!(
exit_code, expected_exit_code,
"Exit code should match expected for error: {}",
error
);
let receipt = manager.create_error_receipt(
"test-exit-code-match",
PhaseId::Requirements,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
},
None, None, vec![], None, "native", None, None, None, );
assert_eq!(
receipt.exit_code, expected_exit_code,
"Receipt exit_code should match expected for error: {}",
error
);
}
}
#[test]
fn test_receipt_written_on_error_path() {
let (manager, _temp_dir) = create_test_manager();
let error = XCheckerError::PacketOverflow {
used_bytes: 100000,
used_lines: 2000,
limit_bytes: 65536,
limit_lines: 1200,
};
let receipt = manager.create_error_receipt(
"test-write-receipt",
PhaseId::Requirements,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
},
None, None, vec![], None, "native", None, None, None, );
let receipt_path = manager.write_receipt(&receipt).unwrap();
assert!(receipt_path.exists(), "Receipt file should exist on disk");
let receipt_content = fs::read_to_string(receipt_path.as_std_path()).unwrap();
assert!(receipt_content.contains("\"exit_code\":7"));
assert!(receipt_content.contains("\"error_kind\":\"packet_overflow\""));
assert!(receipt_content.contains("\"error_reason\""));
}
#[test]
fn test_error_receipts_have_no_outputs() {
let (manager, _temp_dir) = create_test_manager();
let error = XCheckerError::PacketOverflow {
used_bytes: 100000,
used_lines: 2000,
limit_bytes: 65536,
limit_lines: 1200,
};
let receipt = manager.create_error_receipt(
"test-no-outputs",
PhaseId::Requirements,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
},
None, None, vec![], None, "native", None, None, None, );
assert_eq!(receipt.outputs.len(), 0);
}
#[test]
fn test_invalid_transition_maps_to_cli_args() {
let (manager, _temp_dir) = create_test_manager();
let error = XCheckerError::Phase(PhaseError::InvalidTransition {
from: "none".to_string(),
to: "design".to_string(),
});
let (exit_code, error_kind): (i32, ErrorKind) = (&error).into();
assert_eq!(exit_code, codes::CLI_ARGS);
assert_eq!(error_kind, ErrorKind::CliArgs);
let receipt = manager.create_error_receipt(
"test-invalid-transition",
PhaseId::Requirements,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
},
None, None, vec![], None, "native", None, None, None, );
assert_eq!(receipt.exit_code, codes::CLI_ARGS);
assert_eq!(receipt.error_kind, Some(ErrorKind::CliArgs));
}
#[test]
fn test_dependency_not_satisfied_maps_to_cli_args() {
let (manager, _temp_dir) = create_test_manager();
let error = XCheckerError::Phase(PhaseError::DependencyNotSatisfied {
phase: "design".to_string(),
dependency: "requirements".to_string(),
});
let (exit_code, error_kind): (i32, ErrorKind) = (&error).into();
assert_eq!(exit_code, codes::CLI_ARGS);
assert_eq!(error_kind, ErrorKind::CliArgs);
let receipt = manager.create_error_receipt(
"test-dependency-not-satisfied",
PhaseId::Design,
&error,
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
},
None, None, vec![], None, "native", None, None, None, );
assert_eq!(receipt.exit_code, codes::CLI_ARGS);
assert_eq!(receipt.error_kind, Some(ErrorKind::CliArgs));
}
#[test]
fn test_error_receipts_include_standard_fields() {
let (manager, _temp_dir) = create_test_manager();
let error = XCheckerError::PacketOverflow {
used_bytes: 100000,
used_lines: 2000,
limit_bytes: 65536,
limit_lines: 1200,
};
let receipt = manager.create_error_receipt(
"test-standard-fields",
PhaseId::Requirements,
&error,
"0.1.0", "0.8.1", "haiku", Some("sonnet".to_string()), HashMap::new(), PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
}, Some("stderr output".to_string()), None, vec!["warning1".to_string()], Some(false), "wsl", None, None, None, );
assert_eq!(receipt.schema_version, "1");
assert_eq!(receipt.spec_id, "test-standard-fields");
assert_eq!(receipt.phase, "requirements");
assert_eq!(receipt.xchecker_version, "0.1.0");
assert_eq!(receipt.claude_cli_version, "0.8.1");
assert_eq!(receipt.model_full_name, "haiku");
assert_eq!(receipt.model_alias, Some("sonnet".to_string()));
assert_eq!(receipt.canonicalization_backend, "jcs-rfc8785");
assert_eq!(receipt.runner, "wsl");
assert_eq!(receipt.runner_distro, None); assert_eq!(receipt.exit_code, codes::PACKET_OVERFLOW);
assert_eq!(receipt.error_kind, Some(ErrorKind::PacketOverflow));
assert!(receipt.error_reason.is_some());
assert_eq!(receipt.stderr_tail, Some("stderr output".to_string()));
assert_eq!(receipt.warnings.len(), 1);
assert_eq!(receipt.fallback_used, Some(false));
}