Skip to main content

cargo_rail/
error.rs

1//! Error handling with contextual messages and CI-friendly exit codes.
2//!
3//! Exit code semantics: 0 = success, 1 = check mode found changes, 2 = error.
4//! Errors carry optional help text that guides users toward resolution.
5
6use std::fmt;
7use std::io;
8use std::path::PathBuf;
9
10/// Exit codes for cargo-rail
11///
12/// Exit codes follow CI-friendly semantics:
13/// - 0 = Success (or check passed with no changes needed)
14/// - 1 = Check mode found changes would be made (not an error, but actionable)
15/// - 2 = Error occurred (actual failure)
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ExitCode {
18  /// Success - everything is good
19  Success,
20  /// Check failed - changes would be made (use in --check mode)
21  CheckFailed,
22  /// Error occurred (user or system error)
23  Error,
24  /// Custom exit code (e.g., propagated from subprocess)
25  Custom(i32),
26}
27
28impl ExitCode {
29  /// Convert to i32 for process exit
30  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/// Main error type for cargo-rail
41#[derive(Debug)]
42pub enum RailError {
43  /// Configuration errors
44  Config(ConfigError),
45
46  /// Git operation errors
47  Git(GitError),
48
49  /// I/O errors
50  Io(io::Error),
51
52  /// Generic error with message and optional context
53  Message {
54    /// Error message
55    message: String,
56    /// Additional context about the error
57    context: Option<String>,
58    /// Help text to guide the user
59    help: Option<String>,
60  },
61
62  /// Check mode found pending changes (not an error, but exits with code 1)
63  ///
64  /// Used by --check commands to signal that changes would be made.
65  /// This is not a failure, but CI should treat it as "action needed".
66  CheckHasPendingChanges,
67
68  /// Exit with specific code (no error message printed)
69  ///
70  /// Used for:
71  /// - Propagating subprocess exit codes (e.g., cargo test failures)
72  /// - Silent exits after JSON output has been written
73  /// - Any case where we need a specific exit code without error output
74  ExitWithCode {
75    /// The exit code to use
76    code: i32,
77  },
78}
79
80impl RailError {
81  /// Create a simple error message
82  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  /// Create an error with help text
91  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  /// Add context to an existing error
100  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  /// Get the appropriate exit code for this error
113  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  /// Get contextual help message for this error
122  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(()), // Silent - CI signal
146      RailError::ExitWithCode { .. } => Ok(()),    // Silent - exit code only
147    }
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/// Configuration-related errors
239#[derive(Debug)]
240pub enum ConfigError {
241  /// rail.toml not found
242  NotFound {
243    /// Workspace root where config was searched
244    workspace_root: PathBuf,
245  },
246
247  /// Config file exists but failed to parse
248  ParseError {
249    /// Path to the config file
250    path: PathBuf,
251    /// Parse error message
252    message: String,
253  },
254
255  /// Missing required field
256  MissingField {
257    /// Name of the missing field
258    field: String,
259  },
260
261  /// Crate not found in configuration
262  CrateNotFound {
263    /// Name of the crate that wasn't found
264    name: String,
265  },
266
267  /// Invalid configuration value
268  InvalidValue {
269    /// Field name
270    field: String,
271    /// Error message
272    message: String,
273  },
274
275  /// Invalid field configuration
276  InvalidField {
277    /// Field name
278    field: String,
279    /// Reason why it's invalid
280    reason: String,
281  },
282
283  /// Invalid glob pattern
284  InvalidGlobPattern {
285    /// The invalid pattern
286    pattern: String,
287    /// Error message
288    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/// Git operation errors
344#[derive(Debug)]
345pub enum GitError {
346  /// Git command failed
347  CommandFailed {
348    /// Command that was executed
349    command: String,
350    /// Standard error output
351    stderr: String,
352  },
353
354  /// Repository not found
355  RepoNotFound {
356    /// Path where repository was expected
357    path: PathBuf,
358  },
359
360  /// Commit not found
361  CommitNotFound {
362    /// SHA of the missing commit
363    sha: String,
364  },
365
366  /// Push failed
367  PushFailed {
368    /// Remote name
369    remote: String,
370    /// Branch name
371    branch: String,
372    /// Failure reason
373    reason: String,
374  },
375
376  /// Worktree has uncommitted changes
377  DirtyWorktree {
378    /// List of dirty files
379    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
440/// Result type alias for cargo-rail
441pub type RailResult<T> = Result<T, RailError>;
442
443/// Helper trait to add context to Results
444pub trait ResultExt<T> {
445  /// Add context to an error result
446  fn context(self, ctx: impl Into<String>) -> RailResult<T>;
447
448  /// Add context using a closure (lazy evaluation)
449  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/// Structured JSON error for machine-readable output
471#[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
482/// Print an error to stderr with optional help text
483///
484/// In JSON mode, outputs a structured JSON error object instead of text.
485pub fn print_error(error: &RailError) {
486  // These are not errors to display - they're exit code signals
487  // CheckHasPendingChanges: CI signal for "changes would be made"
488  // ExitWithCode: subprocess errors or silent exits after JSON output
489  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
507/// Print error as structured JSON to stdout
508fn 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  // JSON errors go to stdout for consistent machine parsing
523  // (stderr may have other output mixed in)
524  if let Ok(json) = serde_json::to_string_pretty(&json_error) {
525    println!("{}", json);
526  } else {
527    // Fallback to text if JSON serialization fails
528    crate::error!("{}", error);
529  }
530}