Skip to main content

code_repo_wiki/
doctor.rs

1//! doctor:环境诊断命令(v17 t08)
2//!
3//! 对运行环境做六查并输出逐项结果,定位"配置加载失败/产物目录不可写/
4//! Key 缺失/网络不可达/版本漂移"类问题,给出可操作提示。语义(wayfinder-v17 t08
5//! 拍板):全过退出码 0,任一失败退出码 1(与 lint 三态同族,供 CI/脚本
6//! 门禁使用)。网络检查只覆盖 LLM base_url(mock provider 跳过——本地
7//! 模拟不触网,网络检查无意义)。
8
9use 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/// 单项检查结果
18#[derive(Debug)]
19pub struct CheckResult {
20    /// 检查项名称(终端输出用)
21    pub name: &'static str,
22    /// 是否通过
23    pub ok: bool,
24    /// 附加说明(失败原因 / 通过时的补充信息)
25    pub detail: Option<String>,
26}
27
28/// 六查诊断:配置可解析 → 产物目录可写 → 输出目录状态 → LLM Key →
29/// 网络连通性 → 版本漂移。任何一项失败不中断后续检查(一次跑完给出
30/// 全景,排障友好)。
31///
32/// 注意:配置加载失败时无法获得输出目录/Key/网络信息,只返回第一项
33/// 失败结果(避免在无效配置上继续猜测)。
34pub fn run(config_path: Option<&Path>, root: &ProjectRoot) -> Result<Vec<CheckResult>> {
35    let mut checks = Vec::new();
36
37    // 1. 配置存在且可解析(None 走默认配置链——项目级字段级合并覆盖
38    // 用户级,与主流程 load_config_rooted 同源;失败即终止——后续检查
39    // 都依赖配置)
40    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            // 输出目录相对路径统一解析到 root(与 load_config_rooted 同语义)
47            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    // 2. 产物目录可写(建目录 + 写探针文件删除;不可写时生成必失败,
69    // 提前暴露比生成中途报错友好)
70    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    // 3. 输出目录状态(已存在非空产物 → 提示将增量更新/可能覆盖;
85    // 空目录/不存在 → 全新生成提示)
86    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    // 4. LLM Key(api_key 字段优先,其次 api_key_env 环境变量;
99    // 空字符串视为未配置——空 api_key 是常见误配置)
100    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    // 5. 网络连通性(mock 跳过——本地模拟不触网;其余查 base_url 5s 超时)
128    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        // 5s 超时探活(GET 根路径;服务端 4xx/5xx 也算可达——连通性
144        // 检查只关心网络层通不通,业务错误由实际调用暴露)。
145        // reqwest 未启用 blocking feature(与项目异步客户端一致),
146        // 用 tokio runtime 单次 block_on 包装
147        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    // 6. 版本自检(v19 t01):产物由哪个工具版本生成,与当前二进制是否一致。
175    // 捕获 PATH 旧版二进制静默漂移(旧版缺 doctor/dry-run,调用报
176    // unrecognized subcommand exit 2,用户无从知道产物是旧格式)。
177    // 无状态文件 → 尚未生成,提示首次生成(恒通过,不算失败)。
178    // 状态缺 tool_version 字段(旧版本生成的状态文件)→ 无法判断,提示
179    // 建议重新全量生成(恒通过,不阻断——漂移是告警级问题)。
180    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    /// 构造临时目录内的最小 mock 配置(可追加覆盖段)
228    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        // api_key 置空(空字符串视为未配置)+ env 未设置
278        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        // base_url 指向未监听端口 → 不可达;同段内改写 provider 与追加 base_url
296        // (追加第二个 [llm] 段会因重复表头解析失败,测试须改写原段)
297        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    /// v19 t01:状态无版本记录(旧版产物)→ 版本检查恒通过但提示漂移;
314    /// 版本一致 → 通过且说明由当前版本生成
315    #[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        // 写入旧版本状态(tool_version=0.0.0)→ 提示漂移
329        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        // 写入当前版本状态 → 一致通过
343        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}