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}