use std::io::Write;
use std::path::Path;
use anyhow::Result;
use clap::Args;
use rayon::prelude::*;
use schemars::JsonSchema;
use serde::Serialize;
use crate::cli::GlobalArgs;
use crate::concurrency::should_parallelize;
use crate::output::NdjsonWriter;
use crate::signal::ShutdownSignal;
#[derive(Args, Debug, Default)]
pub struct DoctorArgs {
#[arg(long, action = clap::ArgAction::SetTrue, help = "Exit non-zero when any check fails")]
pub strict: bool,
}
#[derive(Serialize, JsonSchema)]
struct DoctorReport {
r#type: &'static str,
version: String,
target: String,
ok: bool,
checks: Vec<DoctorCheck>,
}
#[derive(Serialize, JsonSchema, Clone)]
struct DoctorCheck {
name: String,
status: String,
detail: String,
}
#[tracing::instrument(skip_all, fields(command = "doctor"))]
pub fn cmd_doctor(
args: &DoctorArgs,
global: &GlobalArgs,
writer: &mut NdjsonWriter<impl Write>,
_shutdown: &ShutdownSignal,
_defaults: &crate::config::DefaultsSection,
) -> Result<()> {
let mut checks = workspace_checks(global);
let lang_override = global.lang.clone();
let threads = global.threads;
let max_filesize = global.effective_max_filesize();
let no_auto_heal = global.no_auto_heal;
let independent: Vec<Box<dyn Fn() -> DoctorCheck + Send + Sync>> = vec![
Box::new(move || {
let cpus = crate::concurrency::available_cpus();
let effective = crate::concurrency::effective_threads(threads);
let ram = crate::concurrency::ram_concurrency_cap();
let pool = crate::concurrency::configured_pool_size();
let detail = format!(
"cpus={cpus}, effective_threads={effective}, ram_cap={}, configured_pool={}, cli_threads={:?}",
ram.map(|n| n.to_string()).unwrap_or_else(|| "n/a".into()),
pool.map(|n| n.to_string()).unwrap_or_else(|| "unset".into()),
threads
);
check("concurrency_bound", "pass", detail)
}),
Box::new(|| {
check(
"feature_ast",
if cfg!(feature = "ast") {
"pass"
} else {
"warn"
},
if cfg!(feature = "ast") {
"ast feature enabled (scope/query/outline/transform)".to_string()
} else {
"ast feature disabled — install with --features ast for AST commands".to_string()
},
)
}),
Box::new(|| {
if cfg!(feature = "watch") {
check(
"feature_watch",
"pass",
"watch feature enabled (long-running; not one-shot default)",
)
} else {
check(
"feature_watch",
"info",
"watch disabled in this build (one-shot default; use --features watch)",
)
}
}),
Box::new(|| {
check(
"feature_semantic",
"pass",
"semantic-search offline Jaccard always available (no feature gate)",
)
}),
Box::new(|| {
let runtime = crate::env_detect::detect();
check(
"runtime_environment",
"pass",
crate::env_detect::summary(&runtime),
)
}),
Box::new(|| {
let runtime = crate::env_detect::detect();
check(
"platform_os",
"pass",
format!(
"os={} arch={} family={} target={}",
runtime.os, runtime.arch, runtime.family, runtime.target
),
)
}),
Box::new(|| {
if let Some(cfg_dir) = crate::storage::config_dir() {
let via = if crate::storage::home_override().is_some() {
"XDG/ProjectDirs config"
} else {
"ProjectDirs/XDG"
};
check(
"storage_config_dir",
"pass",
format!("config_dir={} via={via}", cfg_dir.display()),
)
} else {
check(
"storage_config_dir",
"warn",
"cannot resolve config dir (no ProjectDirs / XDG home)",
)
}
}),
Box::new(|| {
check(
"platform_fsync",
"pass",
format!(
"file={} dir={} rename={}",
crate::platform::platform_fsync_name(),
crate::platform::platform_dir_fsync_name(),
crate::platform::platform_rename_method()
),
)
}),
Box::new(|| {
check(
"stdout_contract",
"pass",
"structured NDJSON exclusively on stdout; logs on stderr",
)
}),
Box::new(|| {
check(
"mcp_policy",
"pass",
"MCP forbidden; use CLI + NDJSON (agent-surface / commands)",
)
}),
Box::new(move || {
let resolved = crate::locale::resolved_state();
check(
"locale",
"pass",
format!(
"resolved={} source={} override={:?} (NDJSON codes/Display English; suggestions locale-aware)",
resolved.map(|s| s.idioma.as_str()).unwrap_or("en"),
resolved.map(|s| s.source.as_str()).unwrap_or("default"),
lang_override.as_deref().unwrap_or("system/default")
),
)
}),
Box::new(move || {
check(
"max_filesize",
"pass",
format!("effective max_filesize={max_filesize} bytes"),
)
}),
Box::new(move || {
check(
"auto_heal",
"pass",
if no_auto_heal {
"startup wal-heal disabled (--no-auto-heal)".to_string()
} else {
"startup wal-heal enabled (orphan cleanup on startup)".to_string()
},
)
}),
];
let mut independent_results: Vec<DoctorCheck> = if should_parallelize(independent.len()) {
independent.par_iter().map(|job| job()).collect()
} else {
independent.iter().map(|job| job()).collect()
};
checks.append(&mut independent_results);
checks.sort_by(|a, b| a.name.cmp(&b.name));
let has_fail = checks.iter().any(|c| c.status == "fail");
let has_warn = checks.iter().any(|c| c.status == "warn");
let ok = if args.strict {
!has_fail && !has_warn
} else {
!has_fail
};
writer.write_event(&DoctorReport {
r#type: "doctor_report",
version: env!("CARGO_PKG_VERSION").to_string(),
target: env!("TARGET").to_string(),
ok,
checks,
})?;
if args.strict && !ok {
anyhow::bail!("doctor: one or more checks failed or warned (strict mode)");
}
Ok(())
}
fn workspace_checks(global: &GlobalArgs) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
match global.resolve_workspace() {
Ok(ws) => {
let ws_a = ws.clone();
let ws_b = ws.clone();
let ws_c = ws;
let config_path = global.config.clone();
let (exists, (writable, config)) = rayon::join(
move || {
if ws_a.is_dir() {
check(
"workspace_exists",
"pass",
format!("workspace directory: {}", ws_a.display()),
)
} else {
check(
"workspace_exists",
"fail",
format!("workspace path is not a directory: {}", ws_a.display()),
)
}
},
move || {
rayon::join(
|| match is_writable_dir(&ws_b) {
Ok(true) => check(
"workspace_writable",
"pass",
"can create temporary files in workspace",
),
Ok(false) => check(
"workspace_writable",
"fail",
"cannot write temporary files in workspace",
),
Err(e) => check(
"workspace_writable",
"warn",
format!("write probe error: {e}"),
),
},
|| match crate::config::load_config(&ws_c, config_path.as_deref()) {
Ok(cfg) => check(
"config_load",
"pass",
format!(
"defaults.backup={}, retention={}",
cfg.defaults.backup, cfg.defaults.retention
),
),
Err(e) => check("config_load", "fail", format!("config error: {e}")),
},
)
},
);
checks.push(exists);
checks.push(writable);
checks.push(config);
}
Err(e) => {
checks.push(check(
"workspace_exists",
"fail",
format!("cannot resolve workspace: {e:#}"),
));
checks.push(check(
"workspace_writable",
"fail",
"skipped — workspace unresolved",
));
checks.push(check(
"config_load",
"fail",
"skipped — workspace unresolved",
));
}
}
checks
}
fn check(name: &str, status: &str, detail: impl AsRef<str>) -> DoctorCheck {
DoctorCheck {
name: name.to_string(),
status: status.to_string(),
detail: detail.as_ref().to_string(),
}
}
fn is_writable_dir(dir: &Path) -> std::io::Result<bool> {
let probe = dir.join(".atomwrite-doctor-probe");
match std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&probe)
{
Ok(_) => {
let _ = std::fs::remove_file(&probe);
Ok(true)
}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => Ok(false),
Err(e) => Err(e),
}
}