Skip to main content

alopex_cli/
error.rs

1//! Error types for Alopex CLI
2//!
3//! This module defines CLI-specific error types using thiserror.
4
5use thiserror::Error;
6
7use crate::batch::ExitCode;
8
9/// CLI-specific error type.
10///
11/// This enum represents all possible errors that can occur during CLI operations.
12#[derive(Error, Debug)]
13pub enum CliError {
14    /// An error from the underlying database layer.
15    #[error("Database error: {0}")]
16    Database(#[from] alopex_embedded::Error),
17
18    /// An I/O error occurred.
19    #[error("IO error: {0}")]
20    Io(#[from] std::io::Error),
21
22    /// An invalid argument was provided.
23    #[error("Invalid argument: {0}")]
24    InvalidArgument(String),
25
26    /// A specified profile was not found.
27    #[error("プロファイル '{0}' が見つかりません。alopex profile list で一覧を確認してください。")]
28    #[allow(dead_code)]
29    ProfileNotFound(String),
30
31    /// Conflicting CLI options were provided.
32    #[error(
33        "`--profile` と `--data-dir` は同時に指定できません。どちらか一方を使用してください。"
34    )]
35    #[allow(dead_code)]
36    ConflictingOptions,
37
38    /// A transaction ID is invalid.
39    #[error(
40        "トランザクション ID '{0}' は無効です。alopex kv txn begin で新しいトランザクションを開始してください。"
41    )]
42    #[allow(dead_code)]
43    InvalidTransactionId(String),
44
45    /// A transaction timed out and was rolled back.
46    #[error(
47        "トランザクション '{0}' がタイムアウトしました(60秒)。自動的にロールバックされました。"
48    )]
49    #[allow(dead_code)]
50    TransactionTimeout(String),
51
52    /// A query exceeded its deadline.
53    #[error("Query timeout: {0}. Suggestion: Use --deadline 120s to increase timeout.")]
54    #[allow(dead_code)]
55    Timeout(String),
56
57    /// A query was cancelled by the user.
58    #[error("Query cancelled by user.")]
59    #[allow(dead_code)]
60    Cancelled,
61
62    /// No SQL query was provided.
63    #[error("SQL クエリを指定してください。引数、-f オプション、または標準入力から入力できます。")]
64    #[allow(dead_code)]
65    NoQueryProvided,
66
67    /// An unknown index type was specified.
68    #[error("未知のインデックスタイプ: '{0}'。許可値: minmax, bloom")]
69    #[allow(dead_code)]
70    UnknownIndexType(String),
71
72    /// An unknown compression type was specified.
73    #[error("未知の圧縮形式: '{0}'。許可値: lz4, zstd, none")]
74    #[allow(dead_code)]
75    UnknownCompressionType(String),
76
77    /// File format is incompatible with the CLI version.
78    #[error("ファイルフォーマット v{file} は CLI v{cli} でサポートされていません。CLI をアップグレードしてください。")]
79    #[allow(dead_code)]
80    IncompatibleVersion { cli: String, file: String },
81
82    /// An S3-related error occurred.
83    #[error("S3 error: {0}")]
84    #[allow(dead_code)]
85    S3(String),
86
87    /// Credentials are missing or invalid.
88    #[error("Credentials error: {0}")]
89    Credentials(String),
90
91    /// A parsing error occurred.
92    #[error("Parse error: {0}")]
93    #[allow(dead_code)]
94    Parse(String),
95
96    /// A JSON serialization/deserialization error occurred.
97    #[error("JSON error: {0}")]
98    Json(#[from] serde_json::Error),
99
100    /// A server connection error occurred.
101    #[error("Server connection error: {0}")]
102    #[allow(dead_code)]
103    ServerConnection(String),
104
105    /// A server does not support the requested command.
106    #[error("Server does not support this command: {0}")]
107    #[allow(dead_code)]
108    ServerUnsupported(String),
109
110    /// Classified terminal outcome of a distributed SQL read. Its exit code
111    /// is fixed by the wire/CLI classification rather than by a generic error.
112    #[error("Distributed read {outcome}: {reason}")]
113    DistributedReadOutcome {
114        outcome: String,
115        reason: String,
116        exit_code: ExitCode,
117    },
118
119    /// A cluster management request completed with a classified non-success
120    /// outcome. The response was already written to stdout before this error
121    /// is returned, preserving the operation ID for automation.
122    #[error("Cluster management {outcome}: {reason}")]
123    ClusterManagementOutcome {
124        outcome: String,
125        reason: String,
126        exit_code: ExitCode,
127    },
128}
129
130/// Type alias for CLI results.
131pub type Result<T> = std::result::Result<T, CliError>;
132
133/// Print an error for the top-level command dispatcher.
134///
135/// If `verbose` is true, prints the debug format (with stack trace info).
136/// Otherwise, prints the display format (user-friendly message).
137pub fn handle_error(error: &CliError, verbose: bool) {
138    if verbose {
139        eprintln!("Error: {:?}", error); // Debug format with stack trace
140    } else {
141        eprintln!("Error: {}", error); // Display format
142    }
143}
144
145/// Return the process exit class associated with an error. Most CLI failures
146/// remain the legacy generic error code; a cluster management response can
147/// instead retain its explicit terminal, retryable, pending, or authorization
148/// classification.
149impl CliError {
150    pub fn exit_code(&self) -> ExitCode {
151        match self {
152            Self::ClusterManagementOutcome { exit_code, .. } => *exit_code,
153            Self::DistributedReadOutcome { exit_code, .. } => *exit_code,
154            _ => ExitCode::Error,
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_invalid_argument_error() {
165        let err = CliError::InvalidArgument("test message".to_string());
166        assert_eq!(format!("{}", err), "Invalid argument: test message");
167    }
168
169    #[test]
170    fn test_s3_error() {
171        let err = CliError::S3("bucket not found".to_string());
172        assert_eq!(format!("{}", err), "S3 error: bucket not found");
173    }
174
175    #[test]
176    fn test_credentials_error() {
177        let err = CliError::Credentials("AWS_ACCESS_KEY_ID not set".to_string());
178        assert_eq!(
179            format!("{}", err),
180            "Credentials error: AWS_ACCESS_KEY_ID not set"
181        );
182    }
183
184    #[test]
185    fn cluster_management_error_preserves_its_exit_class() {
186        let err = CliError::ClusterManagementOutcome {
187            outcome: "retryable_failure".to_string(),
188            reason: "not_leader".to_string(),
189            exit_code: ExitCode::Retryable,
190        };
191
192        assert_eq!(err.exit_code(), ExitCode::Retryable);
193    }
194
195    #[test]
196    fn distributed_read_error_preserves_its_exit_class() {
197        let err = CliError::DistributedReadOutcome {
198            outcome: "unsupported".to_string(),
199            reason: "remote DDL is outside the closed catalog".to_string(),
200            exit_code: ExitCode::Unsupported,
201        };
202        assert_eq!(err.exit_code(), ExitCode::Unsupported);
203    }
204
205    #[test]
206    fn test_parse_error() {
207        let err = CliError::Parse("invalid JSON".to_string());
208        assert_eq!(format!("{}", err), "Parse error: invalid JSON");
209    }
210
211    #[test]
212    fn test_io_error_from() {
213        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
214        let err: CliError = io_err.into();
215        assert!(matches!(err, CliError::Io(_)));
216    }
217}