use std::collections::BTreeSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::Result;
use clap::Args;
use crate::Exit;
use crate::config;
use crate::files;
use crate::languages;
use toml::Value;
const HEADER_RULE: &str = "============================================================";
#[derive(Debug, Args)]
pub struct DoctorArgs {
#[arg(value_name = "PATH", default_value = ".")]
pub path: PathBuf,
#[arg(long, value_name = "FILE")]
pub config: Option<PathBuf>,
}
pub fn run(args: &DoctorArgs) -> Result<Exit> {
let mut out = std::io::stdout().lock();
match run_to(&mut out, args) {
Ok(exit) => Ok(exit),
Err(err) if is_broken_pipe(&err) => Ok(Exit::Clean),
Err(err) => Err(err),
}
}
pub(crate) fn is_broken_pipe(err: &anyhow::Error) -> bool {
err.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::BrokenPipe)
}
pub fn run_to<W: Write>(out: &mut W, args: &DoctorArgs) -> Result<Exit> {
run_at(out, args, &crate::auth::default_path()?)
}
pub fn run_at<W: Write>(out: &mut W, args: &DoctorArgs, auth_path: &Path) -> Result<Exit> {
run_at_with_codex(out, args, auth_path, &crate::llm::codex::current_status)
}
pub(crate) fn run_at_with_codex<W: Write>(
out: &mut W,
args: &DoctorArgs,
auth_path: &Path,
codex_probe: &dyn Fn() -> Result<crate::llm::codex::CodexStatus, String>,
) -> Result<Exit> {
let root = args
.path
.canonicalize()
.unwrap_or_else(|_| args.path.clone());
writeln!(out, "drep in {}", root.display())?;
writeln!(out, "{HEADER_RULE}")?;
let files = files::expand_paths(std::slice::from_ref(&root), files::is_scan_target);
let file_refs: Vec<&Path> = files.iter().map(PathBuf::as_path).collect();
let buckets = languages::group_by_language(&file_refs);
if buckets.is_empty() {
writeln!(out)?;
writeln!(out, "No source files drep recognises were found here.")?;
write_llm_section(out, args, &root, auth_path, codex_probe)?;
return Ok(Exit::Clean);
}
write_languages_section(out, &buckets)?;
let missing = write_tools_section(out, &buckets, &root)?;
write_llm_section(out, args, &root, auth_path, codex_probe)?;
if let Some(line) = missing_tools_line(&missing) {
writeln!(out)?;
writeln!(out, "{line}")?;
}
Ok(Exit::Clean)
}
fn missing_tools_line(missing: &[&str]) -> Option<String> {
if missing.is_empty() {
return None;
}
Some(format!(
"{} configured tool(s) are missing: {}. drep exits 2 rather than reporting those files clean.",
missing.len(),
missing.join(", "),
))
}
fn write_languages_section<W: Write>(
out: &mut W,
buckets: &[(&'static languages::spec::LanguageSupport, Vec<&Path>)],
) -> Result<()> {
writeln!(out)?;
writeln!(out, "Languages found:")?;
for (language, paths) in buckets {
writeln!(out, " {}: {} file(s)", language.display_name, paths.len())?;
}
Ok(())
}
fn write_tools_section<W: Write>(
out: &mut W,
buckets: &[(&'static languages::spec::LanguageSupport, Vec<&Path>)],
root: &Path,
) -> Result<Vec<&'static str>> {
writeln!(out)?;
writeln!(out, "Deterministic checks (these gate):")?;
let mut missing: Vec<&'static str> = Vec::new();
for (language, paths) in buckets {
if language.tools.is_empty() {
writeln!(out, " {}: no tools wired up yet", language.display_name)?;
continue;
}
for spec in language.tools {
let roots: BTreeSet<PathBuf> = paths
.iter()
.filter_map(|path| languages::runner::configuration_root(spec, root, path))
.collect();
let outcome = if roots.is_empty() {
languages::runner::tool_status(spec, root)
} else {
workspace_tool_status(spec, root, &roots)
};
writeln!(out, " {}: {}", spec.name, outcome.detail)?;
if matches!(outcome.status, languages::runner::ToolStatus::Unavailable)
&& !missing.contains(&spec.name)
{
missing.push(spec.name);
}
}
}
Ok(missing)
}
fn workspace_tool_status(
spec: &'static languages::spec::ToolSpec,
root: &Path,
roots: &BTreeSet<PathBuf>,
) -> languages::runner::ToolOutcome {
let statuses: Vec<_> = roots
.iter()
.map(|workspace| languages::runner::tool_status_at(spec, root, workspace))
.collect();
if let Some(unavailable) = statuses
.iter()
.find(|outcome| matches!(outcome.status, languages::runner::ToolStatus::Unavailable))
{
return unavailable.clone();
}
let detail = if roots.len() == 1 && roots.contains(&root.to_path_buf()) {
"ready".to_owned()
} else {
format!("ready in {} workspace(s)", roots.len())
};
languages::runner::ToolOutcome {
tool: spec.name,
status: languages::runner::ToolStatus::Ok,
findings: Vec::new(),
detail,
compilation_succeeded: false,
}
}
fn write_llm_section<W: Write>(
out: &mut W,
args: &DoctorArgs,
root: &Path,
auth_path: &Path,
codex_probe: &dyn Fn() -> Result<crate::llm::codex::CodexStatus, String>,
) -> Result<()> {
writeln!(out)?;
writeln!(out, "LLM analysis (required):")?;
let config_path: PathBuf = match &args.config {
Some(p) => p.clone(),
None => root.join(config::default_config_path()),
};
let raw = match std::fs::read_to_string(&config_path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
writeln!(
out,
" No config file at {} - `drep check` cannot run. Run `drep init`.",
config_path.display()
)?;
return Ok(());
}
Err(err) => {
writeln!(out, " {} could not be read: {err}", config_path.display())?;
return Ok(());
}
};
let value: toml::Value = match toml::from_str(&raw) {
Ok(v) => v,
Err(err) => {
writeln!(
out,
" {} could not be parsed: {}",
config_path.display(),
err.message()
)?;
return Ok(());
}
};
let providers = match value.get("llm") {
None => {
writeln!(
out,
" {} declares no `[[llm]]` provider. Run `drep init`.",
config_path.display()
)?;
return Ok(());
}
Some(Value::Array(entries)) if entries.is_empty() => {
writeln!(
out,
" {} declares no `[[llm]]` provider. Run `drep init`.",
config_path.display()
)?;
return Ok(());
}
Some(Value::Array(entries)) => entries,
Some(_) => {
writeln!(
out,
" {} has an `llm` key that is not a `[[llm]]` array of tables. \
Providers are declared as `[[llm]]`, one block per provider.",
config_path.display()
)?;
report_load_failure(out, &config_path)?;
return Ok(());
}
};
let needs_auth_store = providers.iter().any(|entry| {
entry_is_enabled(entry) && entry.get("backend").and_then(Value::as_str) != Some("codex")
});
let store = match needs_auth_store
.then(|| crate::auth::AuthStore::load(auth_path))
.transpose()
{
Ok(Some(store)) => store,
Ok(None) => crate::auth::AuthStore::new(),
Err(err) => {
writeln!(out, " The auth store could not be read: {err}")?;
crate::auth::AuthStore::new()
}
};
let mut enabled_count = 0usize;
let mut codex_status: Option<Result<crate::llm::codex::CodexStatus, String>> = None;
for entry in providers {
let model = entry
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("(no model set)");
let endpoint = entry
.get("endpoint")
.and_then(|v| v.as_str())
.unwrap_or("(no endpoint set)");
let protocol = match entry.get("protocol").and_then(|v| v.as_str()) {
None | Some("openai") => String::new(),
Some(other) => format!(" [{other}]"),
};
let is_codex = entry.get("backend").and_then(Value::as_str) == Some("codex");
let description = if is_codex {
format!("{model} via ChatGPT/Codex subscription")
} else {
format!("{model} at {endpoint}{protocol}")
};
if entry_is_enabled(entry) {
enabled_count += 1;
writeln!(out, " {enabled_count}. {description}")?;
if is_codex {
let status = codex_status.get_or_insert_with(codex_probe);
match status {
Ok(status) => {
writeln!(out, " Codex CLI: {}", status.cli_version())?;
writeln!(out, " authentication: ChatGPT-managed")?;
writeln!(out, " isolation: ephemeral, read-only, tools disabled")?;
}
Err(err) => writeln!(out, " unavailable: {err}")?,
}
} else {
writeln!(out, " key: {}", key_source_line(entry, &store))?;
}
} else {
writeln!(out, " - {description} (disabled - skipped)")?;
}
}
writeln!(out, " {}", failover_line(enabled_count))?;
for name in unset_env_vars(&value) {
writeln!(
out,
" {name} is NOT set - LLM analysis will fail until you export it."
)?;
}
match config::load(&config_path) {
Err(config::ConfigError::EnvVarUnset(_, _)) => Ok(()),
other => report_load_result(out, &config_path, other),
}
}
fn key_source_line(entry: &Value, store: &crate::auth::AuthStore) -> String {
let api_key = entry.get("api_key").and_then(|v| v.as_str());
let endpoint = entry.get("endpoint").and_then(|v| v.as_str());
let source = crate::auth::source_of(api_key, endpoint, true, store);
match (source, api_key) {
(crate::auth::KeySource::Config, Some(reference))
if !crate::config::env_var_refs_in(&Value::String(reference.to_string()))
.is_empty() =>
{
format!("{reference} ({})", crate::auth::KeySource::Config.label())
}
(crate::auth::KeySource::Config, _) => format!(
"a literal value ({}) - prefer `${{VAR}}` so the file can be committed",
crate::auth::KeySource::Config.label()
),
(source, _) => source.label().to_string(),
}
}
fn report_load_failure<W: Write>(out: &mut W, config_path: &Path) -> Result<()> {
let loaded = config::load(config_path);
report_load_result(out, config_path, loaded)
}
fn report_load_result<W: Write>(
out: &mut W,
config_path: &Path,
loaded: Result<config::Config, config::ConfigError>,
) -> Result<()> {
if let Err(err) = loaded {
writeln!(out, " {} will not load: {err}", config_path.display())?;
}
Ok(())
}
fn entry_is_enabled(entry: &toml::Value) -> bool {
entry
.get("enabled")
.and_then(toml::Value::as_bool)
.unwrap_or_else(|| config::LlmConfig::default().enabled)
}
fn failover_line(enabled: usize) -> String {
match enabled {
0 => "Every provider is disabled - `drep check` cannot run. Re-enable one.".to_owned(),
1 => "One provider, so there is no fallback: if it is unreachable, `drep check` exits 2."
.to_owned(),
n => format!(
"{n} providers, tried in order: a transport failure falls through to the \
next. A 401 or 403 does not - that is misconfiguration, and failing \
over would hide it."
),
}
}
fn unset_env_vars(value: &toml::Value) -> Vec<String> {
config::required_env_var_refs(value)
.into_iter()
.filter(|name| std::env::var_os(name).is_none())
.collect()
}
#[cfg(test)]
mod unit_tests;
#[cfg(test)]
mod tests;