Skip to main content

cargo_fresh/
errors.rs

1//! cargo-fresh 的可执行错误类型与提示映射。
2//!
3//! 大部分内部路径继续返回 `anyhow::Result`——错误链很短,没必要为每个 bail
4//! 都写一个枚举变体。这个模块的核心价值是 [`hint_for`]:拿到失败时的
5//! `anyhow::Error`,沿错误链向下嗅探到 `CargoFreshError` 或底层
6//! `reqwest::Error`,反推一条对用户**可执行**的提示串("检查 HTTPS_PROXY"、
7//! "用 cargo --version 验证 toolchain" 之类),由 `main` 在退出前打到 stderr。
8//!
9//! 取舍:与其逼着所有 `anyhow::bail!` 改成枚举,不如保留 anyhow 的灵活
10//! 上下文,仅在我们能识别且能给出**具体**建议的路径上下沉到这里。
11//! 没有匹配时 `hint_for` 返回 `None`,由调用方退回默认错误信息。
12
13use thiserror::Error;
14
15/// 用户运行 cargo-fresh 时能识别出的几类典型失败。
16///
17/// 目前只覆盖会真正冒泡到 `main` 的路径——`fetch_latest_versions` 内部把
18/// 网络错误全部吞成空候选,所以 registry 相关的失败不会以错误形式出现。
19/// 网络层提示由 `hint_for` 中对 `reqwest::Error` 的直接匹配兜底,未来若
20/// 决定把 registry 错误外抛,可在此处增补变体。
21#[derive(Debug, Error)]
22pub enum CargoFreshError {
23    /// `cargo install --list` 子进程跑失败:cargo 不在 PATH、cargo home 损坏等。
24    #[error("`cargo install --list` failed: {source}")]
25    CargoListFailed {
26        #[source]
27        source: anyhow::Error,
28    },
29}
30
31/// 一条可执行提示。`hint_for` 只返回**哪一条**提示(locale key),具体文案由
32/// `main` 通过 `Language::get_text(hint.locale_key())` 按用户语言渲染——和 cargo-fresh
33/// 其它所有用户可见字符串走同一条 i18n 路径(`src/locale/texts.rs`),不在这里硬编码英文。
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Hint {
36    /// `cargo install --list` 子进程失败:检查 cargo 是否在 PATH。
37    CargoListFailed,
38    /// reqwest 连接/超时:检查网络连通性 / 代理。
39    NetworkConnectTimeout,
40    /// `--filter` / `--exclude` 传了非法 glob。
41    InvalidGlob,
42}
43
44impl Hint {
45    /// 对应 `src/locale/texts.rs` 里的本地化键,喂给 `Language::get_text`。
46    pub fn locale_key(self) -> &'static str {
47        match self {
48            Hint::CargoListFailed => "hint_cargo_list_failed",
49            Hint::NetworkConnectTimeout => "hint_network_connect_timeout",
50            Hint::InvalidGlob => "hint_invalid_glob",
51        }
52    }
53}
54
55/// 把 anyhow 错误链嗅探成一条 [`Hint`]。返回 `None` 时调用方按原样打印错误。
56///
57/// 嗅探规则:
58/// 1. 先看错误链里是否有 `CargoFreshError` —— 我们自己显式建模的几个路径
59/// 2. 再看链里是否有 `reqwest::Error` 且 `is_connect()` / `is_timeout()` —— 网络层判定
60/// 3. 再看链里是否有 `globset::Error` —— `--filter` / `--exclude` 传了非法 glob
61/// 4. 都不匹配返回 None
62///
63/// 注意:每包安装失败(权限被拒、GitHub 限流等)不会走到这里——它们被
64/// `run_one_update` 收成 `Failed` 结果、单独打 `Failed` 行,从不以顶层
65/// `anyhow::Error` 形式冒泡。所以这里只覆盖 `run()` 里 `?` 直接外抛的几条
66/// 启动期路径(列包、过滤模式编译、网络)。
67pub fn hint_for(err: &anyhow::Error) -> Option<Hint> {
68    for cause in err.chain() {
69        if cause.downcast_ref::<CargoFreshError>().is_some() {
70            // 目前 CargoFreshError 只有 CargoListFailed 一个变体。
71            return Some(Hint::CargoListFailed);
72        }
73        if let Some(re) = cause.downcast_ref::<reqwest::Error>() {
74            if re.is_connect() || re.is_timeout() {
75                return Some(Hint::NetworkConnectTimeout);
76            }
77        }
78        if cause.downcast_ref::<globset::Error>().is_some() {
79            return Some(Hint::InvalidGlob);
80        }
81    }
82    None
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn hint_for_cargo_list_failed() {
91        let err: anyhow::Error = CargoFreshError::CargoListFailed {
92            source: anyhow::anyhow!("exit code 101"),
93        }
94        .into();
95        assert_eq!(hint_for(&err), Some(Hint::CargoListFailed));
96    }
97
98    #[test]
99    fn hint_for_wrapped_cargo_fresh_error() {
100        // anyhow 的 context 链:底层是 CargoFreshError,外层 wrap 了一层
101        let err = anyhow::Error::from(CargoFreshError::CargoListFailed {
102            source: anyhow::anyhow!("exit code 101"),
103        })
104        .context("while listing installed packages");
105        assert_eq!(hint_for(&err), Some(Hint::CargoListFailed));
106    }
107
108    #[test]
109    fn hint_for_unrelated_error_returns_none() {
110        let err = anyhow::anyhow!("something else entirely");
111        assert!(hint_for(&err).is_none());
112    }
113
114    #[test]
115    fn hint_for_invalid_glob() {
116        // 走真实的 filter_packages 路径,确保 globset 错误真的能被嗅探到
117        // (而不是手搓一个假的 globset::Error 类型)。
118        let mut packages = Vec::new();
119        let err = crate::package::filter_packages(&mut packages, "[unclosed")
120            .expect_err("unclosed bracket should be an invalid glob");
121        assert_eq!(hint_for(&err), Some(Hint::InvalidGlob));
122    }
123
124    #[test]
125    fn every_hint_has_bilingual_text() {
126        // 每个 Hint 变体的 locale_key 在英文和中文里都必须有非空文案——
127        // 否则 main 会打出空 `Hint:` 行。
128        use crate::locale::texts::{get_chinese_text, get_english_text};
129        for hint in [
130            Hint::CargoListFailed,
131            Hint::NetworkConnectTimeout,
132            Hint::InvalidGlob,
133        ] {
134            let key = hint.locale_key();
135            assert!(!get_english_text(key).is_empty(), "missing EN for {key}");
136            assert!(!get_chinese_text(key).is_empty(), "missing ZH for {key}");
137        }
138    }
139}