1use std::fmt;
7use std::io;
8use std::path::PathBuf;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ExitCode {
18 Success,
20 CheckFailed,
22 Error,
24 Custom(i32),
26}
27
28impl ExitCode {
29 pub fn as_i32(self) -> i32 {
31 match self {
32 ExitCode::Success => 0,
33 ExitCode::CheckFailed => 1,
34 ExitCode::Error => 2,
35 ExitCode::Custom(code) => code,
36 }
37 }
38}
39
40#[derive(Debug)]
42pub enum RailError {
43 Config(ConfigError),
45
46 Git(GitError),
48
49 Io(io::Error),
51
52 Message {
54 message: String,
56 context: Option<String>,
58 help: Option<String>,
60 },
61
62 CheckHasPendingChanges,
67
68 ExitWithCode {
75 code: i32,
77 },
78}
79
80impl RailError {
81 pub fn message(msg: impl Into<String>) -> Self {
83 RailError::Message {
84 message: msg.into(),
85 context: None,
86 help: None,
87 }
88 }
89
90 pub fn with_help(msg: impl Into<String>, help: impl Into<String>) -> Self {
92 RailError::Message {
93 message: msg.into(),
94 context: None,
95 help: Some(help.into()),
96 }
97 }
98
99 pub fn context(self, ctx: impl Into<String>) -> Self {
101 let ctx_str = ctx.into();
102 match self {
103 RailError::Message { message, context, help } => RailError::Message {
104 message,
105 context: Some(context.map(|c| format!("{}\n{}", ctx_str, c)).unwrap_or(ctx_str)),
106 help,
107 },
108 _ => self,
109 }
110 }
111
112 pub fn exit_code(&self) -> ExitCode {
114 match self {
115 RailError::CheckHasPendingChanges => ExitCode::CheckFailed,
116 RailError::ExitWithCode { code } => ExitCode::Custom(*code),
117 _ => ExitCode::Error,
118 }
119 }
120
121 pub fn help_message(&self) -> Option<String> {
123 match self {
124 RailError::Config(e) => e.help_message(),
125 RailError::Git(e) => e.help_message(),
126 RailError::Message { help, .. } => help.clone(),
127 _ => None,
128 }
129 }
130}
131
132impl fmt::Display for RailError {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 match self {
135 RailError::Config(e) => write!(f, "{}", e),
136 RailError::Git(e) => write!(f, "{}", e),
137 RailError::Io(e) => write!(f, "{}", e),
138 RailError::Message { message, context, .. } => {
139 write!(f, "{}", message)?;
140 if let Some(ctx) = context {
141 write!(f, "\n{}", ctx)?;
142 }
143 Ok(())
144 }
145 RailError::CheckHasPendingChanges => Ok(()), RailError::ExitWithCode { .. } => Ok(()), }
148 }
149}
150
151impl std::error::Error for RailError {
152 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
153 match self {
154 RailError::Io(e) => Some(e),
155 _ => None,
156 }
157 }
158}
159
160impl From<io::Error> for RailError {
161 fn from(err: io::Error) -> Self {
162 RailError::Io(err)
163 }
164}
165
166impl From<String> for RailError {
167 fn from(msg: String) -> Self {
168 RailError::message(msg)
169 }
170}
171
172impl From<&str> for RailError {
173 fn from(msg: &str) -> Self {
174 RailError::message(msg)
175 }
176}
177
178impl From<toml_edit::TomlError> for RailError {
179 fn from(err: toml_edit::TomlError) -> Self {
180 RailError::message(format!("invalid TOML: {}", err))
181 }
182}
183
184impl From<cargo_metadata::Error> for RailError {
185 fn from(err: cargo_metadata::Error) -> Self {
186 RailError::message(format!("cargo metadata failed: {}", err))
187 }
188}
189
190impl From<std::num::ParseIntError> for RailError {
191 fn from(err: std::num::ParseIntError) -> Self {
192 RailError::message(format!("invalid number: {}", err))
193 }
194}
195
196impl From<toml_edit::de::Error> for RailError {
197 fn from(err: toml_edit::de::Error) -> Self {
198 RailError::message(format!("invalid TOML: {}", err))
199 }
200}
201
202impl From<toml_edit::ser::Error> for RailError {
203 fn from(err: toml_edit::ser::Error) -> Self {
204 RailError::message(format!("TOML serialization failed: {}", err))
205 }
206}
207
208impl From<serde_json::Error> for RailError {
209 fn from(err: serde_json::Error) -> Self {
210 RailError::message(format!("invalid JSON: {}", err))
211 }
212}
213
214impl From<std::str::Utf8Error> for RailError {
215 fn from(err: std::str::Utf8Error) -> Self {
216 RailError::message(format!("invalid UTF-8: {}", err))
217 }
218}
219
220impl From<std::string::FromUtf8Error> for RailError {
221 fn from(err: std::string::FromUtf8Error) -> Self {
222 RailError::message(format!("invalid UTF-8: {}", err))
223 }
224}
225
226impl From<std::path::StripPrefixError> for RailError {
227 fn from(err: std::path::StripPrefixError) -> Self {
228 RailError::message(format!("path error: {}", err))
229 }
230}
231
232impl From<std::env::VarError> for RailError {
233 fn from(err: std::env::VarError) -> Self {
234 RailError::message(format!("environment variable error: {}", err))
235 }
236}
237
238#[derive(Debug)]
240pub enum ConfigError {
241 NotFound {
243 workspace_root: PathBuf,
245 },
246
247 ParseError {
249 path: PathBuf,
251 message: String,
253 },
254
255 MissingField {
257 field: String,
259 },
260
261 CrateNotFound {
263 name: String,
265 },
266
267 InvalidValue {
269 field: String,
271 message: String,
273 },
274
275 InvalidField {
277 field: String,
279 reason: String,
281 },
282
283 InvalidGlobPattern {
285 pattern: String,
287 message: String,
289 },
290}
291
292impl ConfigError {
293 fn help_message(&self) -> Option<String> {
294 match self {
295 ConfigError::NotFound { .. } => Some("run 'cargo rail init' to create configuration".to_string()),
296 ConfigError::ParseError { .. } => Some("check the config file syntax and fix the error".to_string()),
297 ConfigError::CrateNotFound { name } => Some(format!(
298 "run 'cargo rail split --check' to list configured crates (did you mean '{}'?)",
299 name
300 )),
301 ConfigError::InvalidValue { field, .. } => Some(format!("check the '{}' field in your config file", field)),
302 ConfigError::InvalidField { field, .. } => Some(format!("check the '{}' field in your config file", field)),
303 ConfigError::InvalidGlobPattern { pattern, .. } => {
304 Some(format!("fix or remove the invalid glob pattern: '{}'", pattern))
305 }
306 ConfigError::MissingField { field } => Some(format!("add the required '{}' field to your config file", field)),
307 }
308 }
309}
310
311impl fmt::Display for ConfigError {
312 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313 match self {
314 ConfigError::NotFound { workspace_root } => {
315 write!(
316 f,
317 "no configuration found in {}\n searched: rail.toml, .rail.toml, .cargo/rail.toml, .config/rail.toml",
318 workspace_root.display()
319 )
320 }
321 ConfigError::ParseError { path, message } => {
322 write!(f, "failed to parse config file {}: {}", path.display(), message)
323 }
324 ConfigError::MissingField { field } => {
325 write!(f, "missing required field: {}", field)
326 }
327 ConfigError::CrateNotFound { name } => {
328 write!(f, "crate '{}' not found in configuration", name)
329 }
330 ConfigError::InvalidValue { field, message } => {
331 write!(f, "invalid value for '{}': {}", field, message)
332 }
333 ConfigError::InvalidField { field, reason } => {
334 write!(f, "invalid configuration for '{}': {}", field, reason)
335 }
336 ConfigError::InvalidGlobPattern { pattern, message } => {
337 write!(f, "invalid glob pattern '{}': {}", pattern, message)
338 }
339 }
340 }
341}
342
343#[derive(Debug)]
345pub enum GitError {
346 CommandFailed {
348 command: String,
350 stderr: String,
352 },
353
354 RepoNotFound {
356 path: PathBuf,
358 },
359
360 CommitNotFound {
362 sha: String,
364 },
365
366 PushFailed {
368 remote: String,
370 branch: String,
372 reason: String,
374 },
375
376 DirtyWorktree {
378 files: Vec<String>,
380 },
381}
382
383impl GitError {
384 fn help_message(&self) -> Option<String> {
385 match self {
386 GitError::PushFailed { reason, .. } => {
387 if reason.contains("non-fast-forward") {
388 Some("pull first, or use --force".to_string())
389 } else if reason.contains("permission denied") || reason.contains("403") {
390 Some("check SSH key and repository permissions".to_string())
391 } else {
392 None
393 }
394 }
395 GitError::RepoNotFound { path } => Some(format!("run 'git init {}' or verify the path", path.display())),
396 GitError::DirtyWorktree { .. } => Some("commit or stash changes, or use --allow-dirty".to_string()),
397 _ => None,
398 }
399 }
400}
401
402impl fmt::Display for GitError {
403 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
404 match self {
405 GitError::CommandFailed { command, stderr } => {
406 let stderr = stderr.trim();
407 if stderr.is_empty() {
408 write!(f, "git {} failed", command)
409 } else {
410 write!(f, "git {} failed: {}", command, stderr)
411 }
412 }
413 GitError::RepoNotFound { path } => {
414 write!(f, "not a git repository: {}", path.display())
415 }
416 GitError::CommitNotFound { sha } => {
417 write!(f, "commit not found: {}", sha)
418 }
419 GitError::PushFailed { remote, branch, reason } => {
420 write!(f, "push to {}/{} failed: {}", remote, branch, reason.trim())
421 }
422 GitError::DirtyWorktree { files } => {
423 let count = files.len();
424 if count <= 5 {
425 write!(f, "working tree has uncommitted changes:\n{}", files.join("\n"))
426 } else {
427 let shown: Vec<_> = files.iter().take(5).cloned().collect();
428 write!(
429 f,
430 "working tree has uncommitted changes:\n{}\n ... and {} more",
431 shown.join("\n"),
432 count - 5
433 )
434 }
435 }
436 }
437 }
438}
439
440pub type RailResult<T> = Result<T, RailError>;
442
443pub trait ResultExt<T> {
445 fn context(self, ctx: impl Into<String>) -> RailResult<T>;
447
448 fn with_context<F>(self, f: F) -> RailResult<T>
450 where
451 F: FnOnce() -> String;
452}
453
454impl<T, E> ResultExt<T> for Result<T, E>
455where
456 E: Into<RailError>,
457{
458 fn context(self, ctx: impl Into<String>) -> RailResult<T> {
459 self.map_err(|e| e.into().context(ctx))
460 }
461
462 fn with_context<F>(self, f: F) -> RailResult<T>
463 where
464 F: FnOnce() -> String,
465 {
466 self.map_err(|e| e.into().context(f()))
467 }
468}
469
470#[derive(serde::Serialize)]
472struct JsonError {
473 error: bool,
474 code: i32,
475 message: String,
476 #[serde(skip_serializing_if = "Option::is_none")]
477 context: Option<String>,
478 #[serde(skip_serializing_if = "Option::is_none")]
479 help: Option<String>,
480}
481
482pub fn print_error(error: &RailError) {
486 if matches!(
490 error,
491 RailError::CheckHasPendingChanges | RailError::ExitWithCode { .. }
492 ) {
493 return;
494 }
495
496 if crate::output::is_json_mode() {
497 print_error_json(error);
498 } else {
499 crate::error!("{}", error);
500
501 if let Some(help) = error.help_message() {
502 crate::help!("{}", help);
503 }
504 }
505}
506
507fn print_error_json(error: &RailError) {
509 let (message, context) = match error {
510 RailError::Message { message, context, .. } => (message.clone(), context.clone()),
511 _ => (error.to_string(), None),
512 };
513
514 let json_error = JsonError {
515 error: true,
516 code: error.exit_code().as_i32(),
517 message,
518 context,
519 help: error.help_message(),
520 };
521
522 if let Ok(json) = serde_json::to_string_pretty(&json_error) {
525 println!("{}", json);
526 } else {
527 crate::error!("{}", error);
529 }
530}