use anyhow::Result;
use std::path::Path;
use crate::config::schema::LlmProviderType;
use crate::config::load_config;
use crate::incremental::state::GenerationState;
use crate::project::ProjectRoot;
#[derive(Debug)]
pub struct CheckResult {
pub name: &'static str,
pub ok: bool,
pub detail: Option<String>,
}
pub fn run(config_path: Option<&Path>, root: &ProjectRoot) -> Result<Vec<CheckResult>> {
let mut checks = Vec::new();
let resolved = match config_path {
Some(p) => load_config(p).map(|c| (p.to_path_buf(), c)),
None => crate::config::load_default_config(root),
};
let config = match resolved {
Ok((path, mut c)) => {
let output_dir = c.output_dir();
if output_dir.is_relative() {
c.output_dir = Some(root.path().join(output_dir));
}
checks.push(CheckResult {
name: "配置",
ok: true,
detail: Some(format!("加载成功: {}", path.display())),
});
c
}
Err(e) => {
checks.push(CheckResult {
name: "配置",
ok: false,
detail: Some(format!("加载失败: {:#}", e)),
});
return Ok(checks);
}
};
let output_dir = config.output_dir();
let probe = output_dir.join(".doctor-write-probe");
let writable = std::fs::create_dir_all(output_dir)
.and_then(|_| std::fs::write(&probe, b"probe"))
.and_then(|_| std::fs::remove_file(&probe));
checks.push(CheckResult {
name: "产物目录可写",
ok: writable.is_ok(),
detail: Some(match writable {
Ok(()) => format!("{} 可写", output_dir.display()),
Err(e) => format!("{} 不可写: {}", output_dir.display(), e),
}),
});
let has_existing = output_dir.exists()
&& std::fs::read_dir(output_dir).map(|mut d| d.next().is_some()).unwrap_or(false);
checks.push(CheckResult {
name: "输出目录",
ok: true,
detail: Some(if has_existing {
format!("{} 已存在历史产物,生成将按指纹增量更新(人工修改受保护)", output_dir.display())
} else {
format!("{} 为空或不存在,将全新生成", output_dir.display())
}),
});
let key_ok = config
.llm
.api_key
.as_ref()
.map(|k| !k.is_empty())
.unwrap_or(false)
|| (!config.llm.api_key_env.is_empty()
&& std::env::var_os(&config.llm.api_key_env).is_some());
checks.push(CheckResult {
name: "LLM Key",
ok: key_ok,
detail: Some(if key_ok {
if config.llm.api_key.is_some() {
"已配置 api_key(配置文件)".to_string()
} else {
format!("环境变量 {} 已设置", config.llm.api_key_env)
}
} else if config.llm.api_key_env.is_empty() {
"未配置 api_key 且 api_key_env 为空(mock provider 除外)".to_string()
} else {
format!(
"未设置:api_key 为空且环境变量 {} 未定义。请设置 {} 或编辑配置文件的 [llm] 段",
config.llm.api_key_env, config.llm.api_key_env
)
}),
});
if config.llm.provider == LlmProviderType::Mock {
checks.push(CheckResult {
name: "网络",
ok: true,
detail: Some("mock provider:跳过网络检查(本地模拟)".to_string()),
});
} else {
let base_url = config
.llm
.base_url
.clone()
.unwrap_or_else(|| match config.llm.provider {
LlmProviderType::Anthropic => "https://api.anthropic.com/v1".to_string(),
_ => "https://api.openai.com/v1".to_string(),
});
let reachable = tokio::runtime::Runtime::new()
.map(|rt| {
rt.block_on(async {
match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
{
Ok(client) => match client.get(&base_url).send().await {
Ok(resp) => resp.status().as_u16() < 600,
Err(_) => false,
},
Err(_) => false,
}
})
})
.unwrap_or(false);
checks.push(CheckResult {
name: "网络",
ok: reachable,
detail: Some(if reachable {
format!("{} 可达", base_url)
} else {
format!("{} 不可达(5s 超时/拒绝连接)。检查网络或 base_url 配置", base_url)
}),
});
}
let state_path = output_dir.join(".state").join("generation_state.json");
let version_check = if !state_path.exists() {
CheckResult {
name: "版本",
ok: true,
detail: Some("尚无生成状态(首次生成将记录工具版本)".to_string()),
}
} else {
let current = env!("CARGO_PKG_VERSION");
match GenerationState::load(&output_dir.join(".state")) {
Ok(state) => match state.tool_version {
Some(recorded) if recorded == current => CheckResult {
name: "版本",
ok: true,
detail: Some(format!("产物由当前版本 {} 生成", current)),
},
Some(recorded) => CheckResult {
name: "版本",
ok: true,
detail: Some(format!(
"产物由 v{} 生成,当前二进制 v{}——建议运行一次完整 generate 升级产物",
recorded, current
)),
},
None => CheckResult {
name: "版本",
ok: true,
detail: Some("产物由旧版本生成(状态无版本记录),建议运行一次完整 generate".to_string()),
},
},
Err(e) => CheckResult {
name: "版本",
ok: true,
detail: Some(format!("状态文件读取失败(不阻断): {}", e)),
},
}
};
checks.push(version_check);
Ok(checks)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn temp_config(tag: &str, extra: &str) -> (PathBuf, PathBuf) {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_doctor_{}_{}", tag, std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let config = dir.join("doctor-test.toml");
std::fs::write(
&config,
format!(
r#"[llm]
provider = "mock"
model = "mock-model"
api_key = "mock"
api_key_env = ""
max_concurrent = 1
{extra}"#
),
)
.unwrap();
(dir, config)
}
#[test]
fn test_doctor_all_pass_with_mock() {
let (dir, config) = temp_config("pass", "");
let root = ProjectRoot::new(dir.clone());
let checks = run(Some(&config), &root).unwrap();
assert_eq!(checks.len(), 6, "应恰好六项检查: {:?}", checks);
for c in &checks {
assert!(c.ok, "{} 应通过: {:?}", c.name, c.detail);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_doctor_config_missing_fails_first_check_only() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_doctor_missing_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let root = ProjectRoot::new(dir.clone());
let checks = run(Some(&dir.join("nope.toml")), &root).unwrap();
assert_eq!(checks.len(), 1, "配置失败应只返回配置检查: {:?}", checks);
assert!(!checks[0].ok);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_doctor_key_missing_reports_actionable_guidance() {
let (dir, config) = temp_config("key", "");
let root = ProjectRoot::new(dir.clone());
std::fs::write(
&config,
std::fs::read_to_string(&config)
.unwrap()
.replace("api_key = \"mock\"", "api_key = \"\""),
)
.unwrap();
let checks = run(Some(&config), &root).unwrap();
let key = checks.iter().find(|c| c.name == "LLM Key").expect("应有 Key 检查");
assert!(!key.ok);
let detail = key.detail.clone().unwrap();
assert!(detail.contains("api_key_env"), "应给出可操作引导: {detail}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_doctor_network_unreachable_fails() {
let (dir, config) = temp_config("net", "");
let cfg_text = std::fs::read_to_string(&config).unwrap();
std::fs::write(
&config,
cfg_text
.replace("provider = \"mock\"", "provider = \"openai-compatible\"")
+ "base_url = \"http://127.0.0.1:1/v1\"\n",
)
.unwrap();
let root = ProjectRoot::new(dir.clone());
let checks = run(Some(&config), &root).unwrap();
let net = checks.iter().find(|c| c.name == "网络").expect("应有网络检查");
assert!(!net.ok, "未监听端口应报不可达: {:?}", net.detail);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_doctor_version_reports_drift() {
let (dir, config) = temp_config("ver", "");
let root = ProjectRoot::new(dir.clone());
let checks = run(Some(&config), &root).unwrap();
let ver = checks.iter().find(|c| c.name == "版本").expect("应有版本检查");
assert!(ver.ok, "无状态文件时应通过: {:?}", ver.detail);
assert!(
ver.detail.clone().unwrap().contains("尚无生成状态"),
"应提示首次生成: {:?}",
ver.detail
);
std::fs::create_dir_all(dir.join(".code-repo-wiki/.state")).unwrap();
std::fs::write(
dir.join(".code-repo-wiki/.state/generation_state.json"),
r#"{"last_commit_hash":null,"file_fingerprints":{},"generated_at":"2025-01-01T00:00:00Z","tool_version":"0.0.0"}"#,
)
.unwrap();
let checks = run(Some(&config), &root).unwrap();
let ver = checks.iter().find(|c| c.name == "版本").expect("应有版本检查");
assert!(ver.ok);
let detail = ver.detail.clone().unwrap();
assert!(detail.contains("0.0.0"), "应报告记录版本: {detail}");
assert!(detail.contains("升级产物"), "应建议升级产物: {detail}");
let current = env!("CARGO_PKG_VERSION");
std::fs::write(
dir.join(".code-repo-wiki/.state/generation_state.json"),
format!(
r#"{{"last_commit_hash":null,"file_fingerprints":{{}},"generated_at":"2025-01-01T00:00:00Z","tool_version":"{current}"}}"#
),
)
.unwrap();
let checks = run(Some(&config), &root).unwrap();
let ver = checks.iter().find(|c| c.name == "版本").expect("应有版本检查");
assert!(ver.ok);
assert!(
ver.detail.clone().unwrap().contains("由当前版本"),
"版本一致应通过: {:?}",
ver.detail
);
let _ = std::fs::remove_dir_all(&dir);
}
}