Skip to main content

gh_download/
error.rs

1use std::io;
2use std::path::PathBuf;
3
4use thiserror::Error;
5
6use crate::i18n::Language;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct UserFacingError {
10    pub title: String,
11    pub reason: String,
12    pub suggestions: Vec<String>,
13}
14
15#[derive(Debug, Error)]
16pub enum AppError {
17    #[error("repository cannot be empty")]
18    EmptyRepository,
19
20    #[error("remote path cannot be empty")]
21    EmptyRemotePath,
22
23    #[error("GitHub returned an unexpected response")]
24    UnexpectedApiResponse,
25
26    #[error("file entry is missing repository path")]
27    MissingRepositoryPath,
28
29    #[error("HTTP {status} for {url}")]
30    HttpStatus {
31        status: u16,
32        url: String,
33        detail: Option<String>,
34    },
35
36    #[error("request failed: {message}")]
37    Request {
38        url: Option<String>,
39        message: String,
40    },
41
42    #[error("json parse failed: {0}")]
43    Json(String),
44
45    #[error("invalid configuration: {0}")]
46    Config(String),
47
48    #[error("failed to write local path: {path}")]
49    Io {
50        path: PathBuf,
51        #[source]
52        source: io::Error,
53    },
54
55    #[error("invalid local path: {0}")]
56    InvalidPath(String),
57}
58
59pub fn classify_error(
60    error: &AppError,
61    token_present: bool,
62    language: Language,
63) -> UserFacingError {
64    let title = match language {
65        Language::En => "✖ Download failed".to_string(),
66        Language::Zh => "✖ 下载失败".to_string(),
67    };
68
69    match (language, error) {
70        (Language::Zh, AppError::HttpStatus { status, detail, .. })
71            if *status == 401 || *status == 403 || *status == 429 =>
72        {
73            let mut suggestions = vec![
74                "设置环境变量 GITHUB_TOKEN 或 GH_TOKEN".to_string(),
75                "或使用 --token <token> 重新执行".to_string(),
76            ];
77            if !token_present {
78                suggestions.push("如果直连 GitHub 不稳定,可检查 --proxy-base 是否可访问".to_string());
79            }
80            UserFacingError {
81                title,
82                reason: detail
83                    .clone()
84                    .unwrap_or_else(|| format!("GitHub 认证失败或触发限流(HTTP {})", status)),
85                suggestions,
86            }
87        }
88        (Language::En, AppError::HttpStatus { status, detail, .. })
89            if *status == 401 || *status == 403 || *status == 429 =>
90        {
91            let mut suggestions = vec![
92                "Set GITHUB_TOKEN or GH_TOKEN in the environment".to_string(),
93                "Or rerun with --token <token>".to_string(),
94            ];
95            if !token_present {
96                suggestions.push(
97                    "If direct GitHub access is unstable, verify that --proxy-base is reachable"
98                        .to_string(),
99                );
100            }
101            UserFacingError {
102                title,
103                reason: detail.clone().unwrap_or_else(|| {
104                    format!(
105                        "GitHub authentication failed or the rate limit was hit (HTTP {})",
106                        status
107                    )
108                }),
109                suggestions,
110            }
111        }
112        (Language::Zh, AppError::HttpStatus { status, detail, .. }) if *status == 404 => {
113            UserFacingError {
114                title,
115                reason: detail.clone().unwrap_or_else(|| {
116                    "未找到指定的仓库、分支或远端路径,或者当前凭证无法访问该私有仓库".to_string()
117                }),
118                suggestions: vec![
119                    "检查 owner/repo 是否正确".to_string(),
120                    "检查 --ref 指向的分支、tag 或 commit 是否存在".to_string(),
121                    "检查远端路径大小写是否正确".to_string(),
122                    "如果是私有仓库,请提供 --token 或设置 GITHUB_TOKEN / GH_TOKEN".to_string(),
123                ],
124            }
125        }
126        (Language::En, AppError::HttpStatus { status, detail, .. }) if *status == 404 => {
127            UserFacingError {
128                title,
129                reason: detail.clone().unwrap_or_else(|| {
130                    "The repository, ref, or remote path was not found, or the current credentials cannot access the private repository".to_string()
131                }),
132                suggestions: vec![
133                    "Check whether owner/repo is correct".to_string(),
134                    "Check whether --ref points to an existing branch, tag, or commit".to_string(),
135                    "Check the remote path and its letter casing".to_string(),
136                    "If this is a private repository, provide --token or set GITHUB_TOKEN / GH_TOKEN".to_string(),
137                ],
138            }
139        }
140        (Language::Zh, AppError::Request { .. }) => UserFacingError {
141            title,
142            reason: "连接 GitHub 或代理失败".to_string(),
143            suggestions: vec![
144                "检查当前网络是否可访问 GitHub".to_string(),
145                "如果使用了 --proxy-base,请确认代理地址可访问".to_string(),
146                "稍后重试,或提供 --token 降低匿名请求失败概率".to_string(),
147            ],
148        },
149        (Language::En, AppError::Request { .. }) => UserFacingError {
150            title,
151            reason: "Failed to connect to GitHub or the configured proxy".to_string(),
152            suggestions: vec![
153                "Check whether the current network can reach GitHub".to_string(),
154                "If you are using --proxy-base, verify that the proxy URL is reachable".to_string(),
155                "Try again later, or provide --token to reduce anonymous request failures".to_string(),
156            ],
157        },
158        (Language::Zh, AppError::Io { path, .. }) => UserFacingError {
159            title,
160            reason: format!("无法写入本地路径 {}", path.display()),
161            suggestions: vec![
162                "检查目标目录是否有写权限".to_string(),
163                "确认磁盘空间充足,且目标文件未被其他程序占用".to_string(),
164            ],
165        },
166        (Language::En, AppError::Io { path, .. }) => UserFacingError {
167            title,
168            reason: format!("Failed to write local path {}", path.display()),
169            suggestions: vec![
170                "Check whether the target directory is writable".to_string(),
171                "Confirm that disk space is available and the file is not locked by another program".to_string(),
172            ],
173        },
174        (Language::Zh, AppError::UnexpectedApiResponse) => UserFacingError {
175            title,
176            reason: "GitHub 返回了无法识别的响应格式".to_string(),
177            suggestions: vec![
178                "稍后重试,或检查仓库路径是否正确".to_string(),
179                "如果问题持续出现,请附上命令和仓库信息进行排查".to_string(),
180            ],
181        },
182        (Language::En, AppError::UnexpectedApiResponse) => UserFacingError {
183            title,
184            reason: "GitHub returned a response format that the CLI could not understand".to_string(),
185            suggestions: vec![
186                "Try again later, or verify that the repository path is correct".to_string(),
187                "If the issue persists, capture the command and repository details for debugging".to_string(),
188            ],
189        },
190        (Language::Zh, AppError::EmptyRepository) => UserFacingError {
191            title,
192            reason: "仓库参数不能为空".to_string(),
193            suggestions: vec!["请按 OWNER/REPO 格式提供仓库参数".to_string()],
194        },
195        (Language::En, AppError::EmptyRepository) => UserFacingError {
196            title,
197            reason: "The repository argument cannot be empty".to_string(),
198            suggestions: vec!["Provide the repository in OWNER/REPO format".to_string()],
199        },
200        (Language::Zh, AppError::EmptyRemotePath) => UserFacingError {
201            title,
202            reason: "远端路径参数不能为空".to_string(),
203            suggestions: vec!["请提供仓库内文件或目录路径".to_string()],
204        },
205        (Language::En, AppError::EmptyRemotePath) => UserFacingError {
206            title,
207            reason: "The remote path argument cannot be empty".to_string(),
208            suggestions: vec!["Provide a file or directory path inside the repository".to_string()],
209        },
210        (Language::Zh, AppError::MissingRepositoryPath) => UserFacingError {
211            title,
212            reason: "GitHub 返回的文件条目缺少仓库路径".to_string(),
213            suggestions: vec!["稍后重试,或检查目标仓库路径是否正常".to_string()],
214        },
215        (Language::En, AppError::MissingRepositoryPath) => UserFacingError {
216            title,
217            reason: "GitHub returned a file entry without its repository path".to_string(),
218            suggestions: vec!["Try again later, or verify that the target repository path is valid".to_string()],
219        },
220        (Language::Zh, AppError::Json(message)) => UserFacingError {
221            title,
222            reason: format!("解析 GitHub 响应失败:{}", message),
223            suggestions: vec!["稍后重试,或检查代理返回内容是否被修改".to_string()],
224        },
225        (Language::En, AppError::Json(message)) => UserFacingError {
226            title,
227            reason: format!("Failed to parse the GitHub response: {}", message),
228            suggestions: vec!["Try again later, or verify that the proxy response was not altered".to_string()],
229        },
230        (Language::Zh, AppError::Config(message)) => UserFacingError {
231            title,
232            reason: format!("配置文件无效:{}", message),
233            suggestions: vec![
234                "检查 --config 指向的文件是否存在且可读".to_string(),
235                "确认配置文件使用 TOML 格式,且只包含 token、api_base、proxy_base、prefix_mode、concurrency、lang".to_string(),
236                "如果不想使用配置文件,请修正该文件或移除 --config".to_string(),
237            ],
238        },
239        (Language::En, AppError::Config(message)) => UserFacingError {
240            title,
241            reason: format!("Configuration file error: {}", message),
242            suggestions: vec![
243                "Check whether the file passed to --config exists and is readable".to_string(),
244                "Confirm that the file uses TOML and only contains token, api_base, proxy_base, prefix_mode, concurrency, and lang".to_string(),
245                "If you do not want to use a config file, fix it or remove --config".to_string(),
246            ],
247        },
248        (Language::Zh, AppError::InvalidPath(message)) => UserFacingError {
249            title,
250            reason: format!("本地路径无效:{}", message),
251            suggestions: vec!["检查本地路径是否存在非法字符,或家目录是否可解析".to_string()],
252        },
253        (Language::En, AppError::InvalidPath(message)) => UserFacingError {
254            title,
255            reason: format!("Invalid local path: {}", message),
256            suggestions: vec![
257                "Check whether the local path contains invalid characters or whether the home directory can be resolved".to_string(),
258            ],
259        },
260        (Language::Zh, AppError::HttpStatus { status, detail, .. }) => UserFacingError {
261            title,
262            reason: detail
263                .clone()
264                .unwrap_or_else(|| format!("GitHub 请求失败(HTTP {})", status)),
265            suggestions: vec![
266                "稍后重试,或检查仓库与路径是否正确".to_string(),
267                "如果问题持续出现,请确认代理和认证配置".to_string(),
268            ],
269        },
270        (Language::En, AppError::HttpStatus { status, detail, .. }) => UserFacingError {
271            title,
272            reason: detail
273                .clone()
274                .unwrap_or_else(|| format!("GitHub request failed (HTTP {})", status)),
275            suggestions: vec![
276                "Try again later, or verify that the repository and path are correct".to_string(),
277                "If the issue persists, check the proxy and authentication settings".to_string(),
278            ],
279        },
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn classify_rate_limit_error_suggests_token() {
289        let error = AppError::HttpStatus {
290            status: 403,
291            url: "https://api.github.com".to_string(),
292            detail: Some("API rate limit exceeded".to_string()),
293        };
294        let user_error = classify_error(&error, false, Language::En);
295        assert!(
296            user_error
297                .suggestions
298                .iter()
299                .any(|item| item.contains("GITHUB_TOKEN"))
300        );
301    }
302}