1use thiserror::Error;
2
3pub type Result<T> = std::result::Result<T, GcopError>;
5
6#[derive(Debug)]
11pub struct GitErrorWrapper(pub git2::Error);
12
13impl std::fmt::Display for GitErrorWrapper {
14 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15 write!(f, "{}", self.0.message())
16 }
17}
18
19impl std::error::Error for GitErrorWrapper {
20 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
21 Some(&self.0)
22 }
23}
24
25impl From<git2::Error> for GcopError {
26 fn from(e: git2::Error) -> Self {
27 GcopError::Git(GitErrorWrapper(e))
28 }
29}
30
31#[derive(Error, Debug)]
73pub enum GcopError {
74 #[error("Git error: {0}")]
84 Git(GitErrorWrapper),
85
86 #[error("Git command failed: {0}")]
95 GitCommand(String),
96
97 #[error("Configuration error: {0}")]
101 Config(String),
102
103 #[error("LLM provider error: {0}")]
111 Llm(String),
112
113 #[error("LLM stream truncated ({provider}): {detail}")]
118 LlmStreamTruncated {
119 provider: String,
121 detail: String,
123 },
124
125 #[error("LLM content blocked ({provider}): {reason}")]
130 LlmContentBlocked {
131 provider: String,
133 reason: String,
135 },
136
137 #[error("LLM request timeout ({provider}): {detail}")]
141 LlmTimeout {
142 provider: String,
144 detail: String,
146 },
147
148 #[error("LLM connection failed ({provider}): {detail}")]
152 LlmConnectionFailed {
153 provider: String,
155 detail: String,
157 },
158
159 #[error("LLM API error ({status}): {message}")]
168 LlmApi {
169 status: u16,
171 message: String,
173 },
174
175 #[error("Network error: {0}")]
179 Network(#[from] reqwest::Error),
180
181 #[error("IO error: {0}")]
185 Io(#[from] std::io::Error),
186
187 #[error("Serialization error: {0}")]
191 Serde(#[from] serde_json::Error),
192
193 #[error("Configuration parsing error: {0}")]
197 ConfigParse(#[from] config::ConfigError),
198
199 #[error("UI error: {0}")]
203 Inquire(#[from] inquire::InquireError),
204
205 #[error("No staged changes found")]
209 NoStagedChanges,
210
211 #[error("Operation cancelled by user")]
215 UserCancelled,
216
217 #[error("Invalid input: {0}")]
221 InvalidInput(String),
222
223 #[error("Max retries exceeded after {0} attempts")]
227 MaxRetriesExceeded(usize),
228
229 #[error("Split commit partially failed at group {completed}/{total}: {detail}")]
233 SplitCommitPartial {
234 completed: usize,
236 total: usize,
238 detail: String,
240 },
241
242 #[error("Failed to parse split commit response: {0}")]
246 SplitParseFailed(String),
247
248 #[error("{0}")]
252 Other(String),
253}
254
255fn git_error_code_to_key(code: git2::ErrorCode) -> Option<&'static str> {
264 use git2::ErrorCode;
265 match code {
266 ErrorCode::GenericError | ErrorCode::BufSize | ErrorCode::User => None,
267 ErrorCode::NotFound => Some("git_not_found"),
268 ErrorCode::Exists => Some("git_exists"),
269 ErrorCode::Ambiguous => Some("git_ambiguous"),
270 ErrorCode::BareRepo => Some("git_bare_repo"),
271 ErrorCode::UnbornBranch => Some("git_unborn_branch"),
272 ErrorCode::Directory => Some("git_directory"),
273 ErrorCode::Owner => Some("git_owner"),
274 ErrorCode::Unmerged => Some("git_unmerged"),
275 ErrorCode::Conflict | ErrorCode::MergeConflict => Some("git_conflict"),
276 ErrorCode::NotFastForward => Some("git_not_fast_forward"),
277 ErrorCode::InvalidSpec => Some("git_invalid_spec"),
278 ErrorCode::Modified => Some("git_modified"),
279 ErrorCode::Uncommitted => Some("git_uncommitted"),
280 ErrorCode::IndexDirty => Some("git_index_dirty"),
281 ErrorCode::Locked => Some("git_locked"),
282 ErrorCode::Auth => Some("git_auth"),
283 ErrorCode::Certificate => Some("git_certificate"),
284 ErrorCode::Applied => Some("git_applied"),
285 ErrorCode::ApplyFail => Some("git_apply_fail"),
286 ErrorCode::Peel => Some("git_peel"),
287 ErrorCode::Eof => Some("git_eof"),
288 ErrorCode::Invalid => Some("git_invalid"),
289 ErrorCode::HashsumMismatch => Some("git_hashsum_mismatch"),
290 ErrorCode::Timeout => Some("git_timeout"),
291 }
292}
293
294impl GcopError {
295 pub fn localized_message(&self) -> String {
312 match self {
313 GcopError::Git(wrapper) => {
314 rust_i18n::t!("error.git", detail = wrapper.to_string()).to_string()
315 }
316 GcopError::GitCommand(msg) => {
317 rust_i18n::t!("error.git_command", detail = msg.as_str()).to_string()
318 }
319 GcopError::Config(msg) => {
320 rust_i18n::t!("error.config", detail = msg.as_str()).to_string()
321 }
322 GcopError::Llm(msg) => rust_i18n::t!("error.llm", detail = msg.as_str()).to_string(),
323 GcopError::LlmStreamTruncated { provider, detail } => rust_i18n::t!(
324 "error.llm_stream_truncated",
325 provider = provider.as_str(),
326 detail = detail.as_str()
327 )
328 .to_string(),
329 GcopError::LlmContentBlocked { provider, reason } => rust_i18n::t!(
330 "error.llm_content_blocked",
331 provider = provider.as_str(),
332 reason = reason.as_str()
333 )
334 .to_string(),
335 GcopError::LlmTimeout { provider, detail } => rust_i18n::t!(
336 "error.llm",
337 detail = format!("{}: {}", provider, detail).as_str()
338 )
339 .to_string(),
340 GcopError::LlmConnectionFailed { provider, detail } => rust_i18n::t!(
341 "error.llm",
342 detail = format!("{}: {}", provider, detail).as_str()
343 )
344 .to_string(),
345 GcopError::LlmApi { status, message } => {
346 rust_i18n::t!("error.llm_api", status = status, message = message.as_str())
347 .to_string()
348 }
349 GcopError::Network(e) => {
350 rust_i18n::t!("error.network", detail = e.to_string()).to_string()
351 }
352 GcopError::Io(e) => rust_i18n::t!("error.io", detail = e.to_string()).to_string(),
353 GcopError::Serde(e) => rust_i18n::t!("error.serde", detail = e.to_string()).to_string(),
354 GcopError::ConfigParse(e) => {
355 rust_i18n::t!("error.config_parse", detail = e.to_string()).to_string()
356 }
357 GcopError::Inquire(e) => rust_i18n::t!("error.ui", detail = e.to_string()).to_string(),
358 GcopError::NoStagedChanges => rust_i18n::t!("error.no_staged_changes").to_string(),
359 GcopError::UserCancelled => rust_i18n::t!("error.user_cancelled").to_string(),
360 GcopError::InvalidInput(msg) => {
361 rust_i18n::t!("error.invalid_input", detail = msg.as_str()).to_string()
362 }
363 GcopError::MaxRetriesExceeded(n) => {
364 rust_i18n::t!("error.max_retries", count = n).to_string()
365 }
366 GcopError::SplitCommitPartial {
367 completed,
368 total,
369 detail,
370 } => rust_i18n::t!(
371 "error.split_partial",
372 completed = completed,
373 total = total,
374 detail = detail.as_str()
375 )
376 .to_string(),
377 GcopError::SplitParseFailed(msg) => {
378 rust_i18n::t!("error.split_parse_failed", detail = msg.as_str()).to_string()
379 }
380 GcopError::Other(msg) => msg.clone(),
381 }
382 }
383
384 pub fn localized_suggestion(&self) -> Option<String> {
411 match self {
412 GcopError::Git(wrapper) => git_error_code_to_key(wrapper.0.code())
413 .map(|key| rust_i18n::t!(format!("suggestion.{}", key)).to_string()),
414 GcopError::NoStagedChanges => {
415 Some(rust_i18n::t!("suggestion.no_staged_changes").to_string())
416 }
417 GcopError::Config(msg)
418 if msg.contains("API key not found")
419 || msg.contains("API key")
420 || msg.contains("api_key")
421 || msg.contains("API key 为空")
422 || (msg.contains("未找到")
423 && (msg.contains("API key") || msg.contains("api_key"))) =>
424 {
425 if msg.contains("Claude") || msg.contains("claude") {
426 Some(rust_i18n::t!("suggestion.claude_api_key").to_string())
427 } else if msg.contains("OpenAI") || msg.contains("openai") {
428 Some(rust_i18n::t!("suggestion.openai_api_key").to_string())
429 } else if msg.contains("Gemini") || msg.contains("gemini") {
430 Some(rust_i18n::t!("suggestion.gemini_api_key").to_string())
431 } else {
432 Some(rust_i18n::t!("suggestion.generic_api_key").to_string())
433 }
434 }
435 GcopError::Config(msg)
436 if msg.contains("not found in config")
437 || msg.contains("未找到 provider")
438 || msg.contains("配置中未找到 provider") =>
439 {
440 Some(rust_i18n::t!("suggestion.provider_not_found").to_string())
441 }
442 GcopError::Network(_) => Some(rust_i18n::t!("suggestion.network").to_string()),
443 GcopError::LlmApi { status: 401, .. } => {
444 Some(rust_i18n::t!("suggestion.llm_401").to_string())
445 }
446 GcopError::LlmApi { status: 429, .. } => {
447 Some(rust_i18n::t!("suggestion.llm_429").to_string())
448 }
449 GcopError::LlmApi { status, .. } if *status >= 500 => {
450 Some(rust_i18n::t!("suggestion.llm_5xx").to_string())
451 }
452 GcopError::LlmTimeout { .. } => {
453 Some(rust_i18n::t!("suggestion.llm_timeout").to_string())
454 }
455 GcopError::LlmConnectionFailed { .. } => {
456 Some(rust_i18n::t!("suggestion.llm_connection").to_string())
457 }
458 GcopError::LlmStreamTruncated { .. } => {
459 Some(rust_i18n::t!("suggestion.llm_stream_truncated").to_string())
460 }
461 GcopError::LlmContentBlocked { .. } => {
462 Some(rust_i18n::t!("suggestion.llm_content_blocked").to_string())
463 }
464 GcopError::Llm(msg)
465 if msg.contains("Failed to parse")
466 || (msg.contains("解析") && msg.contains("响应")) =>
467 {
468 Some(rust_i18n::t!("suggestion.llm_parse").to_string())
469 }
470 GcopError::MaxRetriesExceeded(_) => {
471 Some(rust_i18n::t!("suggestion.max_retries").to_string())
472 }
473 GcopError::SplitCommitPartial { .. } => {
474 Some(rust_i18n::t!("suggestion.split_partial").to_string())
475 }
476 GcopError::SplitParseFailed(_) => {
477 Some(rust_i18n::t!("suggestion.split_parse_failed").to_string())
478 }
479 _ => None,
480 }
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
491 fn test_suggestion_no_staged_changes() {
492 let err = GcopError::NoStagedChanges;
493 assert_eq!(
494 err.localized_suggestion(),
495 Some("Run 'git add <files>' to stage your changes first".to_string())
496 );
497 }
498
499 #[test]
502 fn test_suggestion_config_claude_api_key() {
503 let err = GcopError::Config("API key not found for Claude provider".to_string());
504 let suggestion = err.localized_suggestion().unwrap();
505 assert!(!suggestion.contains("GCOP__"));
506 assert!(suggestion.contains("[llm.providers.claude]"));
507 }
508
509 #[test]
510 fn test_suggestion_config_openai_api_key() {
511 let err = GcopError::Config("API key not found for OpenAI".to_string());
512 let suggestion = err.localized_suggestion().unwrap();
513 assert!(!suggestion.contains("GCOP__"));
514 assert!(suggestion.contains("[llm.providers.openai]"));
515 }
516
517 #[test]
518 fn test_suggestion_config_generic_api_key() {
519 let err = GcopError::Config("API key not found for custom-provider".to_string());
520 let suggestion = err.localized_suggestion().unwrap();
521 assert_eq!(suggestion, "Set api_key in config.toml");
522 }
523
524 #[test]
525 fn test_suggestion_config_provider_not_found() {
526 let err = GcopError::Config("Provider 'unknown' not found in config".to_string());
527 let suggestion = err.localized_suggestion().unwrap();
528 assert!(suggestion.contains("Check your ~/.config/gcop/config.toml"));
529 assert!(suggestion.contains("claude, openai, ollama"));
530 }
531
532 #[test]
535 fn test_suggestion_network_error() {
536 }
543
544 #[test]
547 fn test_suggestion_llm_timeout() {
548 let err = GcopError::LlmTimeout {
549 provider: "OpenAI".to_string(),
550 detail: "read timed out after 30s".to_string(),
551 };
552 let suggestion = err.localized_suggestion().unwrap();
553 assert!(suggestion.contains("timed out"));
554 }
555
556 #[test]
557 fn test_suggestion_llm_connection_failed() {
558 let err = GcopError::LlmConnectionFailed {
559 provider: "Claude".to_string(),
560 detail: "DNS resolution error".to_string(),
561 };
562 let suggestion = err.localized_suggestion().unwrap();
563 assert!(suggestion.contains("endpoint URL"));
564 assert!(suggestion.contains("DNS"));
565 }
566
567 #[test]
568 fn test_suggestion_llm_stream_truncated() {
569 let err = GcopError::LlmStreamTruncated {
570 provider: "Claude".to_string(),
571 detail: "no message_stop received".to_string(),
572 };
573 let suggestion = err.localized_suggestion().unwrap();
574 assert!(
575 suggestion.to_lowercase().contains("truncated")
576 || suggestion.contains("重试")
577 || suggestion.contains("provider")
578 );
579 }
580
581 #[test]
582 fn test_suggestion_llm_content_blocked() {
583 let err = GcopError::LlmContentBlocked {
584 provider: "Gemini".to_string(),
585 reason: "SAFETY".to_string(),
586 };
587 let suggestion = err.localized_suggestion().unwrap();
588 assert!(
589 suggestion.to_lowercase().contains("safety")
590 || suggestion.to_lowercase().contains("blocked")
591 || suggestion.contains("拦截")
592 );
593 }
594
595 #[test]
596 fn test_suggestion_llm_api_401_unauthorized() {
597 let err = GcopError::LlmApi {
598 status: 401,
599 message: "Unauthorized".to_string(),
600 };
601 let suggestion = err.localized_suggestion().unwrap();
602 assert!(suggestion.contains("API key"));
603 assert!(suggestion.contains("expired"));
604 }
605
606 #[test]
607 fn test_suggestion_llm_api_429_rate_limit() {
608 let err = GcopError::LlmApi {
609 status: 429,
610 message: "Too Many Requests".to_string(),
611 };
612 let suggestion = err.localized_suggestion().unwrap();
613 assert!(suggestion.contains("Rate limit"));
614 assert!(suggestion.contains("API plan"));
615 }
616
617 #[test]
618 fn test_suggestion_llm_api_5xx_service_unavailable() {
619 let err_500 = GcopError::LlmApi {
620 status: 500,
621 message: "Internal Server Error".to_string(),
622 };
623 let err_503 = GcopError::LlmApi {
624 status: 503,
625 message: "Service Unavailable".to_string(),
626 };
627
628 let suggestion_500 = err_500.localized_suggestion().unwrap();
629 let suggestion_503 = err_503.localized_suggestion().unwrap();
630
631 assert!(suggestion_500.contains("temporarily unavailable"));
632 assert!(suggestion_503.contains("temporarily unavailable"));
633 }
634
635 #[test]
636 fn test_suggestion_llm_parse_failed() {
637 let err = GcopError::Llm("Failed to parse LLM response as JSON".to_string());
638 let suggestion = err.localized_suggestion().unwrap();
639 assert!(suggestion.contains("--verbose"));
640 }
641
642 #[test]
643 fn test_suggestion_max_retries_exceeded() {
644 let err = GcopError::MaxRetriesExceeded(5);
645 let suggestion = err.localized_suggestion().unwrap();
646 assert!(suggestion.contains("feedback"));
647 }
648
649 #[test]
652 fn test_suggestion_returns_none_for_other_errors() {
653 let cases = vec![
654 GcopError::UserCancelled,
655 GcopError::InvalidInput("bad input".to_string()),
656 GcopError::Other("random error".to_string()),
657 GcopError::GitCommand("git failed".to_string()),
658 GcopError::Config("some random config error".to_string()),
660 GcopError::Llm("some random llm error".to_string()),
661 ];
662
663 for err in cases {
664 assert!(
665 err.localized_suggestion().is_none(),
666 "Expected None for {:?}, got {:?}",
667 err,
668 err.localized_suggestion()
669 );
670 }
671 }
672}