1use anyhow::Result;
10use std::path::Path;
11
12use crate::config::schema::LlmProviderType;
13use crate::config::load_config;
14use crate::incremental::state::GenerationState;
15use crate::project::ProjectRoot;
16
17#[derive(Debug)]
19pub struct CheckResult {
20 pub name: &'static str,
22 pub ok: bool,
24 pub detail: Option<String>,
26}
27
28pub fn run(config_path: Option<&Path>, root: &ProjectRoot) -> Result<Vec<CheckResult>> {
35 let mut checks = Vec::new();
36
37 let resolved = match config_path {
41 Some(p) => load_config(p).map(|c| (p.to_path_buf(), c)),
42 None => crate::config::load_default_config(root),
43 };
44 let config = match resolved {
45 Ok((path, mut c)) => {
46 let output_dir = c.output_dir();
48 if output_dir.is_relative() {
49 c.output_dir = Some(root.path().join(output_dir));
50 }
51 checks.push(CheckResult {
52 name: "配置",
53 ok: true,
54 detail: Some(format!("加载成功: {}", path.display())),
55 });
56 c
57 }
58 Err(e) => {
59 checks.push(CheckResult {
60 name: "配置",
61 ok: false,
62 detail: Some(format!("加载失败: {:#}", e)),
63 });
64 return Ok(checks);
65 }
66 };
67
68 let output_dir = config.output_dir();
71 let probe = output_dir.join(".doctor-write-probe");
72 let writable = std::fs::create_dir_all(output_dir)
73 .and_then(|_| std::fs::write(&probe, b"probe"))
74 .and_then(|_| std::fs::remove_file(&probe));
75 checks.push(CheckResult {
76 name: "产物目录可写",
77 ok: writable.is_ok(),
78 detail: Some(match writable {
79 Ok(()) => format!("{} 可写", output_dir.display()),
80 Err(e) => format!("{} 不可写: {}", output_dir.display(), e),
81 }),
82 });
83
84 let has_existing = output_dir.exists()
87 && std::fs::read_dir(output_dir).map(|mut d| d.next().is_some()).unwrap_or(false);
88 checks.push(CheckResult {
89 name: "输出目录",
90 ok: true,
91 detail: Some(if has_existing {
92 format!("{} 已存在历史产物,生成将按指纹增量更新(人工修改受保护)", output_dir.display())
93 } else {
94 format!("{} 为空或不存在,将全新生成", output_dir.display())
95 }),
96 });
97
98 let key_ok = config
101 .llm
102 .api_key
103 .as_ref()
104 .map(|k| !k.is_empty())
105 .unwrap_or(false)
106 || (!config.llm.api_key_env.is_empty()
107 && std::env::var_os(&config.llm.api_key_env).is_some());
108 checks.push(CheckResult {
109 name: "LLM Key",
110 ok: key_ok,
111 detail: Some(if key_ok {
112 if config.llm.api_key.is_some() {
113 "已配置 api_key(配置文件)".to_string()
114 } else {
115 format!("环境变量 {} 已设置", config.llm.api_key_env)
116 }
117 } else if config.llm.api_key_env.is_empty() {
118 "未配置 api_key 且 api_key_env 为空(mock provider 除外)".to_string()
119 } else {
120 format!(
121 "未设置:api_key 为空且环境变量 {} 未定义。请设置 {} 或编辑配置文件的 [llm] 段",
122 config.llm.api_key_env, config.llm.api_key_env
123 )
124 }),
125 });
126
127 if config.llm.provider == LlmProviderType::Mock {
129 checks.push(CheckResult {
130 name: "网络",
131 ok: true,
132 detail: Some("mock provider:跳过网络检查(本地模拟)".to_string()),
133 });
134 } else {
135 let base_url = config
136 .llm
137 .base_url
138 .clone()
139 .unwrap_or_else(|| match config.llm.provider {
140 LlmProviderType::Anthropic => "https://api.anthropic.com/v1".to_string(),
141 _ => "https://api.openai.com/v1".to_string(),
142 });
143 let reachable = tokio::runtime::Runtime::new()
148 .map(|rt| {
149 rt.block_on(async {
150 match reqwest::Client::builder()
151 .timeout(std::time::Duration::from_secs(5))
152 .build()
153 {
154 Ok(client) => match client.get(&base_url).send().await {
155 Ok(resp) => resp.status().as_u16() < 600,
156 Err(_) => false,
157 },
158 Err(_) => false,
159 }
160 })
161 })
162 .unwrap_or(false);
163 checks.push(CheckResult {
164 name: "网络",
165 ok: reachable,
166 detail: Some(if reachable {
167 format!("{} 可达", base_url)
168 } else {
169 format!("{} 不可达(5s 超时/拒绝连接)。检查网络或 base_url 配置", base_url)
170 }),
171 });
172 }
173
174 let state_path = output_dir.join(".state").join("generation_state.json");
181 let version_check = if !state_path.exists() {
182 CheckResult {
183 name: "版本",
184 ok: true,
185 detail: Some("尚无生成状态(首次生成将记录工具版本)".to_string()),
186 }
187 } else {
188 let current = env!("CARGO_PKG_VERSION");
189 match GenerationState::load(&output_dir.join(".state")) {
190 Ok(state) => match state.tool_version {
191 Some(recorded) if recorded == current => CheckResult {
192 name: "版本",
193 ok: true,
194 detail: Some(format!("产物由当前版本 {} 生成", current)),
195 },
196 Some(recorded) => CheckResult {
197 name: "版本",
198 ok: true,
199 detail: Some(format!(
200 "产物由 v{} 生成,当前二进制 v{}——建议运行一次完整 generate 升级产物",
201 recorded, current
202 )),
203 },
204 None => CheckResult {
205 name: "版本",
206 ok: true,
207 detail: Some("产物由旧版本生成(状态无版本记录),建议运行一次完整 generate".to_string()),
208 },
209 },
210 Err(e) => CheckResult {
211 name: "版本",
212 ok: true,
213 detail: Some(format!("状态文件读取失败(不阻断): {}", e)),
214 },
215 }
216 };
217 checks.push(version_check);
218
219 Ok(checks)
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use std::path::PathBuf;
226
227 fn temp_config(tag: &str, extra: &str) -> (PathBuf, PathBuf) {
229 let dir = std::env::temp_dir().join(format!("code_repo_wiki_doctor_{}_{}", tag, std::process::id()));
230 let _ = std::fs::remove_dir_all(&dir);
231 std::fs::create_dir_all(&dir).unwrap();
232 let config = dir.join("doctor-test.toml");
233 std::fs::write(
234 &config,
235 format!(
236 r#"[llm]
237provider = "mock"
238model = "mock-model"
239api_key = "mock"
240api_key_env = ""
241max_concurrent = 1
242{extra}"#
243 ),
244 )
245 .unwrap();
246 (dir, config)
247 }
248
249 #[test]
250 fn test_doctor_all_pass_with_mock() {
251 let (dir, config) = temp_config("pass", "");
252 let root = ProjectRoot::new(dir.clone());
253 let checks = run(Some(&config), &root).unwrap();
254 assert_eq!(checks.len(), 6, "应恰好六项检查: {:?}", checks);
255 for c in &checks {
256 assert!(c.ok, "{} 应通过: {:?}", c.name, c.detail);
257 }
258 let _ = std::fs::remove_dir_all(&dir);
259 }
260
261 #[test]
262 fn test_doctor_config_missing_fails_first_check_only() {
263 let dir = std::env::temp_dir().join(format!("code_repo_wiki_doctor_missing_{}", std::process::id()));
264 let _ = std::fs::remove_dir_all(&dir);
265 std::fs::create_dir_all(&dir).unwrap();
266 let root = ProjectRoot::new(dir.clone());
267 let checks = run(Some(&dir.join("nope.toml")), &root).unwrap();
268 assert_eq!(checks.len(), 1, "配置失败应只返回配置检查: {:?}", checks);
269 assert!(!checks[0].ok);
270 let _ = std::fs::remove_dir_all(&dir);
271 }
272
273 #[test]
274 fn test_doctor_key_missing_reports_actionable_guidance() {
275 let (dir, config) = temp_config("key", "");
276 let root = ProjectRoot::new(dir.clone());
277 std::fs::write(
279 &config,
280 std::fs::read_to_string(&config)
281 .unwrap()
282 .replace("api_key = \"mock\"", "api_key = \"\""),
283 )
284 .unwrap();
285 let checks = run(Some(&config), &root).unwrap();
286 let key = checks.iter().find(|c| c.name == "LLM Key").expect("应有 Key 检查");
287 assert!(!key.ok);
288 let detail = key.detail.clone().unwrap();
289 assert!(detail.contains("api_key_env"), "应给出可操作引导: {detail}");
290 let _ = std::fs::remove_dir_all(&dir);
291 }
292
293 #[test]
294 fn test_doctor_network_unreachable_fails() {
295 let (dir, config) = temp_config("net", "");
298 let cfg_text = std::fs::read_to_string(&config).unwrap();
299 std::fs::write(
300 &config,
301 cfg_text
302 .replace("provider = \"mock\"", "provider = \"openai-compatible\"")
303 + "base_url = \"http://127.0.0.1:1/v1\"\n",
304 )
305 .unwrap();
306 let root = ProjectRoot::new(dir.clone());
307 let checks = run(Some(&config), &root).unwrap();
308 let net = checks.iter().find(|c| c.name == "网络").expect("应有网络检查");
309 assert!(!net.ok, "未监听端口应报不可达: {:?}", net.detail);
310 let _ = std::fs::remove_dir_all(&dir);
311 }
312
313 #[test]
316 fn test_doctor_version_reports_drift() {
317 let (dir, config) = temp_config("ver", "");
318 let root = ProjectRoot::new(dir.clone());
319 let checks = run(Some(&config), &root).unwrap();
320 let ver = checks.iter().find(|c| c.name == "版本").expect("应有版本检查");
321 assert!(ver.ok, "无状态文件时应通过: {:?}", ver.detail);
322 assert!(
323 ver.detail.clone().unwrap().contains("尚无生成状态"),
324 "应提示首次生成: {:?}",
325 ver.detail
326 );
327
328 std::fs::create_dir_all(dir.join(".code-repo-wiki/.state")).unwrap();
330 std::fs::write(
331 dir.join(".code-repo-wiki/.state/generation_state.json"),
332 r#"{"last_commit_hash":null,"file_fingerprints":{},"generated_at":"2025-01-01T00:00:00Z","tool_version":"0.0.0"}"#,
333 )
334 .unwrap();
335 let checks = run(Some(&config), &root).unwrap();
336 let ver = checks.iter().find(|c| c.name == "版本").expect("应有版本检查");
337 assert!(ver.ok);
338 let detail = ver.detail.clone().unwrap();
339 assert!(detail.contains("0.0.0"), "应报告记录版本: {detail}");
340 assert!(detail.contains("升级产物"), "应建议升级产物: {detail}");
341
342 let current = env!("CARGO_PKG_VERSION");
344 std::fs::write(
345 dir.join(".code-repo-wiki/.state/generation_state.json"),
346 format!(
347 r#"{{"last_commit_hash":null,"file_fingerprints":{{}},"generated_at":"2025-01-01T00:00:00Z","tool_version":"{current}"}}"#
348 ),
349 )
350 .unwrap();
351 let checks = run(Some(&config), &root).unwrap();
352 let ver = checks.iter().find(|c| c.name == "版本").expect("应有版本检查");
353 assert!(ver.ok);
354 assert!(
355 ver.detail.clone().unwrap().contains("由当前版本"),
356 "版本一致应通过: {:?}",
357 ver.detail
358 );
359 let _ = std::fs::remove_dir_all(&dir);
360 }
361}