Skip to main content

gcop_rs/
error.rs

1use thiserror::Error;
2
3/// Result type alias, use [`GcopError`] as error type
4pub type Result<T> = std::result::Result<T, GcopError>;
5
6/// A wrapper type for git2::Error that provides more friendly error information
7///
8/// Hide technical details of libgit2 (ErrorClass, ErrorCode, etc.),
9/// Display only user-friendly error messages.
10#[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/// gcop-rs unified error types
32///
33/// Contains all possible error conditions, supporting:
34/// - Internationalized error messages (via [`localized_message()`])
35/// - User-friendly solution suggestions (via [`localized_suggestion()`])
36/// - Automatic conversion from various library errors (implementing `From<T>`)
37///
38/// # Error category
39/// - Git operation errors: [`GitCommand`], [`Git`]
40/// - LLM related errors: [`Llm`], [`LlmApi`]
41/// - Configuration errors: [`Config`], [`ConfigParse`]
42/// - User operations: [`UserCancelled`], [`InvalidInput`]
43/// - Others: [`Io`], [`Network`], [`Other`]
44///
45/// # Example
46/// ```
47/// use gcop_rs::error::{GcopError, Result};
48///
49/// fn example() -> Result<()> {
50///     let err = GcopError::NoStagedChanges;
51///     println!("Error: {}", err.localized_message());
52///     if let Some(suggestion) = err.localized_suggestion() {
53///         println!("Suggestion: {}", suggestion);
54///     }
55///     Err(err)
56/// }
57/// ```
58///
59/// [`GitCommand`]: GcopError::GitCommand
60/// [`Git`]: GcopError::Git
61/// [`Llm`]: GcopError::Llm
62/// [`LlmApi`]: GcopError::LlmApi
63/// [`Config`]: GcopError::Config
64/// [`ConfigParse`]: GcopError::ConfigParse
65/// [`UserCancelled`]: GcopError::UserCancelled
66/// [`InvalidInput`]: GcopError::InvalidInput
67/// [`Io`]: GcopError::Io
68/// [`Network`]: GcopError::Network
69/// [`Other`]: GcopError::Other
70/// [`localized_message()`]: GcopError::localized_message
71/// [`localized_suggestion()`]: GcopError::localized_suggestion
72#[derive(Error, Debug)]
73pub enum GcopError {
74    /// Git2 library error (libgit2)
75    ///
76    /// Contains detailed ErrorCode and ErrorClass.
77    ///
78    /// # Common error codes
79    /// - `NotFound`: file/branch does not exist
80    /// - `Exists`: branch already exists
81    /// - `Uncommitted`: There are uncommitted changes
82    /// - `Conflict`: merge conflict
83    #[error("Git error: {0}")]
84    Git(GitErrorWrapper),
85
86    /// Git command execution failed
87    ///
88    /// Contains the stderr output of the `git` command.
89    ///
90    /// # Common reasons
91    /// - No staged changes: `nothing to commit`
92    /// - pre-commit hook failed
93    /// - merge conflicts
94    #[error("Git command failed: {0}")]
95    GitCommand(String),
96
97    /// Configuration error
98    ///
99    /// Including configuration file errors, environment variable errors, missing API keys, etc.
100    #[error("Configuration error: {0}")]
101    Config(String),
102
103    /// LLM provider error
104    ///
105    /// Generic LLM errors (non-HTTP status code errors).
106    ///
107    /// # Common reasons
108    /// - Response parsing failed
109    /// - No candidates/choices in response
110    #[error("LLM provider error: {0}")]
111    Llm(String),
112
113    /// LLM stream unexpectedly truncated
114    ///
115    /// The streaming response ended without a proper termination signal
116    /// (e.g. no `message_stop` from Claude, no `[DONE]` from OpenAI).
117    #[error("LLM stream truncated ({provider}): {detail}")]
118    LlmStreamTruncated {
119        /// Provider name (e.g. "Claude", "OpenAI")
120        provider: String,
121        /// Description of the truncation
122        detail: String,
123    },
124
125    /// LLM response blocked by content policy
126    ///
127    /// The provider refused to generate a response due to safety filters
128    /// (e.g. Gemini SAFETY or RECITATION finish reason).
129    #[error("LLM content blocked ({provider}): {reason}")]
130    LlmContentBlocked {
131        /// Provider name (e.g. "Gemini")
132        provider: String,
133        /// Reason reported by the provider
134        reason: String,
135    },
136
137    /// LLM request timeout
138    ///
139    /// The HTTP request to the LLM API timed out before receiving a response.
140    #[error("LLM request timeout ({provider}): {detail}")]
141    LlmTimeout {
142        /// Provider name (e.g. "Claude", "OpenAI")
143        provider: String,
144        /// Error detail from the HTTP client
145        detail: String,
146    },
147
148    /// LLM connection failed
149    ///
150    /// Could not establish a connection to the LLM API endpoint.
151    #[error("LLM connection failed ({provider}): {detail}")]
152    LlmConnectionFailed {
153        /// Provider name (e.g. "Claude", "OpenAI")
154        provider: String,
155        /// Error detail from the HTTP client
156        detail: String,
157    },
158
159    /// LLM API HTTP Error
160    ///
161    /// Contains HTTP status codes and error messages.
162    ///
163    /// # Common status codes
164    /// - `401` - API key is invalid or expired
165    /// - `429` - rate limit
166    /// - `500+` - Server error
167    #[error("LLM API error ({status}): {message}")]
168    LlmApi {
169        /// HTTP status code
170        status: u16,
171        /// error message
172        message: String,
173    },
174
175    /// network error
176    ///
177    /// HTTP request failed (timeout, DNS error, connection refused, etc.).
178    #[error("Network error: {0}")]
179    Network(#[from] reqwest::Error),
180
181    /// IO error
182    ///
183    /// File reading and writing failed.
184    #[error("IO error: {0}")]
185    Io(#[from] std::io::Error),
186
187    /// serialization error
188    ///
189    /// JSON serialization/deserialization failed.
190    #[error("Serialization error: {0}")]
191    Serde(#[from] serde_json::Error),
192
193    /// Configuration file parsing error
194    ///
195    /// The TOML file is malformed or the field types do not match.
196    #[error("Configuration parsing error: {0}")]
197    ConfigParse(#[from] config::ConfigError),
198
199    /// UI interaction errors
200    ///
201    /// Terminal interaction failed (user input error, terminal unavailable, etc.).
202    #[error("UI error: {0}")]
203    Inquire(#[from] inquire::InquireError),
204
205    /// No staged changes
206    ///
207    /// The staging area is empty and the commit message cannot be generated.
208    #[error("No staged changes found")]
209    NoStagedChanges,
210
211    /// User cancels operation
212    ///
213    /// The user chooses to exit at the interactive prompt.
214    #[error("Operation cancelled by user")]
215    UserCancelled,
216
217    /// Invalid input
218    ///
219    /// The user-supplied parameter does not conform to the expected format.
220    #[error("Invalid input: {0}")]
221    InvalidInput(String),
222
223    /// Maximum number of retries reached
224    ///
225    /// The number of commit message generation retries exceeds the configured upper limit.
226    #[error("Max retries exceeded after {0} attempts")]
227    MaxRetriesExceeded(usize),
228
229    /// Split commit partially failed.
230    ///
231    /// Some commit groups succeeded while a later group failed.
232    #[error("Split commit partially failed at group {completed}/{total}: {detail}")]
233    SplitCommitPartial {
234        /// Number of groups that committed successfully.
235        completed: usize,
236        /// Total number of groups.
237        total: usize,
238        /// Error detail.
239        detail: String,
240    },
241
242    /// Split response parsing failed.
243    ///
244    /// The LLM response could not be parsed as valid commit groups.
245    #[error("Failed to parse split commit response: {0}")]
246    SplitParseFailed(String),
247
248    /// Common error types
249    ///
250    /// Used for errors that do not fit into other categories.
251    #[error("{0}")]
252    Other(String),
253}
254
255/// Map Git ErrorCode to suggestion key (for deduplication)
256///
257/// # Parameters
258/// - `code`: libgit2 error code
259///
260/// # Returns
261/// - `Some(key)` - suggested i18n key
262/// - `None` - no specific advice (generic error)
263fn 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    /// Get localized error messages
296    ///
297    /// Returns a translated error message based on the current locale.
298    ///
299    /// # Returns
300    /// Localized error message string
301    ///
302    /// # Example
303    /// ```
304    /// use gcop_rs::error::GcopError;
305    ///
306    /// let err = GcopError::NoStagedChanges;
307    /// println!("{}", err.localized_message());
308    /// // Output: No staged changes found (English environment)
309    /// // Output: No staged changes found (Chinese environment)
310    /// ```
311    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    /// Get localized solutions
385    ///
386    /// Returns user-friendly resolution suggestions based on the error type (if any).
387    ///
388    /// # Returns
389    /// - `Some(suggestion)` - solution suggestion string
390    /// - `None` - no specific suggestions
391    ///
392    /// # Suggestion type
393    /// - **NoStagedChanges**: Prompt to run `git add`
394    /// - **Config(API key)**: Prompt to set API key
395    /// - **LlmApi(401)**: Prompt to check API key validity
396    /// - **LlmApi(429)**: Prompt to try again later or upgrade the API plan
397    /// - **Network**: Prompt to check network connection
398    /// - Other errors: may return `None`
399    ///
400    /// # Example
401    /// ```
402    /// use gcop_rs::error::GcopError;
403    ///
404    /// let err = GcopError::NoStagedChanges;
405    /// if let Some(suggestion) = err.localized_suggestion() {
406    ///     println!("Try: {}", suggestion);
407    /// }
408    /// // Output: Try: Run 'git add <files>' to stage your changes first
409    /// ```
410    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    // === NoStagedChanges branch ===
489
490    #[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    // === Config Error: API key branch ===
500
501    #[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    // === Network Error ===
533
534    #[test]
535    fn test_suggestion_network_error() {
536        // reqwest::Error cannot be constructed directly, use real network error or skip
537        // Here we test the behavior when the Network variant is present
538        // Note: Actual reqwest::Error is required. Here is a document describing the test idea.
539
540        // Since reqwest::Error is difficult to construct, we verify the logic of suggestion()
541        // Actual testing requires integration testing or using mocks
542    }
543
544    // === Llm wrong branch ===
545
546    #[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    // === No suggested branches ===
650
651    #[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            // Config/Llm does not match any pattern
659            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}