use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use crossterm::style::{Color, Stylize};
use std::collections::HashMap;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
use crate::{
CliArgs, Config, ExitCode, OrchestratorConfig, OrchestratorHandle, PhaseId, XCheckerError,
emit_jcs,
};
use crate::atomic_write::write_file_atomic;
use crate::error::{ConfigError, PhaseError};
use crate::error_reporter::{ErrorReport, utils as error_utils};
use crate::logging::Logger;
use crate::redaction::SecretRedactor;
use crate::source::SourceResolver;
use crate::spec_id::sanitize_spec_id;
fn use_color() -> bool {
std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
}
fn styled_check() -> String {
if use_color() {
format!("{}", "✓".with(Color::Green).bold())
} else {
"✓".to_string()
}
}
fn styled_warning() -> String {
if use_color() {
format!("{}", "⚠".with(Color::Yellow).bold())
} else {
"⚠".to_string()
}
}
fn styled_success(text: &str) -> String {
if use_color() {
format!("{}", text.with(Color::Green).bold())
} else {
text.to_string()
}
}
fn styled_info(text: &str) -> String {
if use_color() {
format!("{}", text.with(Color::Cyan).bold())
} else {
text.to_string()
}
}
#[derive(Parser)]
#[command(name = "xchecker")]
#[command(about = "A CLI tool for orchestrating spec generation workflows using LLM providers")]
#[command(long_about = r#"
xchecker is a deterministic, token-efficient pipeline that transforms rough ideas
into detailed implementation plans through a structured phase-based approach.
EXAMPLES:
# Generate a spec from stdin input
echo "Create a REST API for user management" | xchecker spec user-api
# Generate a spec from a GitHub issue
xchecker spec issue-123 --source gh --gh owner/repo
# Generate a spec from local filesystem context
xchecker spec my-feature --source fs --repo /path/to/project
# Run in dry-run mode to see what would be executed
xchecker spec test-spec --dry-run --verbose
# Check status of a spec
xchecker status user-api
# Resume from a specific phase
xchecker resume user-api --phase design
# Clean up spec artifacts
xchecker clean user-api
# Run performance benchmarks
xchecker benchmark --file-count 50 --iterations 3
# Run integration tests
xchecker test --components --smoke
CONFIGURATION:
Configuration is loaded with precedence: CLI flags > config file > defaults
Config file is discovered by searching upward from CWD for .xchecker/config.toml
Use --config to specify an explicit config file path
PHASES:
Requirements → Design → Tasks → Review → Fixup → Final
Each phase produces artifacts and receipts for auditability
Use --dry-run to see planned execution without making LLM calls
For more information, see: https://github.com/your-org/xchecker
"#)]
#[command(version)]
pub struct Cli {
#[arg(long, global = true)]
pub config: Option<PathBuf>,
#[arg(long, global = true)]
pub model: Option<String>,
#[arg(long, global = true)]
pub max_turns: Option<u32>,
#[arg(long, global = true)]
pub packet_max_bytes: Option<usize>,
#[arg(long, global = true)]
pub packet_max_lines: Option<usize>,
#[arg(long, global = true)]
pub output_format: Option<String>,
#[arg(long, global = true)]
pub runner_mode: Option<String>,
#[arg(long, global = true)]
pub runner_distro: Option<String>,
#[arg(long, global = true)]
pub claude_path: Option<String>,
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(long, global = true)]
pub allow: Vec<String>,
#[arg(long, global = true)]
pub deny: Vec<String>,
#[arg(long, global = true)]
pub dangerously_skip_permissions: bool,
#[arg(long, global = true)]
pub ignore_secret_pattern: Vec<String>,
#[arg(long, global = true)]
pub extra_secret_pattern: Vec<String>,
#[arg(long, global = true)]
pub phase_timeout: Option<u64>,
#[arg(long, global = true)]
pub stdout_cap_bytes: Option<usize>,
#[arg(long, global = true)]
pub stderr_cap_bytes: Option<usize>,
#[arg(long, global = true)]
pub lock_ttl_seconds: Option<u64>,
#[arg(long, global = true)]
pub debug_packet: bool,
#[arg(long, global = true)]
pub allow_links: bool,
#[arg(long, global = true, overrides_with = "no_strict_validation")]
pub strict_validation: bool,
#[arg(long, global = true, overrides_with = "strict_validation")]
pub no_strict_validation: bool,
#[arg(long, global = true)]
pub llm_provider: Option<String>,
#[arg(long, global = true)]
pub llm_fallback_provider: Option<String>,
#[arg(long, global = true)]
pub prompt_template: Option<String>,
#[arg(long, global = true)]
pub llm_claude_binary: Option<String>,
#[arg(long, global = true)]
pub llm_gemini_binary: Option<String>,
#[arg(long, global = true)]
pub llm_gemini_default_model: Option<String>,
#[arg(long, global = true)]
pub execution_strategy: Option<String>,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
Spec {
id: String,
#[arg(long, default_value = "stdin", value_parser = ["gh", "fs", "stdin"])]
source: String,
#[arg(long, help = "GitHub repository (e.g., 'myorg/myrepo')")]
gh: Option<String>,
#[arg(long, help = "Path to local repository directory")]
repo: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
#[arg(long)]
apply_fixups: bool,
#[arg(long)]
strict_lock: bool,
#[arg(long)]
json: bool,
},
Status {
id: String,
#[arg(long)]
json: bool,
},
Resume {
id: String,
#[arg(long, value_parser = ["requirements", "design", "tasks", "review", "fixup", "final"])]
phase: String,
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
#[arg(long)]
apply_fixups: bool,
#[arg(long)]
strict_lock: bool,
#[arg(long)]
json: bool,
},
Clean {
id: String,
#[arg(long)]
hard: bool,
#[arg(long)]
force: bool,
},
Benchmark {
#[arg(long, default_value = "100")]
file_count: usize,
#[arg(long, default_value = "1024")]
file_size: usize,
#[arg(long, default_value = "5")]
iterations: usize,
#[arg(long)]
json: bool,
#[arg(long)]
max_empty_run_secs: Option<f64>,
#[arg(long)]
max_packetization_ms: Option<f64>,
#[arg(long)]
max_rss_mb: Option<f64>,
#[arg(long)]
max_commit_mb: Option<f64>,
},
Test {
#[arg(long)]
components: bool,
#[arg(long)]
smoke: bool,
},
Doctor {
#[arg(long)]
json: bool,
#[arg(long)]
strict_exit: bool,
},
Init {
id: String,
#[arg(long)]
create_lock: bool,
},
#[command(subcommand)]
Project(ProjectCommands),
Gate {
id: String,
#[arg(long)]
policy: Option<PathBuf>,
#[arg(long)]
min_phase: Option<String>,
#[arg(long)]
fail_on_pending_fixups: bool,
#[arg(long)]
max_phase_age: Option<String>,
#[arg(long)]
json: bool,
},
#[command(subcommand)]
Template(TemplateCommands),
}
#[derive(Subcommand)]
pub enum ProjectCommands {
Init {
name: String,
},
AddSpec {
spec_id: String,
#[arg(long, short)]
tag: Vec<String>,
#[arg(long)]
force: bool,
},
List {
#[arg(long)]
workspace: Option<PathBuf>,
},
Status {
#[arg(long)]
workspace: Option<PathBuf>,
#[arg(long)]
json: bool,
},
History {
spec_id: String,
#[arg(long)]
json: bool,
},
Tui {
#[arg(long)]
workspace: Option<PathBuf>,
},
}
#[derive(Subcommand)]
pub enum TemplateCommands {
List,
Init {
template: String,
spec_id: String,
},
}
#[must_use]
pub fn build_cli() -> clap::Command {
<Cli as clap::CommandFactory>::command()
}
pub fn run() -> Result<(), ExitCode> {
let cli = Cli::parse();
let cli_args = CliArgs {
config_path: cli.config.clone(),
model: cli.model.clone(),
max_turns: cli.max_turns,
packet_max_bytes: cli.packet_max_bytes,
packet_max_lines: cli.packet_max_lines,
output_format: cli.output_format.clone(),
verbose: Some(cli.verbose),
runner_mode: cli.runner_mode.clone(),
runner_distro: cli.runner_distro.clone(),
claude_path: cli.claude_path.clone(),
allow: cli.allow.clone(),
deny: cli.deny.clone(),
dangerously_skip_permissions: cli.dangerously_skip_permissions,
ignore_secret_pattern: cli.ignore_secret_pattern.clone(),
extra_secret_pattern: cli.extra_secret_pattern.clone(),
phase_timeout: cli.phase_timeout,
stdout_cap_bytes: cli.stdout_cap_bytes,
stderr_cap_bytes: cli.stderr_cap_bytes,
lock_ttl_seconds: cli.lock_ttl_seconds,
debug_packet: cli.debug_packet,
allow_links: cli.allow_links,
strict_validation: if cli.strict_validation {
Some(true)
} else if cli.no_strict_validation {
Some(false)
} else {
None
},
llm_provider: cli.llm_provider.clone(),
llm_fallback_provider: cli.llm_fallback_provider.clone(),
prompt_template: cli.prompt_template.clone(),
llm_claude_binary: cli.llm_claude_binary.clone(),
llm_gemini_binary: cli.llm_gemini_binary.clone(),
llm_gemini_default_model: cli.llm_gemini_default_model.clone(),
execution_strategy: cli.execution_strategy.clone(),
};
let config = match Config::discover(&cli_args) {
Ok(config) => config,
Err(err) => {
let contextual_report = error_utils::create_contextual_report(&err, "config");
eprintln!("{contextual_report}");
return Err(err.to_exit_code());
}
};
let redactor = match SecretRedactor::from_config(&config) {
Ok(redactor) => Arc::new(redactor),
Err(e) => {
let err = XCheckerError::Config(ConfigError::InvalidValue {
key: "security".to_string(),
value: e.to_string(),
});
let contextual_report = error_utils::create_contextual_report(&err, "config");
eprintln!("{contextual_report}");
return Err(err.to_exit_code());
}
};
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
eprintln!("✗ Failed to create async runtime: {e}");
return Err(ExitCode::INTERNAL);
}
};
let operation = match &cli.command {
Commands::Spec { .. } => "spec",
Commands::Status { .. } => "status",
Commands::Resume { .. } => "resume",
Commands::Clean { .. } => "clean",
Commands::Benchmark { .. } => "benchmark",
Commands::Test { .. } => "test",
Commands::Doctor { .. } => "doctor",
Commands::Init { .. } => "init",
Commands::Project(_) => "project",
Commands::Gate { .. } => "gate",
Commands::Template(_) => "template",
};
let result = rt.block_on(async {
match cli.command {
Commands::Spec {
id,
source,
gh,
repo,
dry_run,
force,
apply_fixups,
strict_lock,
json,
} => {
let sanitized_id = sanitize_spec_id(&id).map_err(|e| {
XCheckerError::Config(ConfigError::InvalidValue {
key: "spec_id".to_string(),
value: format!("{e}"),
})
})?;
if json {
return execute_spec_json_command(&sanitized_id, &config);
}
execute_spec_command(
&sanitized_id,
&source,
gh.as_deref(),
repo.as_deref(),
dry_run,
cli.verbose,
force,
apply_fixups,
strict_lock,
&config,
&cli_args,
&redactor,
)
.await
}
Commands::Status { id, json } => {
let sanitized_id = sanitize_spec_id(&id).map_err(|e| {
XCheckerError::Config(ConfigError::InvalidValue {
key: "spec_id".to_string(),
value: format!("{e}"),
})
})?;
execute_status_command(&sanitized_id, json, &config)
}
Commands::Resume {
id,
phase,
dry_run,
force,
apply_fixups,
strict_lock,
json,
} => {
let sanitized_id = sanitize_spec_id(&id).map_err(|e| {
XCheckerError::Config(ConfigError::InvalidValue {
key: "spec_id".to_string(),
value: format!("{e}"),
})
})?;
if json {
return execute_resume_json_command(&sanitized_id, &phase, &config);
}
execute_resume_command(
&sanitized_id,
&phase,
dry_run,
cli.verbose,
force,
apply_fixups,
strict_lock,
&config,
&cli_args,
&redactor,
)
.await
}
Commands::Clean { id, hard, force } => {
let sanitized_id = sanitize_spec_id(&id).map_err(|e| {
XCheckerError::Config(ConfigError::InvalidValue {
key: "spec_id".to_string(),
value: format!("{e}"),
})
})?;
execute_clean_command(&sanitized_id, hard, force, &config)
}
Commands::Benchmark {
file_count,
file_size,
iterations,
json,
max_empty_run_secs,
max_packetization_ms,
max_rss_mb,
max_commit_mb,
} => execute_benchmark_command(
file_count,
file_size,
iterations,
json,
max_empty_run_secs,
max_packetization_ms,
max_rss_mb,
max_commit_mb,
cli.verbose,
),
Commands::Test { components, smoke } => {
execute_test_command(components, smoke, cli.verbose)
}
Commands::Doctor { json, strict_exit } => {
execute_doctor_command(json, strict_exit, &config)
}
Commands::Init { id, create_lock } => {
let sanitized_id = sanitize_spec_id(&id).map_err(|e| {
XCheckerError::Config(ConfigError::InvalidValue {
key: "spec_id".to_string(),
value: format!("{e}"),
})
})?;
execute_init_command(&sanitized_id, create_lock, &config)
}
Commands::Project(project_cmd) => execute_project_command(project_cmd),
Commands::Gate {
id,
policy,
min_phase,
fail_on_pending_fixups,
max_phase_age,
json,
} => {
let sanitized_id = sanitize_spec_id(&id).map_err(|e| {
XCheckerError::Config(ConfigError::InvalidValue {
key: "spec_id".to_string(),
value: format!("{e}"),
})
})?;
execute_gate_command(
&sanitized_id,
policy.as_deref(),
min_phase.as_deref(),
fail_on_pending_fixups,
max_phase_age.as_deref(),
json,
)
}
Commands::Template(template_cmd) => execute_template_command(template_cmd),
}
});
if let Err(error) = result {
if let Some(xchecker_error) = error.downcast_ref::<XCheckerError>() {
let contextual_report = error_utils::create_contextual_report_with_redactor(
xchecker_error,
operation,
redactor.as_ref(),
);
eprintln!("{contextual_report}");
return Err(xchecker_error.to_exit_code());
} else {
let redacted_error = redactor.redact_string(&error.to_string());
eprintln!("✗ Unexpected error: {redacted_error}");
if let Some(suggestions) = enhance_error_context(&error) {
eprintln!("\n Suggestions:");
for (i, suggestion) in suggestions.iter().enumerate() {
eprintln!(" {}. {}", i + 1, suggestion);
}
}
eprintln!("\n General troubleshooting:");
eprintln!(" - Run with --verbose for more detailed output");
eprintln!(" - Check the xchecker documentation for common issues");
eprintln!(" - Ensure all dependencies are properly installed");
return Err(ExitCode::INTERNAL);
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn execute_spec_command(
spec_id: &str,
source_type: &str,
gh_repo: Option<&str>,
fs_repo: Option<&str>,
dry_run: bool,
verbose: bool,
force: bool,
apply_fixups: bool,
strict_lock: bool,
config: &Config,
cli_args: &CliArgs,
redactor: &Arc<SecretRedactor>,
) -> Result<()> {
let mut logger = Logger::new(verbose);
logger.start_timing("total_execution");
logger.verbose(&format!("Starting spec generation for ID: {spec_id}"));
if dry_run {
logger.verbose("Running in dry-run mode (no Claude calls will be made)");
}
logger.start_timing("source_resolution");
let source_content = match source_type {
"gh" => {
let gh_repo = gh_repo.ok_or_else(|| {
XCheckerError::Config(ConfigError::MissingRequired("--gh owner/repo".to_string()))
})?;
let parts: Vec<&str> = gh_repo.split('/').collect();
if parts.len() != 2 {
return Err(XCheckerError::Config(ConfigError::InvalidValue {
key: "gh".to_string(),
value: gh_repo.to_string(),
})
.into());
}
SourceResolver::resolve_github(parts[0], parts[1], spec_id).map_err(|_e| {
XCheckerError::Source(crate::error::SourceError::GitHubRepoNotFound {
owner: parts[0].to_string(),
repo: parts[1].to_string(),
})
})?
}
"fs" => {
let fs_repo = fs_repo.ok_or_else(|| {
XCheckerError::Config(ConfigError::MissingRequired("--repo <path>".to_string()))
})?;
let path = PathBuf::from(fs_repo);
SourceResolver::resolve_filesystem(&path).map_err(|e| {
if path.exists() {
if path.is_dir() {
match std::fs::read_dir(&path) {
Err(io_err)
if io_err.kind() == std::io::ErrorKind::PermissionDenied =>
{
XCheckerError::Source(
crate::error::SourceError::FileSystemAccessDenied {
path: fs_repo.to_string(),
},
)
}
Err(_) => {
XCheckerError::Source(crate::error::SourceError::InvalidFormat {
reason: format!("Directory exists but cannot be read: {e}"),
})
}
Ok(_) => {
XCheckerError::Source(crate::error::SourceError::InvalidFormat {
reason: format!("Failed to resolve filesystem source: {e}"),
})
}
}
} else {
XCheckerError::Source(crate::error::SourceError::FileSystemNotDirectory {
path: fs_repo.to_string(),
})
}
} else {
XCheckerError::Source(crate::error::SourceError::FileSystemNotFound {
path: fs_repo.to_string(),
})
}
})?
}
"stdin" => {
SourceResolver::resolve_stdin().map_err(|e| {
let error_msg = e.to_string();
if error_msg.contains("empty") || error_msg.contains("EOF") {
XCheckerError::Source(crate::error::SourceError::EmptyInput)
} else if error_msg.contains("permission") || error_msg.contains("access") {
XCheckerError::Source(crate::error::SourceError::StdinReadFailed {
reason: "Permission denied or stdin not accessible".to_string(),
})
} else {
XCheckerError::Source(crate::error::SourceError::StdinReadFailed {
reason: error_msg,
})
}
})?
}
_ => {
return Err(XCheckerError::Config(ConfigError::InvalidValue {
key: "source".to_string(),
value: format!("Unknown source type '{source_type}'. Valid options: 'gh' (GitHub), 'fs' (filesystem), 'stdin' (standard input)"),
}).into());
}
};
logger.end_timing("source_resolution");
let problem_statement = source_content.content.clone();
logger.verbose(&format!("Source resolved successfully from: {source_type}"));
let spec_root = crate::paths::spec_root(spec_id);
let source_dir = spec_root.join("source");
crate::paths::ensure_dir_all(&source_dir)
.with_context(|| format!("Failed to create source directory: {}", source_dir))?;
let problem_path = source_dir.join("00-problem-statement.md");
write_file_atomic(
&problem_path,
&format!("# Problem Statement\n\n{}\n", problem_statement.trim()),
)
.with_context(|| format!("Failed to write problem statement: {}", problem_path))?;
logger.verbose(&format!("Problem statement written to: {}", problem_path));
let model_full_name = config.defaults.model.as_deref().unwrap_or("haiku");
let claude_cli_version = detect_claude_cli_version().unwrap_or_else(|_| "unknown".to_string());
let _lock_drift =
check_lockfile_drift(spec_id, strict_lock, model_full_name, &claude_cli_version)?;
let orchestrator_config = build_orchestrator_config(
dry_run,
verbose,
apply_fixups,
config,
cli_args,
Some(&problem_statement),
redactor.clone(),
);
logger.start_timing("orchestrator_setup");
let mut handle = OrchestratorHandle::with_config_and_force(spec_id, orchestrator_config, force)
.with_context(|| format!("Failed to create orchestrator for spec: {spec_id}"))?;
logger.end_timing("orchestrator_setup");
logger.verbose("Executing Requirements phase...");
logger.start_timing("requirements_phase");
let result = handle
.run_phase(PhaseId::Requirements)
.await
.with_context(|| "Failed to execute Requirements phase")?;
logger.end_timing("requirements_phase");
logger.end_timing("total_execution");
if result.success {
println!("✓ Requirements phase completed successfully");
logger.verbose(&format!("Phase: {}", result.phase.as_str()));
logger.verbose(&format!("Exit code: {}", result.exit_code));
logger.verbose(&format!(
"Artifacts created: {}",
result.artifact_paths.len()
));
for (i, path) in result.artifact_paths.iter().enumerate() {
logger.verbose(&format!(" {}: {}", i + 1, path.display()));
}
if let Some(receipt_path) = &result.receipt_path {
logger.verbose(&format!("Receipt: {}", receipt_path.display()));
}
logger.print_performance_summary();
println!("\nNext steps:");
println!(" - Review the generated requirements in .xchecker/specs/{spec_id}/artifacts/");
println!(" - Check status with: xchecker status {spec_id}");
println!(" - Continue to Design phase: xchecker resume {spec_id} --phase design");
} else {
let phase_error = PhaseError::ExecutionFailed {
phase: result.phase.as_str().to_string(),
code: result.exit_code,
};
let xchecker_error = XCheckerError::Phase(phase_error);
let report = ErrorReport::new(&xchecker_error);
eprintln!("{}", report.format_with_redactor(redactor.as_ref()));
if let Some(error_msg) = &result.error {
let redacted_error_msg = redactor.redact_string(error_msg);
eprintln!("\n Phase failure details: {redacted_error_msg}");
if error_msg.contains("ExecutionFailedWithStderr") {
eprintln!(" ↳ Claude CLI produced error output (see receipt for full stderr)");
} else if error_msg.contains("PartialOutputSaved") {
eprintln!(" ↳ Partial output was saved for debugging");
}
}
eprintln!("\n Debugging information:");
if !result.artifact_paths.is_empty() {
eprintln!(" Partial artifacts:");
for path in &result.artifact_paths {
eprintln!(" - {}", path.display());
}
}
eprintln!(" Spec directory: .xchecker/specs/{spec_id}/");
if let Some(receipt_path) = &result.receipt_path {
eprintln!(" Execution receipt: {}", receipt_path.display());
eprintln!(" ↳ Contains stderr output, warnings, and execution metadata");
}
eprintln!("\n Recovery options:");
eprintln!(" - Review partial outputs and receipt for error details");
eprintln!(" - Fix any configuration or connectivity issues");
eprintln!(" - Retry with: xchecker spec {spec_id}");
eprintln!(" - Test configuration with: xchecker spec {spec_id} --dry-run");
std::process::exit(result.exit_code);
}
Ok(())
}
fn execute_spec_json_command(spec_id: &str, config: &Config) -> Result<()> {
use crate::types::{PhaseId, PhaseInfo, SpecConfigSummary, SpecOutput};
let handle = OrchestratorHandle::readonly(spec_id)
.with_context(|| format!("Failed to create orchestrator for spec: {spec_id}"))?;
let base_path = handle.artifact_manager().base_path();
if !base_path.exists() {
let output = SpecOutput {
schema_version: "spec-json.v1".to_string(),
spec_id: spec_id.to_string(),
phases: vec![],
config_summary: SpecConfigSummary {
execution_strategy: config
.llm
.execution_strategy
.clone()
.unwrap_or_else(|| "controlled".to_string()),
provider: config.llm.provider.clone(),
spec_path: base_path.to_string(),
},
};
let json_output = emit_spec_json(&output)?;
println!("{json_output}");
return Ok(());
}
let all_phases = [
PhaseId::Requirements,
PhaseId::Design,
PhaseId::Tasks,
PhaseId::Review,
PhaseId::Fixup,
PhaseId::Final,
];
let receipts = handle.receipt_manager().list_receipts().unwrap_or_default();
let mut phases = Vec::new();
for phase_id in &all_phases {
let phase_completed = handle.artifact_manager().phase_completed(*phase_id);
let latest_receipt = receipts
.iter()
.filter(|r| r.phase == phase_id.as_str())
.max_by_key(|r| r.emitted_at);
let status = if phase_completed {
"completed".to_string()
} else if latest_receipt.is_some() {
"pending".to_string()
} else {
"not_started".to_string()
};
let last_run = latest_receipt.map(|r| r.emitted_at);
phases.push(PhaseInfo {
phase_id: phase_id.as_str().to_string(),
status,
last_run,
});
}
let config_summary = SpecConfigSummary {
execution_strategy: config
.llm
.execution_strategy
.clone()
.unwrap_or_else(|| "controlled".to_string()),
provider: config.llm.provider.clone(),
spec_path: base_path.to_string(),
};
let output = SpecOutput {
schema_version: "spec-json.v1".to_string(),
spec_id: spec_id.to_string(),
phases,
config_summary,
};
let json_output = emit_spec_json(&output)?;
println!("{json_output}");
Ok(())
}
fn emit_spec_json(output: &crate::types::SpecOutput) -> Result<String> {
emit_jcs(output).context("Failed to emit spec JSON")
}
fn emit_status_json(output: &crate::types::StatusJsonOutput) -> Result<String> {
emit_jcs(output).context("Failed to emit status JSON")
}
fn execute_resume_json_command(spec_id: &str, phase_name: &str, config: &Config) -> Result<()> {
use crate::types::{CurrentInputs, PhaseId, ResumeJsonOutput};
let phase_id = match phase_name.to_lowercase().as_str() {
"requirements" => PhaseId::Requirements,
"design" => PhaseId::Design,
"tasks" => PhaseId::Tasks,
"review" => PhaseId::Review,
"fixup" => PhaseId::Fixup,
"final" => PhaseId::Final,
_ => {
return Err(XCheckerError::Config(ConfigError::InvalidValue {
key: "phase".to_string(),
value: format!("Unknown phase '{phase_name}'. Valid phases: requirements, design, tasks, review, fixup, final"),
}).into());
}
};
let handle = OrchestratorHandle::readonly(spec_id)
.with_context(|| format!("Failed to create orchestrator for spec: {spec_id}"))?;
let base_path = handle.artifact_manager().base_path();
let spec_exists = base_path.exists();
let available_artifacts = if spec_exists {
handle
.artifact_manager()
.list_artifacts()
.unwrap_or_default()
} else {
vec![]
};
let latest_completed_phase = if spec_exists {
handle
.artifact_manager()
.get_latest_completed_phase()
.map(|p| p.as_str().to_string())
} else {
None
};
let current_inputs = CurrentInputs {
available_artifacts,
spec_exists,
latest_completed_phase,
};
let next_steps = generate_next_steps_hint(spec_id, phase_id, ¤t_inputs, config);
let output = ResumeJsonOutput {
schema_version: "resume-json.v1".to_string(),
spec_id: spec_id.to_string(),
phase: phase_id.as_str().to_string(),
current_inputs,
next_steps,
};
let json_output = emit_resume_json(&output)?;
println!("{json_output}");
Ok(())
}
fn generate_next_steps_hint(
spec_id: &str,
phase_id: PhaseId,
current_inputs: &crate::types::CurrentInputs,
_config: &Config,
) -> String {
if !current_inputs.spec_exists {
return format!(
"Spec '{}' does not exist. Run 'xchecker spec {}' to create it first.",
spec_id, spec_id
);
}
let has_requirements = current_inputs
.available_artifacts
.iter()
.any(|a| a.contains("requirements"));
let has_design = current_inputs
.available_artifacts
.iter()
.any(|a| a.contains("design"));
let has_tasks = current_inputs
.available_artifacts
.iter()
.any(|a| a.contains("tasks"));
let has_review = current_inputs
.available_artifacts
.iter()
.any(|a| a.contains("review"));
match phase_id {
PhaseId::Requirements => {
"Run requirements phase to generate initial requirements from the problem statement."
.to_string()
}
PhaseId::Design => {
if has_requirements {
"Run design phase to generate architecture and design from requirements."
.to_string()
} else {
format!(
"Requirements phase not completed. Run 'xchecker resume {} --phase requirements' first.",
spec_id
)
}
}
PhaseId::Tasks => {
if has_design {
"Run tasks phase to generate implementation tasks from design.".to_string()
} else {
format!(
"Design phase not completed. Run 'xchecker resume {} --phase design' first.",
spec_id
)
}
}
PhaseId::Review => {
if has_tasks {
"Run review phase to review and validate the generated spec.".to_string()
} else {
format!(
"Tasks phase not completed. Run 'xchecker resume {} --phase tasks' first.",
spec_id
)
}
}
PhaseId::Fixup => {
if has_review {
"Run fixup phase to apply any suggested changes from review.".to_string()
} else {
format!(
"Review phase not completed. Run 'xchecker resume {} --phase review' first.",
spec_id
)
}
}
PhaseId::Final => "Run final phase to complete the spec generation workflow.".to_string(),
}
}
fn emit_resume_json(output: &crate::types::ResumeJsonOutput) -> Result<String> {
emit_jcs(output).context("Failed to emit resume JSON")
}
fn execute_status_command(spec_id: &str, json: bool, config: &Config) -> Result<()> {
let handle = OrchestratorHandle::readonly(spec_id)
.with_context(|| format!("Failed to create orchestrator for spec: {spec_id}"))?;
let base_path = handle.artifact_manager().base_path();
if !base_path.exists() {
if json {
println!("{{}}");
} else {
println!("Status for spec: {spec_id}");
println!(" Status: No spec found");
println!(" Directory: {base_path} (does not exist)");
}
return Ok(());
}
if json {
use crate::lock::{RunContext, XCheckerLock};
use crate::types::{
ArtifactInfo, ConfigSource, ConfigValue, PhaseStatusInfo, StatusJsonOutput,
};
use std::collections::BTreeMap;
let all_phases = [
PhaseId::Requirements,
PhaseId::Design,
PhaseId::Tasks,
PhaseId::Review,
PhaseId::Fixup,
PhaseId::Final,
];
let receipts = handle.receipt_manager().list_receipts().unwrap_or_default();
let mut phase_statuses = Vec::new();
let mut has_errors = false;
for phase_id in &all_phases {
let latest_receipt = receipts
.iter()
.filter(|r| r.phase == phase_id.as_str())
.max_by_key(|r| r.emitted_at);
let (status, receipt_id) = if let Some(receipt) = latest_receipt {
if receipt.exit_code == 0 {
(
"success".to_string(),
Some(format!(
"{}-{}",
receipt.phase,
receipt.emitted_at.format("%Y%m%d_%H%M%S")
)),
)
} else {
has_errors = true;
(
"failed".to_string(),
Some(format!(
"{}-{}",
receipt.phase,
receipt.emitted_at.format("%Y%m%d_%H%M%S")
)),
)
}
} else {
("not_started".to_string(), None)
};
phase_statuses.push(PhaseStatusInfo {
phase_id: phase_id.as_str().to_string(),
status,
receipt_id,
});
}
let pending_fixups = count_pending_fixups_for_spec(spec_id);
let mut artifact_hashes: BTreeMap<String, String> = BTreeMap::new();
for receipt in &receipts {
for output in &receipt.outputs {
if let Some(filename) = output.path.split('/').next_back() {
let short_hash = if output.blake3_canonicalized.len() >= 8 {
&output.blake3_canonicalized[..8]
} else {
&output.blake3_canonicalized
};
artifact_hashes.insert(filename.to_string(), short_hash.to_string());
}
}
}
let artifact_files = handle
.artifact_manager()
.list_artifacts()
.unwrap_or_default();
let mut artifacts: Vec<ArtifactInfo> = artifact_files
.iter()
.filter_map(|filename| {
artifact_hashes.get(filename).map(|hash| ArtifactInfo {
path: format!("artifacts/{filename}"),
blake3_first8: hash.clone(),
})
})
.collect();
artifacts.sort_by(|a, b| a.path.cmp(&b.path));
let mut effective_config: BTreeMap<String, ConfigValue> = BTreeMap::new();
if let Some(ref provider) = config.llm.provider {
let source = config
.source_attribution
.get("provider")
.cloned()
.unwrap_or(ConfigSource::Config);
effective_config.insert(
"provider".to_string(),
ConfigValue {
value: serde_json::Value::String(provider.clone()),
source,
},
);
}
if let Some(ref model) = config.defaults.model {
let source = config
.source_attribution
.get("model")
.cloned()
.unwrap_or(ConfigSource::Config);
effective_config.insert(
"model".to_string(),
ConfigValue {
value: serde_json::Value::String(model.clone()),
source,
},
);
}
if let Some(max_turns) = config.defaults.max_turns {
let source = config
.source_attribution
.get("max_turns")
.cloned()
.unwrap_or(ConfigSource::Config);
effective_config.insert(
"max_turns".to_string(),
ConfigValue {
value: serde_json::Value::Number(max_turns.into()),
source,
},
);
}
if let Some(timeout) = config.defaults.phase_timeout {
let source = config
.source_attribution
.get("phase_timeout")
.cloned()
.unwrap_or(ConfigSource::Config);
effective_config.insert(
"phase_timeout".to_string(),
ConfigValue {
value: serde_json::Value::Number(timeout.into()),
source,
},
);
}
if let Some(ref strategy) = config.llm.execution_strategy {
let source = config
.source_attribution
.get("execution_strategy")
.cloned()
.unwrap_or(ConfigSource::Config);
effective_config.insert(
"execution_strategy".to_string(),
ConfigValue {
value: serde_json::Value::String(strategy.clone()),
source,
},
);
}
let lock_drift = if let Ok(Some(lock)) = XCheckerLock::load(spec_id) {
let model_full_name = receipts
.last()
.map(|r| r.model_full_name.clone())
.unwrap_or_else(|| config.defaults.model.clone().unwrap_or_default());
let claude_cli_version = receipts
.last()
.map(|r| r.claude_cli_version.clone())
.unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
let context = RunContext {
model_full_name,
claude_cli_version,
schema_version: "1".to_string(),
};
lock.detect_drift(&context)
} else {
None
};
let output = StatusJsonOutput {
schema_version: "status-json.v2".to_string(),
spec_id: spec_id.to_string(),
phase_statuses,
pending_fixups,
has_errors,
strict_validation: config.strict_validation(),
artifacts,
effective_config,
lock_drift,
};
let json_output =
emit_status_json(&output).with_context(|| "Failed to emit status JSON")?;
println!("{json_output}");
return Ok(());
}
println!("Status for spec: {spec_id}");
println!(" Directory: {base_path}");
let latest_completed = handle.artifact_manager().get_latest_completed_phase();
match latest_completed {
Some(phase) => {
println!(" Latest completed phase: {}", phase.as_str());
}
None => {
println!(" Latest completed phase: None");
}
}
let artifacts = handle
.artifact_manager()
.list_artifacts()
.with_context(|| "Failed to list artifacts")?;
if artifacts.is_empty() {
println!(" Artifacts: None");
} else {
println!(" Artifacts: {} found", artifacts.len());
let receipts = handle
.receipt_manager()
.list_receipts()
.with_context(|| "Failed to list receipts")?;
let mut artifact_hashes: HashMap<String, String> = HashMap::new();
for receipt in &receipts {
for output in &receipt.outputs {
if let Some(filename) = output.path.split('/').next_back() {
let short_hash = if output.blake3_canonicalized.len() >= 8 {
&output.blake3_canonicalized[..8]
} else {
&output.blake3_canonicalized
};
artifact_hashes.insert(filename.to_string(), short_hash.to_string());
}
}
}
for artifact in &artifacts {
if let Some(hash) = artifact_hashes.get(artifact) {
println!(" - {artifact} -> {hash}");
} else {
println!(" - {artifact} -> <no hash>");
}
}
}
let receipts = handle
.receipt_manager()
.list_receipts()
.with_context(|| "Failed to list receipts")?;
if receipts.is_empty() {
println!(" Last receipt: None");
} else {
let latest_receipt = receipts.last().unwrap();
let receipt_filename = format!(
"{}-{}.json",
latest_receipt.phase,
latest_receipt.emitted_at.format("%Y%m%d_%H%M%S")
);
let receipt_path = base_path.join("receipts").join(receipt_filename);
println!(" Last receipt: {receipt_path}");
println!(" Phase: {}", latest_receipt.phase);
println!(
" Emitted at: {}",
latest_receipt.emitted_at.format("%Y-%m-%d %H:%M:%S UTC")
);
println!(" Exit code: {}", latest_receipt.exit_code);
println!(" Model: {}", latest_receipt.model_full_name);
if let Some(alias) = &latest_receipt.model_alias {
println!(" Model alias: {alias}");
}
println!(" Runner: {}", latest_receipt.runner);
if let Some(distro) = &latest_receipt.runner_distro {
println!(" Runner distro: {distro}");
}
println!(
" Canonicalization: {}",
latest_receipt.canonicalization_version
);
if !latest_receipt.warnings.is_empty() {
println!(" Warnings: {}", latest_receipt.warnings.len());
for warning in &latest_receipt.warnings {
println!(" - {warning}");
}
}
if latest_receipt.fallback_used == Some(true) {
println!(" Output format fallback: Used (stream-json → text)");
}
}
println!("\n Effective configuration:");
let effective_config = config.effective_config();
for (key, (value, source)) in effective_config {
println!(" {key} = {value} (from {source})");
}
let phases = [
PhaseId::Requirements,
PhaseId::Design,
PhaseId::Tasks,
PhaseId::Review,
PhaseId::Fixup,
PhaseId::Final,
];
let mut partial_phases = Vec::new();
let mut completed_phases = Vec::new();
for phase in phases {
if handle.artifact_manager().has_partial_artifact(phase) {
partial_phases.push(phase);
}
if handle.artifact_manager().phase_completed(phase) {
completed_phases.push(phase);
}
}
if !partial_phases.is_empty() {
println!("\n Partial artifacts found:");
for phase in partial_phases {
println!(" - {} (from failed execution)", phase.as_str());
}
}
if !completed_phases.is_empty() {
println!("\n Completed phases:");
for phase in completed_phases {
println!(" - {}", phase.as_str());
}
}
check_and_display_fixup_targets(spec_id)?;
match latest_completed {
Some(PhaseId::Requirements) => {
println!("\n Resume options:");
println!(" - Continue to Design: xchecker resume {spec_id} --phase design");
println!(" - Re-run Requirements: xchecker resume {spec_id} --phase requirements");
}
Some(PhaseId::Design) => {
println!("\n Resume options:");
println!(" - Continue to Tasks: xchecker resume {spec_id} --phase tasks");
println!(" - Re-run Design: xchecker resume {spec_id} --phase design");
}
Some(PhaseId::Tasks) => {
println!("\n Resume options:");
println!(" - Continue to Review: xchecker resume {spec_id} --phase review");
println!(" - Re-run Tasks: xchecker resume {spec_id} --phase tasks");
}
Some(PhaseId::Review) => {
println!("\n Resume options:");
println!(" - Continue to Fixup: xchecker resume {spec_id} --phase fixup");
println!(" - Re-run Review: xchecker resume {spec_id} --phase review");
}
Some(_) => {
println!("\n Resume options:");
println!(" - Re-run any phase: xchecker resume {spec_id} --phase <phase_name>");
}
None => {
println!("\n Resume options:");
println!(
" - Start from Requirements: xchecker resume {spec_id} --phase requirements"
);
}
}
Ok(())
}
fn check_and_display_fixup_targets(spec_id: &str) -> Result<()> {
use crate::fixup::{FixupMode, FixupParser};
let base_path = crate::paths::spec_root(spec_id);
let review_md_path = base_path.join("artifacts").join("30-review.md");
if !review_md_path.exists() {
return Ok(()); }
let review_content = match std::fs::read_to_string(&review_md_path) {
Ok(content) => content,
Err(_) => return Ok(()), };
let fixup_parser = FixupParser::new(FixupMode::Preview, base_path.clone().into())?;
if !fixup_parser.has_fixup_markers(&review_content) {
return Ok(()); }
match fixup_parser.parse_diffs(&review_content) {
Ok(diffs) => {
if !diffs.is_empty() {
println!("\n Pending fixups detected:");
println!(" Fixup markers found in review phase");
println!(" Intended targets ({} files):", diffs.len());
for diff in &diffs {
println!(" - {}", diff.target_file);
}
match fixup_parser.preview_changes(&diffs) {
Ok(preview) => {
if !preview.all_valid {
println!(" ⚠ Warning: Some diffs failed validation");
}
if !preview.warnings.is_empty() {
println!(" Validation warnings:");
for warning in &preview.warnings {
println!(" - {warning}");
}
}
let mut total_added = 0;
let mut total_removed = 0;
for (file, summary) in &preview.change_summary {
total_added += summary.lines_added;
total_removed += summary.lines_removed;
if !summary.validation_passed {
println!(" ✗ {file}: validation failed");
}
}
if total_added > 0 || total_removed > 0 {
println!(
" Estimated changes: +{total_added} lines, -{total_removed} lines"
);
}
}
Err(e) => {
println!(" ⚠ Warning: Failed to preview changes: {e}");
}
}
println!("\n To apply fixups:");
println!(" xchecker resume {spec_id} --phase fixup --apply-fixups");
println!(" To preview only (default):");
println!(" xchecker resume {spec_id} --phase fixup");
}
}
Err(e) => {
println!("\n Fixup parsing error: {e}");
println!(" Review phase contains fixup markers but diffs could not be parsed");
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn execute_resume_command(
spec_id: &str,
phase_name: &str,
dry_run: bool,
verbose: bool,
force: bool,
apply_fixups: bool,
strict_lock: bool,
config: &Config,
cli_args: &CliArgs,
redactor: &Arc<SecretRedactor>,
) -> Result<()> {
let mut logger = Logger::new(verbose);
logger.start_timing("total_execution");
let phase_id = match phase_name.to_lowercase().as_str() {
"requirements" => PhaseId::Requirements,
"design" => PhaseId::Design,
"tasks" => PhaseId::Tasks,
"review" => PhaseId::Review,
"fixup" => PhaseId::Fixup,
"final" => PhaseId::Final,
_ => {
return Err(XCheckerError::Config(ConfigError::InvalidValue {
key: "phase".to_string(),
value: format!("Unknown phase '{phase_name}'. Valid phases: requirements, design, tasks, review, fixup, final"),
}).into());
}
};
logger.verbose(&format!(
"Resuming spec {} from {} phase",
spec_id,
phase_id.as_str()
));
if dry_run {
logger.verbose("Running in dry-run mode (no Claude calls will be made)");
}
let model_full_name = config.defaults.model.as_deref().unwrap_or("haiku");
let claude_cli_version = detect_claude_cli_version().unwrap_or_else(|_| "unknown".to_string());
let _lock_drift =
check_lockfile_drift(spec_id, strict_lock, model_full_name, &claude_cli_version)?;
let orchestrator_config = build_orchestrator_config(
dry_run,
verbose,
apply_fixups,
config,
cli_args,
None,
redactor.clone(),
);
logger.start_timing("orchestrator_setup");
let mut handle = OrchestratorHandle::with_config_and_force(spec_id, orchestrator_config, force)
.with_context(|| format!("Failed to create orchestrator for spec: {spec_id}"))?;
logger.end_timing("orchestrator_setup");
let base_path = handle.artifact_manager().base_path();
if !base_path.exists() {
return Err(XCheckerError::Config(ConfigError::NotFound {
path: format!("Spec directory: {base_path}"),
})
.into());
}
logger.verbose(&format!(
"Checking dependencies for {} phase...",
phase_id.as_str()
));
logger.start_timing(&format!("{}_phase", phase_id.as_str()));
let result = handle
.run_phase(phase_id)
.await
.with_context(|| format!("Failed to resume {} phase", phase_id.as_str()))?;
logger.end_timing(&format!("{}_phase", phase_id.as_str()));
logger.end_timing("total_execution");
if result.success {
println!("✓ {} phase completed successfully", phase_id.as_str());
logger.verbose(&format!("Phase: {}", result.phase.as_str()));
logger.verbose(&format!("Exit code: {}", result.exit_code));
logger.verbose(&format!(
"Artifacts created: {}",
result.artifact_paths.len()
));
for (i, path) in result.artifact_paths.iter().enumerate() {
logger.verbose(&format!(" {}: {}", i + 1, path.display()));
}
if let Some(receipt_path) = &result.receipt_path {
logger.verbose(&format!("Receipt: {}", receipt_path.display()));
}
logger.print_performance_summary();
println!("\nNext steps:");
match phase_id {
PhaseId::Requirements => {
println!(
" - Review the generated requirements in .xchecker/specs/{spec_id}/artifacts/"
);
println!(" - Continue to Design phase: xchecker resume {spec_id} --phase design");
}
PhaseId::Design => {
println!(" - Review the generated design in .xchecker/specs/{spec_id}/artifacts/");
println!(" - Continue to Tasks phase: xchecker resume {spec_id} --phase tasks");
}
PhaseId::Tasks => {
println!(" - Review the generated tasks in .xchecker/specs/{spec_id}/artifacts/");
println!(" - Continue to Review phase: xchecker resume {spec_id} --phase review");
}
_ => {
println!(" - Check status with: xchecker status {spec_id}");
}
}
} else {
let phase_error = PhaseError::ExecutionFailed {
phase: result.phase.as_str().to_string(),
code: result.exit_code,
};
let xchecker_error = XCheckerError::Phase(phase_error);
let report = ErrorReport::new(&xchecker_error);
eprintln!("{}", report.format_with_redactor(redactor.as_ref()));
if let Some(error_msg) = &result.error {
let redacted_error_msg = redactor.redact_string(error_msg);
eprintln!("\n Phase failure details: {redacted_error_msg}");
}
eprintln!("\n Debugging information:");
if !result.artifact_paths.is_empty() {
eprintln!(" Partial artifacts:");
for path in &result.artifact_paths {
eprintln!(" - {}", path.display());
}
}
eprintln!(" Spec directory: .xchecker/specs/{spec_id}/");
if let Some(receipt_path) = &result.receipt_path {
eprintln!(" Execution receipt: {}", receipt_path.display());
}
eprintln!("\n Recovery options:");
eprintln!(" - Review partial outputs and receipt for error details");
eprintln!(" - Fix any configuration or connectivity issues");
eprintln!(" - Retry with: xchecker resume {spec_id} --phase {phase_name}");
eprintln!(
" - Test configuration with: xchecker resume {spec_id} --phase {phase_name} --dry-run"
);
std::process::exit(result.exit_code);
}
Ok(())
}
fn execute_clean_command(spec_id: &str, hard: bool, force: bool, _config: &Config) -> Result<()> {
use crate::lock::utils;
if let Err(lock_error) = utils::can_clean(spec_id, force, None) {
return Err(anyhow::anyhow!(
"Cannot clean spec '{spec_id}': {lock_error}"
));
}
let (base_path, artifacts_path, receipts_path, context_path, artifacts, receipts) = {
let handle = OrchestratorHandle::with_force(spec_id, force)
.with_context(|| format!("Failed to create orchestrator for spec: {spec_id}"))?;
let base_path = handle.artifact_manager().base_path();
if !base_path.exists() {
println!("No spec found for ID: {spec_id}");
println!("Directory: {base_path} (does not exist)");
return Ok(());
}
println!("Clean spec: {spec_id}");
println!(" Directory: {base_path}");
let artifacts = handle
.artifact_manager()
.list_artifacts()
.with_context(|| "Failed to list artifacts")?;
let receipts = handle
.receipt_manager()
.list_receipts()
.with_context(|| "Failed to list receipts")?;
if artifacts.is_empty() && receipts.is_empty() {
println!(" Nothing to clean (no artifacts or receipts found)");
if !hard {
return Ok(());
}
}
println!(" Will remove:");
if !artifacts.is_empty() {
println!(" Artifacts: {} files", artifacts.len());
for artifact in &artifacts {
println!(" - {artifact}");
}
}
if !receipts.is_empty() {
println!(" Receipts: {} files", receipts.len());
for receipt in &receipts {
let receipt_filename = format!(
"{}-{}.json",
receipt.phase,
receipt.emitted_at.format("%Y%m%d_%H%M%S")
);
println!(" - {receipt_filename}");
}
}
let artifacts_path = handle.artifact_manager().artifacts_path().to_path_buf();
let receipts_path = base_path.join("receipts");
let context_path = base_path.join("context");
let base_path_owned = base_path.to_path_buf();
(
base_path_owned,
artifacts_path,
receipts_path,
context_path,
artifacts,
receipts,
)
};
if !hard {
println!("\nThis will permanently delete all artifacts and receipts for spec '{spec_id}'.");
print!("Are you sure? (y/N): ");
if let Err(e) = std::io::stdout().flush() {
tracing::warn!("Failed to flush stdout: {}", e);
}
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
if input != "y" && input != "yes" {
println!("Clean cancelled.");
return Ok(());
}
}
let mut removed_count = 0;
if artifacts_path.exists() {
std::fs::remove_dir_all(&artifacts_path)
.with_context(|| format!("Failed to remove artifacts directory: {artifacts_path}"))?;
removed_count += artifacts.len();
println!("{} Removed artifacts directory", styled_check());
}
if receipts_path.exists() {
std::fs::remove_dir_all(&receipts_path)
.with_context(|| format!("Failed to remove receipts directory: {receipts_path}"))?;
removed_count += receipts.len();
println!("{} Removed receipts directory", styled_check());
}
if context_path.exists() {
std::fs::remove_dir_all(&context_path)
.with_context(|| format!("Failed to remove context directory: {context_path}"))?;
println!("{} Removed context directory", styled_check());
}
if base_path.exists() {
if hard {
std::fs::remove_dir_all(&base_path)
.with_context(|| format!("Failed to remove spec directory: {base_path}"))?;
println!("{} Removed spec directory completely", styled_check());
} else {
match std::fs::remove_dir(&base_path) {
Ok(()) => {
println!("{} Removed empty spec directory", styled_check());
}
Err(_) => {
println!(
"{} Spec directory retained (contains other files)",
styled_check()
);
}
}
}
}
println!("\n{}", styled_success("Clean completed successfully."));
println!(" Removed {removed_count} files total");
Ok(())
}
fn create_default_config(
verbose: bool,
config: &Config,
cli_args: &CliArgs,
) -> HashMap<String, String> {
let mut config_map = HashMap::new();
if verbose {
config_map.insert("verbose".to_string(), "true".to_string());
}
if let Some(packet_max_bytes) = config.defaults.packet_max_bytes {
config_map.insert("packet_max_bytes".to_string(), packet_max_bytes.to_string());
}
if let Some(packet_max_lines) = config.defaults.packet_max_lines {
config_map.insert("packet_max_lines".to_string(), packet_max_lines.to_string());
}
if let Some(max_turns) = config.defaults.max_turns {
config_map.insert("max_turns".to_string(), max_turns.to_string());
}
if let Some(model) = &config.defaults.model {
config_map.insert("model".to_string(), model.clone());
}
if let Some(output_format) = &config.defaults.output_format {
config_map.insert("output_format".to_string(), output_format.clone());
}
if let Some(phase_timeout) = config.defaults.phase_timeout {
config_map.insert("phase_timeout".to_string(), phase_timeout.to_string());
}
if let Some(stdout_cap_bytes) = config.defaults.stdout_cap_bytes {
config_map.insert("stdout_cap_bytes".to_string(), stdout_cap_bytes.to_string());
}
if let Some(stderr_cap_bytes) = config.defaults.stderr_cap_bytes {
config_map.insert("stderr_cap_bytes".to_string(), stderr_cap_bytes.to_string());
}
if let Some(lock_ttl_seconds) = config.defaults.lock_ttl_seconds {
config_map.insert("lock_ttl_seconds".to_string(), lock_ttl_seconds.to_string());
}
if let Some(debug_packet) = config.defaults.debug_packet
&& debug_packet
{
config_map.insert("debug_packet".to_string(), "true".to_string());
}
if let Some(allow_links) = config.defaults.allow_links
&& allow_links
{
config_map.insert("allow_links".to_string(), "true".to_string());
}
if let Some(runner_mode) = &config.runner.mode {
config_map.insert("runner_mode".to_string(), runner_mode.clone());
}
if let Some(runner_distro) = &config.runner.distro {
config_map.insert("runner_distro".to_string(), runner_distro.clone());
}
if let Some(claude_path) = &config.runner.claude_path {
config_map.insert("claude_path".to_string(), claude_path.clone());
}
if let Some(provider) = &config.llm.provider {
config_map.insert("llm_provider".to_string(), provider.clone());
}
if let Some(fallback_provider) = &config.llm.fallback_provider {
config_map.insert(
"llm_fallback_provider".to_string(),
fallback_provider.clone(),
);
}
if let Some(execution_strategy) = &config.llm.execution_strategy {
config_map.insert("execution_strategy".to_string(), execution_strategy.clone());
}
if let Some(prompt_template) = &config.llm.prompt_template {
config_map.insert("prompt_template".to_string(), prompt_template.clone());
}
if let Some(claude_config) = &config.llm.claude
&& let Some(binary) = &claude_config.binary
{
config_map.insert("llm_claude_binary".to_string(), binary.clone());
}
if let Some(gemini_config) = &config.llm.gemini {
if let Some(binary) = &gemini_config.binary {
config_map.insert("llm_gemini_binary".to_string(), binary.clone());
}
if let Some(default_model) = &gemini_config.default_model {
config_map.insert(
"llm_gemini_default_model".to_string(),
default_model.clone(),
);
}
}
if !cli_args.allow.is_empty() {
config_map.insert("allowed_tools".to_string(), cli_args.allow.join(","));
}
if !cli_args.deny.is_empty() {
config_map.insert("disallowed_tools".to_string(), cli_args.deny.join(","));
}
if cli_args.dangerously_skip_permissions {
config_map.insert(
"dangerously_skip_permissions".to_string(),
"true".to_string(),
);
}
if !cli_args.ignore_secret_pattern.is_empty() {
config_map.insert(
"ignore_secret_patterns".to_string(),
cli_args.ignore_secret_pattern.join("|"),
);
}
if !cli_args.extra_secret_pattern.is_empty() {
config_map.insert(
"extra_secret_patterns".to_string(),
cli_args.extra_secret_pattern.join("|"),
);
}
if cli_args.debug_packet {
config_map.insert("debug_packet".to_string(), "true".to_string());
}
config_map
}
fn build_orchestrator_config(
dry_run: bool,
verbose: bool,
apply_fixups: bool,
config: &Config,
cli_args: &CliArgs,
problem_statement: Option<&str>,
redactor: Arc<SecretRedactor>,
) -> OrchestratorConfig {
let mut config_map = create_default_config(verbose, config, cli_args);
config_map.insert("logger_enabled".to_string(), verbose.to_string());
config_map.insert("apply_fixups".to_string(), apply_fixups.to_string());
if let Some(ps) = problem_statement {
config_map.insert("problem_statement".to_string(), ps.to_string());
}
OrchestratorConfig {
dry_run,
config: config_map,
full_config: Some(config.clone()),
selectors: Some(config.selectors.clone()),
strict_validation: config.strict_validation(),
redactor,
hooks: Some(config.hooks.clone()),
}
}
fn enhance_error_context(error: &anyhow::Error) -> Option<Vec<String>> {
let error_str = error.to_string();
if error_str.contains("Failed to create orchestrator") {
Some(vec![
"Check that the current directory is writable".to_string(),
"Ensure sufficient disk space is available".to_string(),
"Verify directory permissions".to_string(),
"Try running from a different directory".to_string(),
])
} else if error_str.contains("Failed to execute") {
Some(vec![
"Check the spec ID is valid and doesn't contain special characters".to_string(),
"Verify Claude CLI is installed and accessible".to_string(),
"Try running with --dry-run to test configuration".to_string(),
"Check your internet connection if using Claude API".to_string(),
])
} else if error_str.contains("Permission denied") {
Some(vec![
"Check file and directory permissions".to_string(),
"Ensure you have write access to the current directory".to_string(),
"Try running from your home directory or a writable location".to_string(),
])
} else if error_str.contains("No such file or directory") {
Some(vec![
"Verify the specified paths exist".to_string(),
"Check that you're running from the correct directory".to_string(),
"Ensure all required files are present".to_string(),
])
} else {
None
}
}
fn execute_test_command(components: bool, smoke: bool, verbose: bool) -> Result<()> {
use crate::integration_tests;
if verbose {
println!("Running integration tests...");
}
let run_components = components || !smoke;
let run_smoke = smoke || !components;
if run_components {
integration_tests::validate_component_integration()
.with_context(|| "Component integration validation failed")?;
}
if run_smoke {
integration_tests::run_smoke_tests().with_context(|| "Smoke tests failed")?;
}
println!("✓ All integration tests passed successfully");
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn execute_benchmark_command(
file_count: usize,
file_size: usize,
iterations: usize,
json: bool,
max_empty_run_secs: Option<f64>,
max_packetization_ms: Option<f64>,
max_rss_mb: Option<f64>,
max_commit_mb: Option<f64>,
verbose: bool,
) -> Result<()> {
use crate::benchmark::{BenchmarkConfig, BenchmarkRunner, BenchmarkThresholds};
let mut thresholds = BenchmarkThresholds::default();
if let Some(max_secs) = max_empty_run_secs {
thresholds.empty_run_max_secs = max_secs;
}
if let Some(max_ms) = max_packetization_ms {
thresholds.packetization_max_ms_per_100_files = max_ms;
}
if let Some(max_rss) = max_rss_mb {
thresholds.max_rss_mb = Some(max_rss);
}
if let Some(max_commit) = max_commit_mb {
thresholds.max_commit_mb = Some(max_commit);
}
if !json {
println!("=== xchecker Performance Benchmark ===");
println!("Validating NFR1 performance targets:");
println!(" - Empty run: ≤ {:.3}s", thresholds.empty_run_max_secs);
println!(
" - Packetization: ≤ {:.1}ms per 100 files",
thresholds.packetization_max_ms_per_100_files
);
if let Some(max_rss) = thresholds.max_rss_mb {
println!(" - RSS memory: ≤ {max_rss:.1}MB");
}
if let Some(max_commit) = thresholds.max_commit_mb {
println!(" - Commit memory: ≤ {max_commit:.1}MB");
}
println!();
}
let config = BenchmarkConfig {
file_count,
file_size_bytes: file_size,
iterations,
verbose: verbose && !json, thresholds,
};
if verbose && !json {
println!("Benchmark configuration:");
println!(" File count: {}", config.file_count);
println!(" File size: {} bytes", config.file_size_bytes);
println!(" Iterations: {}", config.iterations);
println!();
}
let runner = BenchmarkRunner::new(config);
let results = runner
.run_all_benchmarks()
.context("Failed to run benchmarks")?;
if json {
use serde_json::json;
let json_output = json!({
"ok": results.ok,
"timings_ms": results.timings_ms,
"rss_mb": results.rss_mb,
"commit_mb": results.commit_mb,
"violations": results.violations,
"config": {
"file_count": file_count,
"file_size_bytes": file_size,
"iterations": iterations,
},
"thresholds": {
"empty_run_max_secs": runner.config.thresholds.empty_run_max_secs,
"packetization_max_ms_per_100_files": runner.config.thresholds.packetization_max_ms_per_100_files,
"max_rss_mb": runner.config.thresholds.max_rss_mb,
"max_commit_mb": runner.config.thresholds.max_commit_mb,
}
});
let canonical_json = emit_jcs(&json_output).context("Failed to emit benchmark JSON")?;
println!("{canonical_json}");
} else {
runner.print_summary(&results);
}
if results.ok {
if !json {
println!("\n✓ All performance targets met!");
}
Ok(())
} else {
if !json {
println!("\n✗ Some performance targets not met.");
}
std::process::exit(1);
}
}
fn execute_doctor_command(json: bool, strict_exit: bool, config: &Config) -> Result<()> {
use crate::doctor::DoctorCommand;
let mut doctor = DoctorCommand::new(config.clone());
let spinner_guard = if !json && std::io::stdout().is_terminal() {
Some(SpinnerGuard::new())
} else {
None
};
let result = doctor.run_with_options_strict(strict_exit);
drop(spinner_guard);
let output = result.context("Failed to run doctor checks")?;
if json {
let json_output = emit_jcs(&output).context("Failed to emit doctor JSON")?;
println!("{json_output}");
} else {
crate::logging::log_doctor_report(&output);
if !output.ok {
println!();
if strict_exit {
println!(
"Some checks failed or warned (strict mode). Please address the issues above."
);
} else {
println!(
"Some checks failed. Please address the issues above before using xchecker."
);
}
}
}
if !output.ok {
std::process::exit(1);
}
Ok(())
}
fn execute_gate_command(
spec_id: &str,
policy_path: Option<&std::path::Path>,
min_phase: Option<&str>,
fail_on_pending_fixups: bool,
max_phase_age: Option<&str>,
json: bool,
) -> Result<()> {
use xchecker_gate::{
GateCommand, GatePolicy, emit_gate_json, load_policy_from_path, parse_duration,
parse_phase, resolve_policy_path,
};
let policy_path = resolve_policy_path(policy_path).map_err(|e| {
XCheckerError::Config(ConfigError::InvalidValue {
key: "policy".to_string(),
value: e.to_string(),
})
})?;
let mut policy = if let Some(path) = policy_path {
load_policy_from_path(&path).map_err(|e| {
XCheckerError::Config(ConfigError::InvalidValue {
key: "policy".to_string(),
value: e.to_string(),
})
})?
} else {
GatePolicy::default()
};
if let Some(min_phase) = min_phase {
policy.min_phase = Some(parse_phase(min_phase).map_err(|e| {
XCheckerError::Config(ConfigError::InvalidValue {
key: "min_phase".to_string(),
value: e.to_string(),
})
})?);
}
if fail_on_pending_fixups {
policy.fail_on_pending_fixups = true;
}
if let Some(age_str) = max_phase_age {
policy.max_phase_age = Some(parse_duration(age_str).map_err(|e| {
XCheckerError::Config(ConfigError::InvalidValue {
key: "max_phase_age".to_string(),
value: e.to_string(),
})
})?);
}
let gate = GateCommand::new(spec_id.to_string(), policy);
let result = gate
.execute()
.with_context(|| format!("Failed to evaluate gate for spec: {spec_id}"))?;
if json {
let json_output =
emit_gate_json(&result, spec_id).with_context(|| "Failed to emit gate JSON")?;
println!("{json_output}");
} else {
if result.passed {
println!("✓ {}", result.summary);
} else {
println!("✗ {}", result.summary);
}
println!();
println!("Conditions evaluated:");
for condition in &result.conditions {
let status = if condition.passed { "✓" } else { "✗" };
println!(" {} {}: {}", status, condition.name, condition.description);
if let Some(actual) = &condition.actual {
println!(" Actual: {}", actual);
}
if let Some(expected) = &condition.expected {
println!(" Expected: {}", expected);
}
}
if !result.failure_reasons.is_empty() {
println!();
println!("Failure reasons:");
for reason in &result.failure_reasons {
println!(" - {}", reason);
}
}
}
if result.passed {
Ok(())
} else {
std::process::exit(crate::gate::exit_codes::POLICY_VIOLATION);
}
}
fn execute_init_command(spec_id: &str, create_lock: bool, config: &Config) -> Result<()> {
use crate::lock::XCheckerLock;
println!("{}", styled_info(&format!("Initializing spec: {spec_id}")));
let spec_dir = PathBuf::from(".xchecker").join("specs").join(spec_id);
let artifacts_dir = spec_dir.join("artifacts");
let receipts_dir = spec_dir.join("receipts");
let context_dir = spec_dir.join("context");
if spec_dir.exists() {
println!(" Spec directory already exists: {}", spec_dir.display());
let lock_path = spec_dir.join("lock.json");
if lock_path.exists() {
println!(" Lockfile already exists: {}", lock_path.display());
if create_lock {
println!(
" {} Warning: --create-lock specified but lockfile already exists",
styled_warning()
);
println!(" To update the lockfile, delete it first and run init again");
}
return Ok(());
}
} else {
crate::paths::ensure_dir_all(&artifacts_dir).with_context(|| {
format!(
"Failed to create artifacts directory: {}",
artifacts_dir.display()
)
})?;
crate::paths::ensure_dir_all(&receipts_dir).with_context(|| {
format!(
"Failed to create receipts directory: {}",
receipts_dir.display()
)
})?;
crate::paths::ensure_dir_all(&context_dir).with_context(|| {
format!(
"Failed to create context directory: {}",
context_dir.display()
)
})?;
println!(
" {} Created spec directory: {}",
styled_check(),
spec_dir.display()
);
println!(" {} Created artifacts directory", styled_check());
println!(" {} Created receipts directory", styled_check());
println!(" {} Created context directory", styled_check());
}
if create_lock {
let model = config.defaults.model.as_deref().unwrap_or("haiku");
let claude_cli_version =
detect_claude_cli_version().unwrap_or_else(|_| "unknown".to_string());
let lock = XCheckerLock::new(model.to_string(), claude_cli_version.clone());
lock.save(spec_id)
.with_context(|| "Failed to save lockfile")?;
println!(" {} Created lockfile: lock.json", styled_check());
println!(" Model: {model}");
println!(" Claude CLI version: {claude_cli_version}");
println!(" Schema version: 1");
println!("\n Lockfile will track drift for:");
println!(" - Model changes (current: {model})");
println!(" - Claude CLI version changes (current: {claude_cli_version})");
println!(" - Schema version changes (current: 1)");
println!("\n Use --strict-lock flag to hard fail on drift detection");
} else {
println!("\n No lockfile created (use --create-lock to pin model and CLI version)");
}
println!(
"\n{}",
styled_success(&format!("Spec '{spec_id}' initialized successfully"))
);
println!(" Directory: {}", spec_dir.display());
println!("\nNext steps:");
println!(" 1. Create your problem statement:");
println!(" echo \"Your problem description\" | xchecker spec {spec_id}");
println!(" 2. Or resume from existing source:");
println!(" xchecker resume {spec_id} --phase requirements");
Ok(())
}
fn detect_claude_cli_version() -> Result<String> {
use crate::runner::CommandSpec;
let output = CommandSpec::new("claude")
.arg("--version")
.to_command()
.output()
.context("Failed to execute 'claude --version'")?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"claude --version exited with non-zero status"
));
}
let version_str = String::from_utf8(output.stdout)
.context("Failed to parse claude --version output as UTF-8")?;
let version = version_str
.split_whitespace()
.last()
.ok_or_else(|| anyhow::anyhow!("Failed to parse version from output"))?
.to_string();
Ok(version)
}
fn check_lockfile_drift(
spec_id: &str,
strict_lock: bool,
model_full_name: &str,
claude_cli_version: &str,
) -> Result<Option<crate::types::LockDrift>> {
use crate::lock::{RunContext, XCheckerLock};
let lock = match XCheckerLock::load(spec_id) {
Ok(Some(lock)) => lock,
Ok(None) => return Ok(None), Err(e) => {
eprintln!("⚠ Warning: Failed to load lockfile: {e}");
return Ok(None);
}
};
let context = RunContext {
model_full_name: model_full_name.to_string(),
claude_cli_version: claude_cli_version.to_string(),
schema_version: "1".to_string(),
};
if let Some(drift) = lock.detect_drift(&context) {
eprintln!("\n⚠ Lockfile drift detected for spec '{spec_id}':");
if let Some(ref model_drift) = drift.model_full_name {
eprintln!(" Model: {} → {}", model_drift.locked, model_drift.current);
}
if let Some(ref cli_drift) = drift.claude_cli_version {
eprintln!(" Claude CLI: {} → {}", cli_drift.locked, cli_drift.current);
}
if let Some(ref schema_drift) = drift.schema_version {
eprintln!(
" Schema: {} → {}",
schema_drift.locked, schema_drift.current
);
}
if strict_lock {
eprintln!("\n✗ Strict lock mode enabled: failing due to drift");
eprintln!(" To proceed, either:");
eprintln!(
" - Update the lockfile: rm .xchecker/specs/{spec_id}/lock.json && xchecker init {spec_id} --create-lock"
);
eprintln!(" - Remove --strict-lock flag to allow drift with warning");
return Err(anyhow::anyhow!("Lockfile drift detected in strict mode"));
}
eprintln!("\n Continuing with drift (use --strict-lock to fail on drift)");
Ok(Some(drift))
} else {
Ok(None)
}
}
struct SpinnerGuard {
running: Arc<AtomicBool>,
handle: Option<thread::JoinHandle<()>>,
}
impl SpinnerGuard {
fn new() -> Self {
let running = Arc::new(AtomicBool::new(true));
let running_clone = running.clone();
let _ = crossterm::execute!(std::io::stdout(), crossterm::cursor::Hide);
let handle = thread::spawn(move || {
let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let mut i = 0;
while running_clone.load(Ordering::Relaxed) {
print!("\r{} Running health checks...", frames[i]);
let _ = std::io::stdout().flush();
i = (i + 1) % frames.len();
thread::sleep(Duration::from_millis(80));
}
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::terminal::Clear(crossterm::terminal::ClearType::CurrentLine)
);
print!("\r");
let _ = std::io::stdout().flush();
});
Self {
running,
handle: Some(handle),
}
}
}
impl Drop for SpinnerGuard {
fn drop(&mut self) {
self.running.store(false, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
let _ = crossterm::execute!(std::io::stdout(), crossterm::cursor::Show);
}
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)] #[allow(clippy::await_holding_lock)] mod tests {
use super::*;
use serial_test::serial;
use std::env;
use std::sync::{Mutex, MutexGuard, OnceLock};
use tempfile::TempDir;
use xchecker_utils::test_support::EnvVarGuard;
static CLI_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn cli_env_guard() -> MutexGuard<'static, ()> {
CLI_ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
struct TestEnvGuard {
_lock: MutexGuard<'static, ()>,
_temp_dir: TempDir,
original_dir: PathBuf,
_env_guard: EnvVarGuard,
}
impl Drop for TestEnvGuard {
fn drop(&mut self) {
let _ = env::set_current_dir(&self.original_dir);
}
}
fn setup_test_environment() -> TestEnvGuard {
let lock = cli_env_guard();
let temp_dir = TempDir::new().unwrap();
let original_dir = env::current_dir().unwrap();
let env_guard = EnvVarGuard::cleared("XCHECKER_HOME");
env::set_current_dir(temp_dir.path()).unwrap();
TestEnvGuard {
_lock: lock,
_temp_dir: temp_dir,
original_dir,
_env_guard: env_guard,
}
}
#[test]
fn test_create_default_config() {
let cli_args = CliArgs::default();
let config = Config::discover(&cli_args).unwrap();
let config_map = create_default_config(true, &config, &cli_args);
assert_eq!(config_map.get("verbose"), Some(&"true".to_string()));
assert_eq!(
config_map.get("packet_max_bytes"),
Some(&"65536".to_string())
);
assert_eq!(
config_map.get("packet_max_lines"),
Some(&"1200".to_string())
);
}
#[test]
fn test_create_default_config_no_verbose() {
let cli_args = CliArgs::default();
let config = Config::discover(&cli_args).unwrap();
let config_map = create_default_config(false, &config, &cli_args);
assert!(!config_map.contains_key("verbose"));
assert_eq!(
config_map.get("packet_max_bytes"),
Some(&"65536".to_string())
);
assert_eq!(
config_map.get("packet_max_lines"),
Some(&"1200".to_string())
);
}
#[tokio::test]
#[serial]
async fn test_spec_command_execution() -> anyhow::Result<()> {
use tempfile::TempDir;
let _lock = cli_env_guard();
let temp = TempDir::new()?;
let root = temp.path();
std::fs::create_dir_all(root.join(".git"))?;
let _home_guard = EnvVarGuard::set("XCHECKER_HOME", root.to_str().unwrap());
let _skip_guard = EnvVarGuard::set("XCHECKER_SKIP_LLM_TESTS", "1");
let original_dir = std::env::current_dir()?;
std::env::set_current_dir(root)?;
std::fs::write(
root.join("xchecker.toml"),
r#"
[runner]
runner_mode = "native"
[packet]
packet_max_bytes = 1048576
packet_max_lines = 5000
"#,
)?;
let cli_args = CliArgs::default();
let config = Config::discover(&cli_args)?;
let redactor = Arc::new(SecretRedactor::from_config(&config)?);
std::fs::write(root.join("input.txt"), "Test requirement")?;
let result = execute_spec_command(
"test-spec",
"fs",
Some("input.txt"),
Some(root.to_str().unwrap()), true, false,
false,
false,
false,
&config,
&cli_args,
&redactor,
)
.await;
let _ = std::env::set_current_dir(&original_dir);
assert!(
result.is_ok(),
"Dry-run spec execution should succeed: {:?}",
result.err()
);
Ok(())
}
#[test]
fn test_status_command_no_spec() {
let _temp_dir = setup_test_environment();
let cli_args = CliArgs::default();
let config = Config::discover(&cli_args).unwrap();
let result = execute_status_command("nonexistent-spec", false, &config);
assert!(result.is_ok());
}
#[test]
fn test_status_command_with_spec() {
let _temp_dir = setup_test_environment();
let cli_args = CliArgs::default();
let config = Config::discover(&cli_args).unwrap();
let result = execute_status_command("test-status-spec", false, &config);
assert!(result.is_ok());
}
#[test]
fn test_spec_json_output_schema_version() {
use crate::types::{PhaseInfo, SpecConfigSummary, SpecOutput};
let output = SpecOutput {
schema_version: "spec-json.v1".to_string(),
spec_id: "test-spec".to_string(),
phases: vec![PhaseInfo {
phase_id: "requirements".to_string(),
status: "completed".to_string(),
last_run: Some(chrono::Utc::now()),
}],
config_summary: SpecConfigSummary {
execution_strategy: "controlled".to_string(),
provider: Some("claude-cli".to_string()),
spec_path: ".xchecker/specs/test-spec".to_string(),
},
};
let json_result = emit_spec_json(&output);
assert!(json_result.is_ok(), "Failed to emit spec JSON");
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["schema_version"], "spec-json.v1");
assert_eq!(parsed["spec_id"], "test-spec");
}
#[test]
fn test_spec_json_output_excludes_packet_contents() {
use crate::types::{PhaseInfo, SpecConfigSummary, SpecOutput};
let output = SpecOutput {
schema_version: "spec-json.v1".to_string(),
spec_id: "test-spec".to_string(),
phases: vec![
PhaseInfo {
phase_id: "requirements".to_string(),
status: "completed".to_string(),
last_run: None,
},
PhaseInfo {
phase_id: "design".to_string(),
status: "not_started".to_string(),
last_run: None,
},
],
config_summary: SpecConfigSummary {
execution_strategy: "controlled".to_string(),
provider: None,
spec_path: ".xchecker/specs/test-spec".to_string(),
},
};
let json_result = emit_spec_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert!(
parsed.get("packet").is_none(),
"JSON should not contain packet field"
);
assert!(
parsed.get("artifacts").is_none(),
"JSON should not contain artifacts field"
);
assert!(
parsed.get("raw_response").is_none(),
"JSON should not contain raw_response field"
);
assert!(parsed.get("schema_version").is_some());
assert!(parsed.get("spec_id").is_some());
assert!(parsed.get("phases").is_some());
assert!(parsed.get("config_summary").is_some());
}
#[test]
fn test_spec_json_command_no_spec() {
let _temp_dir = setup_test_environment();
let cli_args = CliArgs::default();
let config = Config::discover(&cli_args).unwrap();
let result = execute_spec_json_command("nonexistent-spec-json", &config);
assert!(result.is_ok());
}
#[test]
fn test_spec_json_canonical_format() {
use crate::types::{PhaseInfo, SpecConfigSummary, SpecOutput};
let output = SpecOutput {
schema_version: "spec-json.v1".to_string(),
spec_id: "test-spec".to_string(),
phases: vec![PhaseInfo {
phase_id: "requirements".to_string(),
status: "completed".to_string(),
last_run: None,
}],
config_summary: SpecConfigSummary {
execution_strategy: "controlled".to_string(),
provider: None,
spec_path: ".xchecker/specs/test-spec".to_string(),
},
};
let json_result = emit_spec_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
assert!(
!json_str.contains(" "),
"Canonical JSON should not have indentation"
);
assert!(
!json_str.contains('\n'),
"Canonical JSON should not have newlines"
);
}
#[test]
fn test_spec_json_all_phases_present() {
use crate::types::{PhaseInfo, SpecConfigSummary, SpecOutput};
let phases = vec![
PhaseInfo {
phase_id: "requirements".to_string(),
status: "completed".to_string(),
last_run: None,
},
PhaseInfo {
phase_id: "design".to_string(),
status: "pending".to_string(),
last_run: None,
},
PhaseInfo {
phase_id: "tasks".to_string(),
status: "not_started".to_string(),
last_run: None,
},
PhaseInfo {
phase_id: "review".to_string(),
status: "not_started".to_string(),
last_run: None,
},
PhaseInfo {
phase_id: "fixup".to_string(),
status: "not_started".to_string(),
last_run: None,
},
PhaseInfo {
phase_id: "final".to_string(),
status: "not_started".to_string(),
last_run: None,
},
];
let output = SpecOutput {
schema_version: "spec-json.v1".to_string(),
spec_id: "test-spec".to_string(),
phases,
config_summary: SpecConfigSummary {
execution_strategy: "controlled".to_string(),
provider: Some("openrouter".to_string()),
spec_path: ".xchecker/specs/test-spec".to_string(),
},
};
let json_result = emit_spec_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let phases_array = parsed["phases"].as_array().unwrap();
assert_eq!(phases_array.len(), 6);
let phase_ids: Vec<&str> = phases_array
.iter()
.map(|p| p["phase_id"].as_str().unwrap())
.collect();
assert!(phase_ids.contains(&"requirements"));
assert!(phase_ids.contains(&"design"));
assert!(phase_ids.contains(&"tasks"));
assert!(phase_ids.contains(&"review"));
assert!(phase_ids.contains(&"fixup"));
assert!(phase_ids.contains(&"final"));
}
#[test]
fn test_benchmark_command_basic() {
let result = execute_benchmark_command(
5, 100, 2, false, Some(10.0), Some(2000.0), None, None, false, );
assert!(result.is_ok());
}
#[test]
fn test_benchmark_command_with_threshold_overrides() {
let result = execute_benchmark_command(
5, 100, 2, false, Some(100.0), Some(10000.0), Some(1000.0), Some(2000.0), false, );
assert!(result.is_ok());
}
#[test]
fn test_benchmark_command_json_output() {
let result = execute_benchmark_command(
5, 100, 2, true, Some(100.0), Some(10000.0), None, None, false, );
assert!(result.is_ok());
}
#[test]
fn test_benchmark_thresholds_applied() {
use crate::benchmark::{BenchmarkConfig, BenchmarkThresholds};
let thresholds = BenchmarkThresholds {
empty_run_max_secs: 3.0,
packetization_max_ms_per_100_files: 150.0,
max_rss_mb: Some(500.0),
max_commit_mb: Some(1000.0),
};
let config = BenchmarkConfig {
file_count: 10,
file_size_bytes: 100,
iterations: 2,
verbose: false,
thresholds,
};
assert_eq!(config.thresholds.empty_run_max_secs, 3.0);
assert_eq!(config.thresholds.packetization_max_ms_per_100_files, 150.0);
assert_eq!(config.thresholds.max_rss_mb, Some(500.0));
assert_eq!(config.thresholds.max_commit_mb, Some(1000.0));
}
#[test]
fn test_benchmark_cli_parsing() {
use clap::Parser;
let args = vec![
"xchecker",
"benchmark",
"--file-count",
"50",
"--iterations",
"3",
];
let cli = Cli::try_parse_from(args);
assert!(cli.is_ok());
if let Ok(cli) = cli {
match cli.command {
Commands::Benchmark {
file_count,
iterations,
..
} => {
assert_eq!(file_count, 50);
assert_eq!(iterations, 3);
}
_ => panic!("Expected Benchmark command"),
}
}
let args_with_thresholds = vec![
"xchecker",
"benchmark",
"--max-empty-run-secs",
"3.5",
"--max-packetization-ms",
"180.0",
"--json",
];
let cli_thresholds = Cli::try_parse_from(args_with_thresholds);
assert!(cli_thresholds.is_ok());
if let Ok(cli) = cli_thresholds {
match cli.command {
Commands::Benchmark {
max_empty_run_secs,
max_packetization_ms,
json,
..
} => {
assert_eq!(max_empty_run_secs, Some(3.5));
assert_eq!(max_packetization_ms, Some(180.0));
assert!(json);
}
_ => panic!("Expected Benchmark command"),
}
}
}
#[test]
fn test_benchmark_default_values() {
use clap::Parser;
let args = vec!["xchecker", "benchmark"];
let cli = Cli::try_parse_from(args);
assert!(cli.is_ok());
if let Ok(cli) = cli {
match cli.command {
Commands::Benchmark {
file_count,
file_size,
iterations,
json,
max_empty_run_secs,
max_packetization_ms,
..
} => {
assert_eq!(file_count, 100); assert_eq!(file_size, 1024); assert_eq!(iterations, 5); assert!(!json); assert_eq!(max_empty_run_secs, None); assert_eq!(max_packetization_ms, None); }
_ => panic!("Expected Benchmark command"),
}
}
}
#[test]
fn test_status_json_output_schema_version() {
use crate::types::{PhaseStatusInfo, StatusJsonOutput};
let output = StatusJsonOutput {
schema_version: "status-json.v2".to_string(),
spec_id: "test-spec".to_string(),
phase_statuses: vec![PhaseStatusInfo {
phase_id: "requirements".to_string(),
status: "success".to_string(),
receipt_id: Some("requirements-20241201_100000".to_string()),
}],
pending_fixups: 0,
has_errors: false,
strict_validation: false,
artifacts: Vec::new(),
effective_config: std::collections::BTreeMap::new(),
lock_drift: None,
};
let json_result = emit_status_json(&output);
assert!(json_result.is_ok(), "Failed to emit status JSON");
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["schema_version"], "status-json.v2");
assert_eq!(parsed["spec_id"], "test-spec");
}
#[test]
fn test_status_json_output_has_required_fields() {
use crate::types::{PhaseStatusInfo, StatusJsonOutput};
let output = StatusJsonOutput {
schema_version: "status-json.v2".to_string(),
spec_id: "test-spec".to_string(),
phase_statuses: vec![
PhaseStatusInfo {
phase_id: "requirements".to_string(),
status: "success".to_string(),
receipt_id: Some("requirements-20241201_100000".to_string()),
},
PhaseStatusInfo {
phase_id: "design".to_string(),
status: "failed".to_string(),
receipt_id: Some("design-20241201_110000".to_string()),
},
PhaseStatusInfo {
phase_id: "tasks".to_string(),
status: "not_started".to_string(),
receipt_id: None,
},
],
pending_fixups: 3,
has_errors: true,
strict_validation: false,
artifacts: Vec::new(),
effective_config: std::collections::BTreeMap::new(),
lock_drift: None,
};
let json_result = emit_status_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert!(parsed.get("schema_version").is_some());
assert!(parsed.get("spec_id").is_some());
assert!(parsed.get("phase_statuses").is_some());
assert!(parsed.get("pending_fixups").is_some());
assert!(parsed.get("has_errors").is_some());
assert!(
parsed.get("strict_validation").is_some(),
"strict_validation field should be present"
);
assert_eq!(parsed["pending_fixups"], 3);
assert_eq!(parsed["has_errors"], true);
assert_eq!(parsed["strict_validation"], false);
}
#[test]
fn test_status_json_canonical_format() {
use crate::types::{PhaseStatusInfo, StatusJsonOutput};
let output = StatusJsonOutput {
schema_version: "status-json.v2".to_string(),
spec_id: "test-spec".to_string(),
phase_statuses: vec![PhaseStatusInfo {
phase_id: "requirements".to_string(),
status: "success".to_string(),
receipt_id: None,
}],
pending_fixups: 0,
has_errors: false,
strict_validation: false,
artifacts: Vec::new(),
effective_config: std::collections::BTreeMap::new(),
lock_drift: None,
};
let json_result = emit_status_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
assert!(
!json_str.contains(" "),
"Canonical JSON should not have indentation"
);
assert!(
!json_str.contains('\n'),
"Canonical JSON should not have newlines"
);
}
#[test]
fn test_status_json_excludes_raw_packet_contents() {
use crate::types::{
ArtifactInfo, ConfigSource, ConfigValue, PhaseStatusInfo, StatusJsonOutput,
};
let mut effective_config = std::collections::BTreeMap::new();
effective_config.insert(
"model".to_string(),
ConfigValue {
value: serde_json::Value::String("haiku".to_string()),
source: ConfigSource::Config,
},
);
let output = StatusJsonOutput {
schema_version: "status-json.v2".to_string(),
spec_id: "test-spec".to_string(),
phase_statuses: vec![PhaseStatusInfo {
phase_id: "requirements".to_string(),
status: "success".to_string(),
receipt_id: Some("requirements-20241201_100000".to_string()),
}],
pending_fixups: 0,
has_errors: false,
strict_validation: false,
artifacts: vec![ArtifactInfo {
path: "artifacts/requirements.yaml".to_string(),
blake3_first8: "abc12345".to_string(),
}],
effective_config,
lock_drift: None,
};
let json_result = emit_status_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert!(
parsed.get("packet").is_none(),
"JSON should not contain packet field"
);
assert!(
parsed.get("raw_response").is_none(),
"JSON should not contain raw_response field"
);
assert!(
parsed.get("artifacts").is_some(),
"JSON should contain artifacts field in v2"
);
assert!(
parsed.get("effective_config").is_some(),
"JSON should contain effective_config field in v2"
);
let artifacts = parsed["artifacts"].as_array().unwrap();
assert_eq!(artifacts.len(), 1);
assert_eq!(artifacts[0]["blake3_first8"], "abc12345");
assert!(
artifacts[0].get("content").is_none(),
"Artifacts should not include full content"
);
}
#[test]
fn test_status_json_command_no_spec() {
let _temp_dir = setup_test_environment();
let cli_args = CliArgs::default();
let config = Config::discover(&cli_args).unwrap();
let result = execute_status_command("nonexistent-spec-json", true, &config);
assert!(result.is_ok());
}
#[test]
fn test_status_json_all_phases_present() {
use crate::types::{PhaseStatusInfo, StatusJsonOutput};
let phase_statuses = vec![
PhaseStatusInfo {
phase_id: "requirements".to_string(),
status: "success".to_string(),
receipt_id: Some("requirements-20241201_100000".to_string()),
},
PhaseStatusInfo {
phase_id: "design".to_string(),
status: "success".to_string(),
receipt_id: Some("design-20241201_110000".to_string()),
},
PhaseStatusInfo {
phase_id: "tasks".to_string(),
status: "failed".to_string(),
receipt_id: Some("tasks-20241201_120000".to_string()),
},
PhaseStatusInfo {
phase_id: "review".to_string(),
status: "not_started".to_string(),
receipt_id: None,
},
PhaseStatusInfo {
phase_id: "fixup".to_string(),
status: "not_started".to_string(),
receipt_id: None,
},
PhaseStatusInfo {
phase_id: "final".to_string(),
status: "not_started".to_string(),
receipt_id: None,
},
];
let output = StatusJsonOutput {
schema_version: "status-json.v2".to_string(),
spec_id: "test-spec".to_string(),
phase_statuses,
pending_fixups: 0,
has_errors: true, strict_validation: false,
artifacts: Vec::new(),
effective_config: std::collections::BTreeMap::new(),
lock_drift: None,
};
let json_result = emit_status_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let phases_array = parsed["phase_statuses"].as_array().unwrap();
assert_eq!(phases_array.len(), 6);
let phase_ids: Vec<&str> = phases_array
.iter()
.map(|p| p["phase_id"].as_str().unwrap())
.collect();
assert!(phase_ids.contains(&"requirements"));
assert!(phase_ids.contains(&"design"));
assert!(phase_ids.contains(&"tasks"));
assert!(phase_ids.contains(&"review"));
assert!(phase_ids.contains(&"fixup"));
assert!(phase_ids.contains(&"final"));
}
#[test]
fn test_resume_json_output_schema_version() {
use crate::types::{CurrentInputs, ResumeJsonOutput};
let output = ResumeJsonOutput {
schema_version: "resume-json.v1".to_string(),
spec_id: "test-spec".to_string(),
phase: "design".to_string(),
current_inputs: CurrentInputs {
available_artifacts: vec!["00-requirements.md".to_string()],
spec_exists: true,
latest_completed_phase: Some("requirements".to_string()),
},
next_steps: "Run design phase to generate architecture and design from requirements."
.to_string(),
};
let json_result = emit_resume_json(&output);
assert!(json_result.is_ok(), "Failed to emit resume JSON");
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["schema_version"], "resume-json.v1");
assert_eq!(parsed["spec_id"], "test-spec");
assert_eq!(parsed["phase"], "design");
}
#[test]
fn test_resume_json_output_has_required_fields() {
use crate::types::{CurrentInputs, ResumeJsonOutput};
let output = ResumeJsonOutput {
schema_version: "resume-json.v1".to_string(),
spec_id: "test-spec".to_string(),
phase: "tasks".to_string(),
current_inputs: CurrentInputs {
available_artifacts: vec![
"00-requirements.md".to_string(),
"10-design.md".to_string(),
],
spec_exists: true,
latest_completed_phase: Some("design".to_string()),
},
next_steps: "Run tasks phase to generate implementation tasks from design.".to_string(),
};
let json_result = emit_resume_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert!(parsed.get("schema_version").is_some());
assert!(parsed.get("spec_id").is_some());
assert!(parsed.get("phase").is_some());
assert!(parsed.get("current_inputs").is_some());
assert!(parsed.get("next_steps").is_some());
let current_inputs = &parsed["current_inputs"];
assert!(current_inputs.get("available_artifacts").is_some());
assert!(current_inputs.get("spec_exists").is_some());
assert!(current_inputs.get("latest_completed_phase").is_some());
}
#[test]
fn test_resume_json_canonical_format() {
use crate::types::{CurrentInputs, ResumeJsonOutput};
let output = ResumeJsonOutput {
schema_version: "resume-json.v1".to_string(),
spec_id: "test-spec".to_string(),
phase: "requirements".to_string(),
current_inputs: CurrentInputs {
available_artifacts: vec![],
spec_exists: true,
latest_completed_phase: None,
},
next_steps: "Run requirements phase.".to_string(),
};
let json_result = emit_resume_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
assert!(
!json_str.contains(" "),
"Canonical JSON should not have indentation"
);
assert!(
!json_str.contains('\n'),
"Canonical JSON should not have newlines"
);
}
#[test]
fn test_resume_json_excludes_raw_artifacts() {
use crate::types::{CurrentInputs, ResumeJsonOutput};
let output = ResumeJsonOutput {
schema_version: "resume-json.v1".to_string(),
spec_id: "test-spec".to_string(),
phase: "design".to_string(),
current_inputs: CurrentInputs {
available_artifacts: vec!["00-requirements.md".to_string()],
spec_exists: true,
latest_completed_phase: Some("requirements".to_string()),
},
next_steps: "Run design phase.".to_string(),
};
let json_result = emit_resume_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert!(
parsed.get("packet").is_none(),
"JSON should not contain packet field"
);
assert!(
parsed.get("artifacts").is_none(),
"JSON should not contain artifacts field"
);
assert!(
parsed.get("raw_response").is_none(),
"JSON should not contain raw_response field"
);
assert!(
parsed.get("artifact_contents").is_none(),
"JSON should not contain artifact_contents field"
);
let artifacts = parsed["current_inputs"]["available_artifacts"]
.as_array()
.unwrap();
for artifact in artifacts {
assert!(
artifact.is_string(),
"Artifacts should be names only, not objects with contents"
);
}
}
#[test]
fn test_resume_json_command_no_spec() {
let _temp_dir = setup_test_environment();
let cli_args = CliArgs::default();
let config = Config::discover(&cli_args).unwrap();
let result = execute_resume_json_command("nonexistent-spec-json", "design", &config);
assert!(result.is_ok());
}
#[test]
fn test_resume_json_all_phases_valid() {
use crate::types::{CurrentInputs, ResumeJsonOutput};
let phases = [
"requirements",
"design",
"tasks",
"review",
"fixup",
"final",
];
for phase in &phases {
let output = ResumeJsonOutput {
schema_version: "resume-json.v1".to_string(),
spec_id: "test-spec".to_string(),
phase: phase.to_string(),
current_inputs: CurrentInputs {
available_artifacts: vec![],
spec_exists: true,
latest_completed_phase: None,
},
next_steps: format!("Run {} phase.", phase),
};
let json_result = emit_resume_json(&output);
assert!(
json_result.is_ok(),
"Failed to emit resume JSON for phase: {}",
phase
);
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["phase"], *phase);
}
}
#[test]
fn test_resume_json_spec_not_exists() {
use crate::types::{CurrentInputs, ResumeJsonOutput};
let output = ResumeJsonOutput {
schema_version: "resume-json.v1".to_string(),
spec_id: "nonexistent-spec".to_string(),
phase: "requirements".to_string(),
current_inputs: CurrentInputs {
available_artifacts: vec![],
spec_exists: false,
latest_completed_phase: None,
},
next_steps: "Spec 'nonexistent-spec' does not exist. Run 'xchecker spec nonexistent-spec' to create it first.".to_string(),
};
let json_result = emit_resume_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["current_inputs"]["spec_exists"], false);
let artifacts = parsed["current_inputs"].get("available_artifacts");
match artifacts {
Some(arr) => {
let arr = arr.as_array().unwrap();
assert!(arr.is_empty());
}
None => {
}
}
}
#[test]
fn test_derive_spec_status_not_started() {
let _temp_dir = crate::paths::with_isolated_home();
let status = derive_spec_status("nonexistent-spec-status-test");
assert_eq!(status, "not_started");
}
#[test]
fn test_derive_spec_status_with_receipt() {
let _temp_dir = crate::paths::with_isolated_home();
use crate::receipt::ReceiptManager;
use crate::types::{PacketEvidence, PhaseId};
use std::collections::HashMap;
let spec_id = "test-spec-with-receipt";
let base_path = crate::paths::spec_root(spec_id);
crate::paths::ensure_dir_all(&base_path).unwrap();
let receipt_manager = ReceiptManager::new(&base_path);
let packet = PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
};
let receipt = receipt_manager.create_receipt(
spec_id,
PhaseId::Requirements,
0, vec![],
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
packet,
None,
None,
vec![],
None,
"native",
None,
None,
None,
None,
None,
);
receipt_manager.write_receipt(&receipt).unwrap();
let status = derive_spec_status(spec_id);
assert!(
status.contains("success"),
"Expected 'success' in status, got: {}",
status
);
assert!(
status.contains("requirements"),
"Expected 'requirements' in status, got: {}",
status
);
}
#[test]
fn test_derive_spec_status_with_failed_receipt() {
let _temp_dir = crate::paths::with_isolated_home();
use crate::receipt::ReceiptManager;
use crate::types::{PacketEvidence, PhaseId};
use std::collections::HashMap;
let spec_id = "test-spec-with-failed-receipt";
let base_path = crate::paths::spec_root(spec_id);
crate::paths::ensure_dir_all(&base_path).unwrap();
let receipt_manager = ReceiptManager::new(&base_path);
let packet = PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
};
let receipt = receipt_manager.create_receipt(
spec_id,
PhaseId::Design,
1, vec![],
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
packet,
None,
None,
vec![],
None,
"native",
None,
None,
None,
None,
None,
);
receipt_manager.write_receipt(&receipt).unwrap();
let status = derive_spec_status(spec_id);
assert!(
status.contains("failed"),
"Expected 'failed' in status, got: {}",
status
);
assert!(
status.contains("design"),
"Expected 'design' in status, got: {}",
status
);
}
#[test]
fn test_derive_spec_status_uses_latest_receipt() {
let _temp_dir = crate::paths::with_isolated_home();
use crate::receipt::ReceiptManager;
use crate::types::{PacketEvidence, PhaseId};
use std::collections::HashMap;
let spec_id = "test-spec-multiple-receipts";
let base_path = crate::paths::spec_root(spec_id);
crate::paths::ensure_dir_all(&base_path).unwrap();
let receipt_manager = ReceiptManager::new(&base_path);
let packet = PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
};
let receipt1 = receipt_manager.create_receipt(
spec_id,
PhaseId::Requirements,
0,
vec![],
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
packet.clone(),
None,
None,
vec![],
None,
"native",
None,
None,
None,
None,
None,
);
receipt_manager.write_receipt(&receipt1).unwrap();
std::thread::sleep(std::time::Duration::from_millis(1100));
let receipt2 = receipt_manager.create_receipt(
spec_id,
PhaseId::Design,
0,
vec![],
"0.1.0",
"0.8.1",
"haiku",
None,
HashMap::new(),
packet,
None,
None,
vec![],
None,
"native",
None,
None,
None,
None,
None,
);
receipt_manager.write_receipt(&receipt2).unwrap();
let status = derive_spec_status(spec_id);
assert!(
status.contains("design"),
"Expected 'design' (latest) in status, got: {}",
status
);
assert!(
status.contains("success"),
"Expected 'success' in status, got: {}",
status
);
}
#[test]
fn test_workspace_status_json_output_schema_version() {
use crate::types::{
WorkspaceSpecStatus, WorkspaceStatusJsonOutput, WorkspaceStatusSummary,
};
let output = WorkspaceStatusJsonOutput {
schema_version: "workspace-status-json.v1".to_string(),
workspace_name: "test-workspace".to_string(),
workspace_path: "/path/to/workspace.yaml".to_string(),
specs: vec![WorkspaceSpecStatus {
spec_id: "spec-1".to_string(),
tags: vec!["backend".to_string()],
status: "success".to_string(),
latest_phase: Some("tasks".to_string()),
last_activity: Some(chrono::Utc::now()),
pending_fixups: 0,
has_errors: false,
}],
summary: WorkspaceStatusSummary {
total_specs: 1,
successful_specs: 1,
failed_specs: 0,
pending_specs: 0,
not_started_specs: 0,
stale_specs: 0,
},
};
let json_result = emit_workspace_status_json(&output);
assert!(json_result.is_ok(), "Failed to emit workspace status JSON");
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["schema_version"], "workspace-status-json.v1");
assert_eq!(parsed["workspace_name"], "test-workspace");
}
#[test]
fn test_workspace_status_json_output_has_required_fields() {
use crate::types::{
WorkspaceSpecStatus, WorkspaceStatusJsonOutput, WorkspaceStatusSummary,
};
let output = WorkspaceStatusJsonOutput {
schema_version: "workspace-status-json.v1".to_string(),
workspace_name: "test-workspace".to_string(),
workspace_path: "/path/to/workspace.yaml".to_string(),
specs: vec![
WorkspaceSpecStatus {
spec_id: "spec-1".to_string(),
tags: vec![],
status: "success".to_string(),
latest_phase: Some("design".to_string()),
last_activity: None,
pending_fixups: 0,
has_errors: false,
},
WorkspaceSpecStatus {
spec_id: "spec-2".to_string(),
tags: vec!["frontend".to_string()],
status: "failed".to_string(),
latest_phase: Some("requirements".to_string()),
last_activity: None,
pending_fixups: 2,
has_errors: true,
},
],
summary: WorkspaceStatusSummary {
total_specs: 2,
successful_specs: 1,
failed_specs: 1,
pending_specs: 0,
not_started_specs: 0,
stale_specs: 0,
},
};
let json_result = emit_workspace_status_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert!(parsed.get("schema_version").is_some());
assert!(parsed.get("workspace_name").is_some());
assert!(parsed.get("workspace_path").is_some());
assert!(parsed.get("specs").is_some());
assert!(parsed.get("summary").is_some());
let summary = &parsed["summary"];
assert!(summary.get("total_specs").is_some());
assert!(summary.get("successful_specs").is_some());
assert!(summary.get("failed_specs").is_some());
assert!(summary.get("pending_specs").is_some());
assert!(summary.get("not_started_specs").is_some());
assert!(summary.get("stale_specs").is_some());
assert_eq!(summary["total_specs"], 2);
assert_eq!(summary["failed_specs"], 1);
}
#[test]
fn test_workspace_status_json_canonical_format() {
use crate::types::{WorkspaceStatusJsonOutput, WorkspaceStatusSummary};
let output = WorkspaceStatusJsonOutput {
schema_version: "workspace-status-json.v1".to_string(),
workspace_name: "test-workspace".to_string(),
workspace_path: "/path/to/workspace.yaml".to_string(),
specs: vec![],
summary: WorkspaceStatusSummary {
total_specs: 0,
successful_specs: 0,
failed_specs: 0,
pending_specs: 0,
not_started_specs: 0,
stale_specs: 0,
},
};
let json_result = emit_workspace_status_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
assert!(
!json_str.contains(" "),
"Canonical JSON should not have indentation"
);
assert!(
!json_str.contains('\n'),
"Canonical JSON should not have newlines"
);
}
#[test]
fn test_workspace_status_json_spec_statuses() {
use crate::types::{
WorkspaceSpecStatus, WorkspaceStatusJsonOutput, WorkspaceStatusSummary,
};
let output = WorkspaceStatusJsonOutput {
schema_version: "workspace-status-json.v1".to_string(),
workspace_name: "test-workspace".to_string(),
workspace_path: "/path/to/workspace.yaml".to_string(),
specs: vec![
WorkspaceSpecStatus {
spec_id: "spec-success".to_string(),
tags: vec!["tag1".to_string(), "tag2".to_string()],
status: "success".to_string(),
latest_phase: Some("final".to_string()),
last_activity: Some(chrono::Utc::now()),
pending_fixups: 0,
has_errors: false,
},
WorkspaceSpecStatus {
spec_id: "spec-failed".to_string(),
tags: vec![],
status: "failed".to_string(),
latest_phase: Some("design".to_string()),
last_activity: None,
pending_fixups: 3,
has_errors: true,
},
WorkspaceSpecStatus {
spec_id: "spec-not-started".to_string(),
tags: vec![],
status: "not_started".to_string(),
latest_phase: None,
last_activity: None,
pending_fixups: 0,
has_errors: false,
},
],
summary: WorkspaceStatusSummary {
total_specs: 3,
successful_specs: 1,
failed_specs: 1,
pending_specs: 0,
not_started_specs: 1,
stale_specs: 0,
},
};
let json_result = emit_workspace_status_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let specs = parsed["specs"].as_array().unwrap();
assert_eq!(specs.len(), 3);
let spec_ids: Vec<&str> = specs
.iter()
.map(|s| s["spec_id"].as_str().unwrap())
.collect();
assert!(spec_ids.contains(&"spec-success"));
assert!(spec_ids.contains(&"spec-failed"));
assert!(spec_ids.contains(&"spec-not-started"));
let statuses: Vec<&str> = specs
.iter()
.map(|s| s["status"].as_str().unwrap())
.collect();
assert!(statuses.contains(&"success"));
assert!(statuses.contains(&"failed"));
assert!(statuses.contains(&"not_started"));
}
#[test]
fn test_workspace_status_cli_parsing() {
use clap::Parser;
let args = vec!["xchecker", "project", "status"];
let cli = Cli::try_parse_from(args);
assert!(cli.is_ok());
if let Ok(cli) = cli {
match cli.command {
Commands::Project(ProjectCommands::Status { workspace, json }) => {
assert!(workspace.is_none());
assert!(!json);
}
_ => panic!("Expected Project Status command"),
}
}
let args_json = vec!["xchecker", "project", "status", "--json"];
let cli_json = Cli::try_parse_from(args_json);
assert!(cli_json.is_ok());
if let Ok(cli) = cli_json {
match cli.command {
Commands::Project(ProjectCommands::Status { workspace, json }) => {
assert!(workspace.is_none());
assert!(json);
}
_ => panic!("Expected Project Status command"),
}
}
let args_workspace = vec![
"xchecker",
"project",
"status",
"--workspace",
"/path/to/workspace.yaml",
];
let cli_workspace = Cli::try_parse_from(args_workspace);
assert!(cli_workspace.is_ok());
if let Ok(cli) = cli_workspace {
match cli.command {
Commands::Project(ProjectCommands::Status { workspace, json }) => {
assert!(workspace.is_some());
assert_eq!(
workspace.unwrap().to_str().unwrap(),
"/path/to/workspace.yaml"
);
assert!(!json);
}
_ => panic!("Expected Project Status command"),
}
}
}
#[test]
fn test_workspace_history_cli_parsing() {
use clap::Parser;
let args = vec!["xchecker", "project", "history", "my-spec"];
let cli = Cli::try_parse_from(args);
assert!(cli.is_ok());
if let Ok(cli) = cli {
match cli.command {
Commands::Project(ProjectCommands::History { spec_id, json }) => {
assert_eq!(spec_id, "my-spec");
assert!(!json);
}
_ => panic!("Expected Project History command"),
}
}
let args_json = vec!["xchecker", "project", "history", "my-spec", "--json"];
let cli_json = Cli::try_parse_from(args_json);
assert!(cli_json.is_ok());
if let Ok(cli) = cli_json {
match cli.command {
Commands::Project(ProjectCommands::History { spec_id, json }) => {
assert_eq!(spec_id, "my-spec");
assert!(json);
}
_ => panic!("Expected Project History command"),
}
}
}
#[test]
fn test_history_json_output_schema_version() {
use crate::types::{HistoryEntry, HistoryMetrics, WorkspaceHistoryJsonOutput};
let output = WorkspaceHistoryJsonOutput {
schema_version: "workspace-history-json.v1".to_string(),
spec_id: "test-spec".to_string(),
timeline: vec![HistoryEntry {
phase: "requirements".to_string(),
timestamp: chrono::Utc::now(),
exit_code: 0,
success: true,
tokens_input: Some(1000),
tokens_output: Some(500),
fixup_count: None,
model: Some("haiku".to_string()),
provider: Some("claude-cli".to_string()),
}],
metrics: HistoryMetrics {
total_executions: 1,
successful_executions: 1,
failed_executions: 0,
total_tokens_input: 1000,
total_tokens_output: 500,
total_fixups: 0,
first_execution: Some(chrono::Utc::now()),
last_execution: Some(chrono::Utc::now()),
},
};
let json_result = emit_workspace_history_json(&output);
assert!(json_result.is_ok(), "Failed to emit history JSON");
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["schema_version"], "workspace-history-json.v1");
assert_eq!(parsed["spec_id"], "test-spec");
}
#[test]
fn test_history_json_output_has_required_fields() {
use crate::types::{HistoryEntry, HistoryMetrics, WorkspaceHistoryJsonOutput};
let output = WorkspaceHistoryJsonOutput {
schema_version: "workspace-history-json.v1".to_string(),
spec_id: "test-spec".to_string(),
timeline: vec![
HistoryEntry {
phase: "requirements".to_string(),
timestamp: chrono::Utc::now(),
exit_code: 0,
success: true,
tokens_input: Some(1000),
tokens_output: Some(500),
fixup_count: None,
model: Some("haiku".to_string()),
provider: None,
},
HistoryEntry {
phase: "design".to_string(),
timestamp: chrono::Utc::now(),
exit_code: 1,
success: false,
tokens_input: Some(2000),
tokens_output: Some(100),
fixup_count: None,
model: Some("haiku".to_string()),
provider: None,
},
],
metrics: HistoryMetrics {
total_executions: 2,
successful_executions: 1,
failed_executions: 1,
total_tokens_input: 3000,
total_tokens_output: 600,
total_fixups: 0,
first_execution: Some(chrono::Utc::now()),
last_execution: Some(chrono::Utc::now()),
},
};
let json_result = emit_workspace_history_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert!(parsed.get("schema_version").is_some());
assert!(parsed.get("spec_id").is_some());
assert!(parsed.get("timeline").is_some());
assert!(parsed.get("metrics").is_some());
let metrics = &parsed["metrics"];
assert!(metrics.get("total_executions").is_some());
assert!(metrics.get("successful_executions").is_some());
assert!(metrics.get("failed_executions").is_some());
assert!(metrics.get("total_tokens_input").is_some());
assert!(metrics.get("total_tokens_output").is_some());
assert!(metrics.get("total_fixups").is_some());
assert_eq!(metrics["total_executions"], 2);
assert_eq!(metrics["successful_executions"], 1);
assert_eq!(metrics["failed_executions"], 1);
assert_eq!(metrics["total_tokens_input"], 3000);
assert_eq!(metrics["total_tokens_output"], 600);
}
#[test]
fn test_history_json_canonical_format() {
use crate::types::{HistoryMetrics, WorkspaceHistoryJsonOutput};
let output = WorkspaceHistoryJsonOutput {
schema_version: "workspace-history-json.v1".to_string(),
spec_id: "test-spec".to_string(),
timeline: vec![],
metrics: HistoryMetrics {
total_executions: 0,
successful_executions: 0,
failed_executions: 0,
total_tokens_input: 0,
total_tokens_output: 0,
total_fixups: 0,
first_execution: None,
last_execution: None,
},
};
let json_result = emit_workspace_history_json(&output);
assert!(json_result.is_ok());
let json_str = json_result.unwrap();
assert!(
!json_str.contains(" "),
"Canonical JSON should not have indentation"
);
assert!(
!json_str.contains('\n'),
"Canonical JSON should not have newlines"
);
}
#[test]
fn test_history_command_no_spec() {
let _temp_dir = setup_test_environment();
let result = execute_project_history_command("nonexistent-spec-history", false);
assert!(result.is_ok());
}
#[test]
fn test_history_command_json_no_spec() {
let _temp_dir = setup_test_environment();
let result = execute_project_history_command("nonexistent-spec-history-json", true);
assert!(result.is_ok());
}
#[test]
fn test_history_timeline_entry_structure() {
use crate::types::HistoryEntry;
let entry = HistoryEntry {
phase: "requirements".to_string(),
timestamp: chrono::Utc::now(),
exit_code: 0,
success: true,
tokens_input: Some(1000),
tokens_output: Some(500),
fixup_count: Some(3),
model: Some("haiku".to_string()),
provider: Some("openrouter".to_string()),
};
let json_value = serde_json::to_value(&entry).unwrap();
assert_eq!(json_value["phase"], "requirements");
assert_eq!(json_value["exit_code"], 0);
assert_eq!(json_value["success"], true);
assert_eq!(json_value["tokens_input"], 1000);
assert_eq!(json_value["tokens_output"], 500);
assert_eq!(json_value["fixup_count"], 3);
assert_eq!(json_value["model"], "haiku");
assert_eq!(json_value["provider"], "openrouter");
}
#[test]
fn test_history_metrics_aggregation() {
use crate::types::HistoryMetrics;
let metrics = HistoryMetrics {
total_executions: 5,
successful_executions: 3,
failed_executions: 2,
total_tokens_input: 10000,
total_tokens_output: 5000,
total_fixups: 7,
first_execution: Some(chrono::Utc::now()),
last_execution: Some(chrono::Utc::now()),
};
let json_value = serde_json::to_value(&metrics).unwrap();
assert_eq!(json_value["total_executions"], 5);
assert_eq!(json_value["successful_executions"], 3);
assert_eq!(json_value["failed_executions"], 2);
assert_eq!(json_value["total_tokens_input"], 10000);
assert_eq!(json_value["total_tokens_output"], 5000);
assert_eq!(json_value["total_fixups"], 7);
}
}
fn derive_spec_status(spec_id: &str) -> String {
use crate::receipt::ReceiptManager;
let base_path = crate::paths::spec_root(spec_id);
let receipt_manager = ReceiptManager::new(&base_path);
match receipt_manager.list_receipts() {
Ok(receipts) => {
if receipts.is_empty() {
"not_started".to_string()
} else {
let latest = receipts.last().unwrap();
if latest.exit_code == 0 {
format!("{}: success", latest.phase)
} else {
format!("{}: failed", latest.phase)
}
}
}
Err(_) => {
if base_path.exists() {
"unknown".to_string()
} else {
"not_started".to_string()
}
}
}
}
fn execute_project_command(cmd: ProjectCommands) -> Result<()> {
use crate::workspace::{self, Workspace};
match cmd {
ProjectCommands::Init { name } => {
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let workspace_path = workspace::init_workspace(&cwd, &name)?;
println!("✓ Initialized workspace: {}", name);
println!(" Created: {}", workspace_path.display());
println!("\nNext steps:");
println!(" - Add specs with: xchecker project add-spec <spec-id>");
println!(" - List specs with: xchecker project list");
Ok(())
}
ProjectCommands::AddSpec {
spec_id,
tag,
force,
} => {
let sanitized_id = sanitize_spec_id(&spec_id).map_err(|e| {
XCheckerError::Config(ConfigError::InvalidValue {
key: "spec_id".to_string(),
value: format!("{e}"),
})
})?;
let workspace_path = workspace::discover_workspace_from_cwd()?.ok_or_else(|| {
anyhow::anyhow!("No workspace found. Run 'xchecker project init <name>' first.")
})?;
let mut ws = Workspace::load(&workspace_path)?;
ws.add_spec(&sanitized_id, tag.clone(), force)?;
ws.save(&workspace_path)?;
println!("✓ Added spec '{}' to workspace", sanitized_id);
if !tag.is_empty() {
println!(" Tags: {}", tag.join(", "));
}
Ok(())
}
ProjectCommands::List { workspace } => {
let workspace_path =
workspace::resolve_workspace(workspace.as_deref())?.ok_or_else(|| {
anyhow::anyhow!("No workspace found. Run 'xchecker project init <name>' first.")
})?;
let ws = Workspace::load(&workspace_path)?;
println!("Workspace: {}", ws.name);
println!("Location: {}", workspace_path.display());
println!();
if ws.specs.is_empty() {
println!("No specs registered.");
println!("\nAdd specs with: xchecker project add-spec <spec-id>");
} else {
println!("Specs ({}):", ws.specs.len());
for spec in ws.list_specs() {
let status = derive_spec_status(&spec.id);
let tags_str = if spec.tags.is_empty() {
String::new()
} else {
format!(" [{}]", spec.tags.join(", "))
};
println!(" - {} ({}){}", spec.id, status, tags_str);
}
}
Ok(())
}
ProjectCommands::Status { workspace, json } => {
execute_project_status_command(workspace.as_deref(), json)
}
ProjectCommands::History { spec_id, json } => {
let sanitized_id = sanitize_spec_id(&spec_id).map_err(|e| {
XCheckerError::Config(ConfigError::InvalidValue {
key: "spec_id".to_string(),
value: format!("{e}"),
})
})?;
execute_project_history_command(&sanitized_id, json)
}
ProjectCommands::Tui { workspace } => execute_project_tui_command(workspace.as_deref()),
}
}
fn execute_project_status_command(
workspace_override: Option<&std::path::Path>,
json: bool,
) -> Result<()> {
use crate::receipt::ReceiptManager;
use crate::types::{WorkspaceSpecStatus, WorkspaceStatusJsonOutput, WorkspaceStatusSummary};
use crate::workspace::{self, Workspace};
let workspace_path = workspace::resolve_workspace(workspace_override)?.ok_or_else(|| {
anyhow::anyhow!("No workspace found. Run 'xchecker project init <name>' first.")
})?;
let ws = Workspace::load(&workspace_path)?;
let mut spec_statuses = Vec::new();
let mut summary = WorkspaceStatusSummary {
total_specs: ws.specs.len() as u32,
successful_specs: 0,
failed_specs: 0,
pending_specs: 0,
not_started_specs: 0,
stale_specs: 0,
};
let stale_threshold = chrono::Duration::days(7);
let now = chrono::Utc::now();
for spec in ws.list_specs() {
let base_path = crate::paths::spec_root(&spec.id);
let receipt_manager = ReceiptManager::new(&base_path);
let receipts = receipt_manager.list_receipts().unwrap_or_default();
let (status, latest_phase, last_activity, has_errors) = if receipts.is_empty() {
summary.not_started_specs += 1;
("not_started".to_string(), None, None, false)
} else {
let latest = receipts.last().unwrap();
let last_activity_time = latest.emitted_at;
let is_stale = now.signed_duration_since(last_activity_time) > stale_threshold;
if is_stale {
summary.stale_specs += 1;
}
if latest.exit_code == 0 {
let all_phases_complete = receipts
.iter()
.any(|r| r.phase == "final" && r.exit_code == 0);
if all_phases_complete {
summary.successful_specs += 1;
(
if is_stale { "stale" } else { "success" }.to_string(),
Some(latest.phase.clone()),
Some(last_activity_time),
false,
)
} else {
summary.pending_specs += 1;
(
if is_stale { "stale" } else { "pending" }.to_string(),
Some(latest.phase.clone()),
Some(last_activity_time),
false,
)
}
} else {
summary.failed_specs += 1;
(
"failed".to_string(),
Some(latest.phase.clone()),
Some(last_activity_time),
true,
)
}
};
let pending_fixups = count_pending_fixups_for_spec(&spec.id);
spec_statuses.push(WorkspaceSpecStatus {
spec_id: spec.id.clone(),
tags: spec.tags.clone(),
status,
latest_phase,
last_activity,
pending_fixups,
has_errors,
});
}
if json {
let output = WorkspaceStatusJsonOutput {
schema_version: "workspace-status-json.v1".to_string(),
workspace_name: ws.name.clone(),
workspace_path: workspace_path.display().to_string(),
specs: spec_statuses,
summary,
};
let json_output = emit_workspace_status_json(&output)?;
println!("{json_output}");
} else {
println!("Workspace: {}", ws.name);
println!("Location: {}", workspace_path.display());
println!();
println!("Summary:");
println!(" Total specs: {}", summary.total_specs);
println!(" Successful: {}", summary.successful_specs);
println!(" Failed: {}", summary.failed_specs);
println!(" Pending: {}", summary.pending_specs);
println!(" Not started: {}", summary.not_started_specs);
if summary.stale_specs > 0 {
println!(" Stale (>7 days): {}", summary.stale_specs);
}
println!();
if spec_statuses.is_empty() {
println!("No specs registered.");
println!("\nAdd specs with: xchecker project add-spec <spec-id>");
} else {
println!("Specs:");
for spec in &spec_statuses {
let tags_str = if spec.tags.is_empty() {
String::new()
} else {
format!(" [{}]", spec.tags.join(", "))
};
let phase_str = spec.latest_phase.as_deref().unwrap_or("-");
let fixups_str = if spec.pending_fixups > 0 {
format!(" ({} fixups)", spec.pending_fixups)
} else {
String::new()
};
println!(
" - {} ({}, {}){}{}",
spec.spec_id, spec.status, phase_str, tags_str, fixups_str
);
}
}
}
Ok(())
}
fn count_pending_fixups_for_spec(spec_id: &str) -> u32 {
crate::fixup::pending_fixups_for_spec(spec_id).targets
}
fn emit_workspace_status_json(output: &crate::types::WorkspaceStatusJsonOutput) -> Result<String> {
emit_jcs(output).context("Failed to emit workspace status JSON")
}
fn execute_project_history_command(spec_id: &str, json: bool) -> Result<()> {
use crate::receipt::ReceiptManager;
use crate::types::{HistoryEntry, HistoryMetrics, WorkspaceHistoryJsonOutput};
let base_path = crate::paths::spec_root(spec_id);
if !base_path.exists() {
if json {
let output = WorkspaceHistoryJsonOutput {
schema_version: "workspace-history-json.v1".to_string(),
spec_id: spec_id.to_string(),
timeline: vec![],
metrics: HistoryMetrics {
total_executions: 0,
successful_executions: 0,
failed_executions: 0,
total_tokens_input: 0,
total_tokens_output: 0,
total_fixups: 0,
first_execution: None,
last_execution: None,
},
};
let json_output = emit_workspace_history_json(&output)?;
println!("{json_output}");
} else {
println!("History for spec: {spec_id}");
println!(" Status: Spec not found");
println!(" Directory: {} (does not exist)", base_path);
}
return Ok(());
}
let receipt_manager = ReceiptManager::new(&base_path);
let receipts = receipt_manager.list_receipts().unwrap_or_default();
let mut timeline: Vec<HistoryEntry> = Vec::new();
let mut metrics = HistoryMetrics {
total_executions: 0,
successful_executions: 0,
failed_executions: 0,
total_tokens_input: 0,
total_tokens_output: 0,
total_fixups: 0,
first_execution: None,
last_execution: None,
};
for receipt in &receipts {
let success = receipt.exit_code == 0;
let (tokens_input, tokens_output, provider, model) = if let Some(ref llm) = receipt.llm {
(
llm.tokens_input,
llm.tokens_output,
llm.provider.clone(),
llm.model_used.clone(),
)
} else {
(None, None, None, Some(receipt.model_full_name.clone()))
};
let fixup_count = if receipt.phase == "fixup" && success {
Some(receipt.outputs.len() as u32)
} else {
None
};
let entry = HistoryEntry {
phase: receipt.phase.clone(),
timestamp: receipt.emitted_at,
exit_code: receipt.exit_code,
success,
tokens_input,
tokens_output,
fixup_count,
model,
provider,
};
metrics.total_executions += 1;
if success {
metrics.successful_executions += 1;
} else {
metrics.failed_executions += 1;
}
if let Some(ti) = tokens_input {
metrics.total_tokens_input += ti;
}
if let Some(to) = tokens_output {
metrics.total_tokens_output += to;
}
if let Some(fc) = fixup_count {
metrics.total_fixups += fc;
}
if metrics.first_execution.is_none()
|| receipt.emitted_at < metrics.first_execution.unwrap()
{
metrics.first_execution = Some(receipt.emitted_at);
}
if metrics.last_execution.is_none() || receipt.emitted_at > metrics.last_execution.unwrap()
{
metrics.last_execution = Some(receipt.emitted_at);
}
timeline.push(entry);
}
timeline.sort_by_key(|e| e.timestamp);
if json {
let output = WorkspaceHistoryJsonOutput {
schema_version: "workspace-history-json.v1".to_string(),
spec_id: spec_id.to_string(),
timeline,
metrics,
};
let json_output = emit_workspace_history_json(&output)?;
println!("{json_output}");
} else {
println!("History for spec: {spec_id}");
println!("Location: {}", base_path);
println!();
println!("Summary:");
println!(" Total executions: {}", metrics.total_executions);
println!(" Successful: {}", metrics.successful_executions);
println!(" Failed: {}", metrics.failed_executions);
if metrics.total_tokens_input > 0 || metrics.total_tokens_output > 0 {
println!(
" Total tokens: {} input, {} output",
metrics.total_tokens_input, metrics.total_tokens_output
);
}
if metrics.total_fixups > 0 {
println!(" Total fixups applied: {}", metrics.total_fixups);
}
if let Some(first) = metrics.first_execution {
println!(
" First execution: {}",
first.format("%Y-%m-%d %H:%M:%S UTC")
);
}
if let Some(last) = metrics.last_execution {
println!(" Last execution: {}", last.format("%Y-%m-%d %H:%M:%S UTC"));
}
println!();
if timeline.is_empty() {
println!("No executions recorded.");
} else {
println!("Timeline ({} entries):", timeline.len());
for entry in &timeline {
let status_icon = if entry.success { "✓" } else { "✗" };
let tokens_str = match (entry.tokens_input, entry.tokens_output) {
(Some(ti), Some(to)) => format!(" [{} in, {} out]", ti, to),
(Some(ti), None) => format!(" [{} in]", ti),
(None, Some(to)) => format!(" [{} out]", to),
(None, None) => String::new(),
};
let fixup_str = entry
.fixup_count
.map(|c| format!(" ({} fixups)", c))
.unwrap_or_default();
println!(
" {} {} {} (exit {}){}{}",
entry.timestamp.format("%Y-%m-%d %H:%M:%S"),
status_icon,
entry.phase,
entry.exit_code,
tokens_str,
fixup_str
);
}
}
}
Ok(())
}
fn emit_workspace_history_json(
output: &crate::types::WorkspaceHistoryJsonOutput,
) -> Result<String> {
emit_jcs(output).context("Failed to emit workspace history JSON")
}
fn execute_project_tui_command(workspace_override: Option<&std::path::Path>) -> Result<()> {
use crate::workspace;
let workspace_path = workspace::resolve_workspace(workspace_override)?.ok_or_else(|| {
anyhow::anyhow!("No workspace found. Run 'xchecker project init <name>' first.")
})?;
crate::tui::run_tui(&workspace_path)
}
fn execute_template_command(cmd: TemplateCommands) -> Result<()> {
match cmd {
TemplateCommands::List => {
println!("Available templates:\n");
for t in xchecker_engine::templates::list_templates() {
println!(" {}", t.id);
println!(" Name: {}", t.name);
println!(" Description: {}", t.description);
println!(" Use case: {}", t.use_case);
if !t.prerequisites.is_empty() {
println!(" Prerequisites: {}", t.prerequisites.join(", "));
}
println!();
}
println!("To initialize a spec from a template:");
println!(" xchecker template init <template> <spec-id>");
Ok(())
}
TemplateCommands::Init { template, spec_id } => {
let sanitized_id = sanitize_spec_id(&spec_id).map_err(|e| {
XCheckerError::Config(ConfigError::InvalidValue {
key: "spec_id".to_string(),
value: format!("{e}"),
})
})?;
if !xchecker_engine::templates::is_valid_template(&template) {
let valid_templates = xchecker_engine::templates::BUILT_IN_TEMPLATES.join(", ");
return Err(XCheckerError::Config(ConfigError::InvalidValue {
key: "template".to_string(),
value: format!(
"Unknown template '{}'. Valid templates: {}",
template, valid_templates
),
})
.into());
}
xchecker_engine::templates::init_from_template(&template, &sanitized_id)?;
let template_info = xchecker_engine::templates::get_template(&template).unwrap();
println!(
"✓ Initialized spec '{}' from template '{}'",
sanitized_id, template
);
println!();
println!("Template: {}", template_info.name);
println!("Description: {}", template_info.description);
println!();
println!("Created files:");
println!(
" - .xchecker/specs/{}/context/problem-statement.md",
sanitized_id
);
println!(" - .xchecker/specs/{}/README.md", sanitized_id);
println!();
println!("Next steps:");
println!(" 1. Review the problem statement:");
println!(
" cat .xchecker/specs/{}/context/problem-statement.md",
sanitized_id
);
println!(" 2. Customize the problem statement for your needs");
println!(" 3. Run the requirements phase:");
println!(" xchecker resume {} --phase requirements", sanitized_id);
Ok(())
}
}
}