use crate::*;
use carryctx::adapter::git::GitCli;
use carryctx::adapter::sqlite_repos::{
SqliteAgentRepository, SqliteSessionRepository, SqliteTaskRepository,
};
use carryctx::adapter::xdg::XdgPaths;
use carryctx::application::runtime::InvocationContext;
use carryctx::domain::session::SessionState;
use carryctx::domain::task::TaskStatus;
use carryctx::error::{CarryCtxError, ExitCode};
use carryctx::repository::{AgentRepository, SessionRepository, TaskFilter, TaskRepository};
use clap::Parser;
#[derive(Parser, Debug)]
pub struct DoctorArgs {
#[arg(long)]
pub fix: bool,
#[arg(long)]
pub json: bool,
}
pub fn handle_doctor(
args: &DoctorArgs,
ctx: &InvocationContext,
is_json: bool,
) -> Result<ExitCode, ExitCode> {
let mut checks: Vec<serde_json::Value> = Vec::new();
let mut all_ok = true;
let xdg = XdgPaths::new();
let global_config = xdg.global_config();
if global_config.exists() {
match std::fs::read_to_string(&global_config) {
Ok(content) => {
match toml::from_str::<carryctx::domain::config::CarryCtxConfig>(&content) {
Ok(_) => checks.push(serde_json::json!({
"check": "config.global",
"status": "ok",
"message": "Global config is valid"
})),
Err(e) => {
all_ok = false;
checks.push(serde_json::json!({
"check": "config.global",
"status": "error",
"message": format!("Invalid global config: {e}"),
"repairable": false
}));
}
}
}
Err(e) => {
checks.push(serde_json::json!({
"check": "config.global",
"status": "warning",
"message": format!("Cannot read global config: {e}"),
"repairable": false
}));
}
}
} else {
checks.push(serde_json::json!({
"check": "config.global",
"status": "info",
"message": "No global config found (using defaults)"
}));
}
let work_dir = resolve_work_dir(ctx);
let git = GitCli::new();
let git_project = match git.discover(work_dir) {
Ok(gp) => {
checks.push(serde_json::json!({
"check": "git.repository",
"status": "ok",
"message": format!("Git repository at {}", gp.repository_root.display())
}));
Some(gp)
}
Err(e) => {
all_ok = false;
checks.push(serde_json::json!({
"check": "git.repository",
"status": "error",
"message": format!("{e}"),
"repairable": false
}));
None
}
};
if let Some(ref gp) = git_project {
let hooks_dir = gp.git_common_dir.join("hooks");
let managed_hooks: Vec<&str> = ["post-commit", "prepare-commit-msg"]
.iter()
.filter(|&&name| {
let p = hooks_dir.join(name);
if !p.exists() {
return false;
}
std::fs::read_to_string(p)
.unwrap_or_default()
.contains("CarryCtx")
})
.copied()
.collect();
if managed_hooks.is_empty() {
checks.push(serde_json::json!({
"check": "git.hooks",
"status": "info",
"message": "No CarryCtx git hooks installed. Run `carryctx hooks install` to enable auto-checkpoint on commit.",
"fix_command": "carryctx hooks install"
}));
} else {
checks.push(serde_json::json!({
"check": "git.hooks",
"status": "ok",
"message": format!("CarryCtx hooks installed: {}", managed_hooks.join(", "))
}));
}
}
let runtime = match try_open_runtime(ctx) {
Ok(rt) => {
checks.push(serde_json::json!({
"check": "database.connection",
"status": "ok",
"message": format!("Database at {}", rt.db_path.display())
}));
checks.push(serde_json::json!({
"check": "database.schema",
"status": "ok",
"message": "Schema version up to date"
}));
Some(rt)
}
Err(exit_code) => {
all_ok = false;
let msg = match exit_code {
ExitCode::Database => {
"Database connection failed — try `carryctx init` to reinitialise"
}
ExitCode::Git => "Not in a Git repository",
_ => "Cannot open project (not initialised? Run `carryctx init`)",
};
checks.push(serde_json::json!({
"check": "database.connection",
"status": "error",
"message": msg,
"repairable": true,
"fix_command": "carryctx init"
}));
None
}
};
if let Some(ref rt) = runtime {
let conn = rt.database.connection();
let project_id = &rt.config.project.id;
let task_repo = SqliteTaskRepository::new(conn);
let agent_repo = SqliteAgentRepository::new(conn);
let filter = TaskFilter {
project_id: project_id.to_string(),
status: None,
owner_agent_id: None,
ready: false,
blocked: false,
mine: None,
};
match task_repo.list(&filter) {
Ok(tasks) => {
let mut orphaned: Vec<String> = Vec::new();
for task in &tasks {
if let Some(owner_id) = &task.owner_agent_id {
if agent_repo
.find_by_id(project_id, owner_id)
.ok()
.flatten()
.is_none()
{
orphaned.push(format!("{} ({})", task.display_id, task.title));
}
}
}
if orphaned.is_empty() {
checks.push(serde_json::json!({
"check": "tasks.orphaned",
"status": "ok",
"message": "No orphaned tasks (all owners exist)"
}));
} else {
all_ok = false;
checks.push(serde_json::json!({
"check": "tasks.orphaned",
"status": "warning",
"message": format!("{} task(s) have deleted owners: {}", orphaned.len(), orphaned.join(", ")),
"note": "Use `carryctx task unclaim <id>` to release ownership"
}));
}
let in_progress: Vec<_> = tasks
.iter()
.filter(|t| t.status == TaskStatus::InProgress)
.collect();
if !in_progress.is_empty() {
checks.push(serde_json::json!({
"check": "tasks.in_progress",
"status": "info",
"message": format!("{} task(s) currently in progress", in_progress.len()),
"tasks": in_progress.iter().map(|t| t.display_id.as_str()).collect::<Vec<_>>()
}));
}
}
Err(e) => {
checks.push(serde_json::json!({
"check": "tasks.orphaned",
"status": "warning",
"message": format!("Could not check tasks: {e}")
}));
}
}
let session_repo = SqliteSessionRepository::new(conn);
match session_repo.list(project_id) {
Ok(sessions) => {
let active: Vec<_> = sessions
.iter()
.filter(|s| matches!(s.state, SessionState::Active))
.collect();
if !active.is_empty() {
checks.push(serde_json::json!({
"check": "sessions.active",
"status": "ok",
"message": format!("{} active session(s)", active.len())
}));
} else {
checks.push(serde_json::json!({
"check": "sessions.active",
"status": "info",
"message": "No active session. Run `carryctx session start` to begin."
}));
}
}
Err(e) => {
checks.push(serde_json::json!({
"check": "sessions.active",
"status": "warning",
"message": format!("Could not check sessions: {e}")
}));
}
}
}
let summary = if all_ok { "healthy" } else { "issues_found" };
let result = serde_json::json!({
"summary": summary,
"checks": checks,
"fix_requested": args.fix,
"allOk": all_ok,
});
let exit_code = if all_ok {
ExitCode::Success
} else {
ExitCode::General
};
if !is_json && !args.json && !ctx.quiet {
println!("CarryCtx Doctor\n");
for check in result["checks"].as_array().unwrap() {
let status = check["status"].as_str().unwrap_or("?");
let message = check["message"].as_str().unwrap_or("");
let icon = match status {
"ok" => "✓",
"error" => "✗",
"warning" => "⚠",
_ => "·",
};
println!(" {icon} {message}");
if let Some(fix_cmd) = check["fix_command"].as_str() {
println!(" → Fix: {fix_cmd}");
}
}
println!();
if all_ok {
println!("Everything looks good!");
} else {
println!("Issues detected. Some may be fixed with `carryctx doctor --fix`.");
}
return Ok(exit_code);
}
let err_result: Result<serde_json::Value, CarryCtxError> = Ok(result);
let _ = render_and_print("doctor", err_result, is_json || args.json, ctx.quiet);
Ok(exit_code)
}