1use thiserror::Error;
8
9#[derive(Debug, Error)]
11pub enum SshCliError {
12 #[error("I/O error: {0}")]
14 Io(#[from] std::io::Error),
15
16 #[error("JSON error: {0}")]
18 Json(#[from] serde_json::Error),
19
20 #[error("TOML read error: {0}")]
22 TomlDe(#[from] toml::de::Error),
23
24 #[error("TOML write error: {0}")]
26 TomlSer(#[from] toml::ser::Error),
27
28 #[error("SSH connection error: {0}")]
30 SshConnection(String),
31
32 #[error("SSH authentication error: {0}")]
34 SshAuthentication(String),
35
36 #[error("SSH connection failed: {0}")]
38 ConnectionFailed(String),
39
40 #[error(
42 "SSH authentication failed; try --password-stdin, --key PATH, --key-passphrase-stdin, or verify the user"
43 )]
44 AuthenticationFailed,
45
46 #[error(
48 "host key changed for {host}:{port}: expected {expected}, got {obtained} (use --replace-host-key if legitimate)"
49 )]
50 HostKeyChanged {
51 host: String,
53 port: u16,
55 expected: String,
57 obtained: String,
59 },
60
61 #[error("command exceeds max_command_chars ({max}): {len} characters")]
63 CommandTooLong {
64 max: usize,
66 len: usize,
68 },
69
70 #[error("sudo/su disabled for this host (disable_sudo)")]
72 SudoDisabled,
73
74 #[error("su_password not configured; use vps edit --su-password or --su-password-stdin")]
76 SuPasswordMissing,
77
78 #[error("SSH channel failed: {0}")]
80 ChannelFailed(String),
81
82 #[error("SSH timeout after {0}ms")]
84 SshTimeout(u64),
85
86 #[error("command failed with exit code {exit_code}: {stderr}")]
88 CommandFailed {
89 exit_code: i32,
91 stderr: String,
93 },
94
95 #[error("VPS '{0}' not found in registry")]
97 VpsNotFound(String),
98
99 #[error("No active VPS. Run 'ssh-cli connect <NAME>' first.")]
101 NoActiveVps,
102
103 #[error("VPS '{0}' already exists in registry")]
105 VpsDuplicate(String),
106
107 #[error("file not found: {0}")]
109 FileNotFound(String),
110
111 #[error("invalid argument: {0}")]
113 InvalidArgument(String),
114
115 #[error("timeout exceeded after {0}ms")]
117 Timeout(u64),
118
119 #[error("configuration directory unavailable")]
121 XdgDirectory,
122
123 #[error("incompatible schema version: expected {expected}, found {found}")]
125 SchemaIncompatible {
126 expected: u32,
128 found: u32,
130 },
131
132 #[error("error: {0}")]
134 Generic(String),
135}
136
137pub mod exit_codes {
139 pub const EX_OK: i32 = 0;
141 pub const EX_GENERAL: i32 = 1;
143 pub const EX_USAGE: i32 = 64;
145 pub const EX_DATAERR: i32 = 65;
147 pub const EX_NOINPUT: i32 = 66;
149 pub const EX_CANTCREAT: i32 = 73;
151 pub const EX_IOERR: i32 = 74;
153 pub const EX_NOPERM: i32 = 77;
155 pub const EX_SIGINT: i32 = 130;
157 pub const EX_SIGTERM: i32 = 143;
159}
160
161impl SshCliError {
162 #[must_use]
164 pub fn exit_code(&self) -> i32 {
165 match self {
166 Self::Io(_) => exit_codes::EX_IOERR,
167 Self::Json(_) => exit_codes::EX_DATAERR,
168 Self::TomlDe(_) => exit_codes::EX_DATAERR,
169 Self::TomlSer(_) => exit_codes::EX_CANTCREAT,
170 Self::SshConnection(_) => exit_codes::EX_IOERR,
171 Self::SshAuthentication(_) => exit_codes::EX_NOPERM,
173 Self::ConnectionFailed(_) => exit_codes::EX_IOERR,
174 Self::AuthenticationFailed => exit_codes::EX_NOPERM,
175 Self::HostKeyChanged { .. } => exit_codes::EX_NOPERM,
176 Self::CommandTooLong { .. } => exit_codes::EX_USAGE,
177 Self::SudoDisabled => exit_codes::EX_NOPERM,
178 Self::SuPasswordMissing => exit_codes::EX_USAGE,
179 Self::ChannelFailed(_) => exit_codes::EX_IOERR,
180 Self::SshTimeout(_) => exit_codes::EX_IOERR,
181 Self::CommandFailed { .. } => exit_codes::EX_GENERAL,
182 Self::VpsNotFound(_) => exit_codes::EX_NOINPUT,
183 Self::NoActiveVps => exit_codes::EX_NOINPUT,
184 Self::VpsDuplicate(_) => exit_codes::EX_USAGE,
185 Self::FileNotFound(_) => exit_codes::EX_NOINPUT,
186 Self::InvalidArgument(_) => exit_codes::EX_USAGE,
187 Self::Timeout(_) => exit_codes::EX_IOERR,
188 Self::XdgDirectory => exit_codes::EX_CANTCREAT,
189 Self::SchemaIncompatible { .. } => exit_codes::EX_DATAERR,
190 Self::Generic(_) => exit_codes::EX_GENERAL,
191 }
192 }
193}
194
195pub type SshCliResult<T> = std::result::Result<T, SshCliError>;
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 #[test]
203 fn vps_not_found_message_contains_name() {
204 let err = SshCliError::VpsNotFound("prod".into());
205 assert!(err.to_string().contains("prod"));
206 }
207
208 #[test]
209 fn vps_duplicate_message_contains_name() {
210 let err = SshCliError::VpsDuplicate("vps-1".into());
211 assert!(err.to_string().contains("already exists"));
212 }
213
214 #[test]
215 fn exit_code_auth_failed_is_noperm() {
216 assert_eq!(
217 SshCliError::AuthenticationFailed.exit_code(),
218 exit_codes::EX_NOPERM
219 );
220 }
221
222 #[test]
223 fn exit_code_command_failed_is_general_not_remote() {
224 let e = SshCliError::CommandFailed {
225 exit_code: 64,
226 stderr: "usage".into(),
227 };
228 assert_eq!(e.exit_code(), exit_codes::EX_GENERAL);
229 }
230}