use std::fmt;
use std::io;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitCode {
Success,
CheckFailed,
Error,
Custom(i32),
}
impl ExitCode {
pub fn as_i32(self) -> i32 {
match self {
ExitCode::Success => 0,
ExitCode::CheckFailed => 1,
ExitCode::Error => 2,
ExitCode::Custom(code) => code,
}
}
}
#[derive(Debug)]
pub enum RailError {
Config(ConfigError),
Git(GitError),
Io(io::Error),
Message {
message: String,
context: Option<String>,
help: Option<String>,
},
CheckHasPendingChanges,
ExitWithCode {
code: i32,
},
}
impl RailError {
pub fn message(msg: impl Into<String>) -> Self {
RailError::Message {
message: msg.into(),
context: None,
help: None,
}
}
pub fn with_help(msg: impl Into<String>, help: impl Into<String>) -> Self {
RailError::Message {
message: msg.into(),
context: None,
help: Some(help.into()),
}
}
pub fn context(self, ctx: impl Into<String>) -> Self {
let ctx_str = ctx.into();
match self {
RailError::Message { message, context, help } => RailError::Message {
message,
context: Some(context.map(|c| format!("{}\n{}", ctx_str, c)).unwrap_or(ctx_str)),
help,
},
_ => self,
}
}
pub fn exit_code(&self) -> ExitCode {
match self {
RailError::CheckHasPendingChanges => ExitCode::CheckFailed,
RailError::ExitWithCode { code } => ExitCode::Custom(*code),
_ => ExitCode::Error,
}
}
pub fn help_message(&self) -> Option<String> {
match self {
RailError::Config(e) => e.help_message(),
RailError::Git(e) => e.help_message(),
RailError::Message { help, .. } => help.clone(),
_ => None,
}
}
}
impl fmt::Display for RailError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RailError::Config(e) => write!(f, "{}", e),
RailError::Git(e) => write!(f, "{}", e),
RailError::Io(e) => write!(f, "{}", e),
RailError::Message { message, context, .. } => {
write!(f, "{}", message)?;
if let Some(ctx) = context {
write!(f, "\n{}", ctx)?;
}
Ok(())
}
RailError::CheckHasPendingChanges => Ok(()), RailError::ExitWithCode { .. } => Ok(()), }
}
}
impl std::error::Error for RailError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
RailError::Io(e) => Some(e),
_ => None,
}
}
}
impl From<io::Error> for RailError {
fn from(err: io::Error) -> Self {
RailError::Io(err)
}
}
impl From<String> for RailError {
fn from(msg: String) -> Self {
RailError::message(msg)
}
}
impl From<&str> for RailError {
fn from(msg: &str) -> Self {
RailError::message(msg)
}
}
impl From<toml_edit::TomlError> for RailError {
fn from(err: toml_edit::TomlError) -> Self {
RailError::message(format!("invalid TOML: {}", err))
}
}
impl From<cargo_metadata::Error> for RailError {
fn from(err: cargo_metadata::Error) -> Self {
RailError::message(format!("cargo metadata failed: {}", err))
}
}
impl From<std::num::ParseIntError> for RailError {
fn from(err: std::num::ParseIntError) -> Self {
RailError::message(format!("invalid number: {}", err))
}
}
impl From<toml_edit::de::Error> for RailError {
fn from(err: toml_edit::de::Error) -> Self {
RailError::message(format!("invalid TOML: {}", err))
}
}
impl From<toml_edit::ser::Error> for RailError {
fn from(err: toml_edit::ser::Error) -> Self {
RailError::message(format!("TOML serialization failed: {}", err))
}
}
impl From<serde_json::Error> for RailError {
fn from(err: serde_json::Error) -> Self {
RailError::message(format!("invalid JSON: {}", err))
}
}
impl From<std::str::Utf8Error> for RailError {
fn from(err: std::str::Utf8Error) -> Self {
RailError::message(format!("invalid UTF-8: {}", err))
}
}
impl From<std::string::FromUtf8Error> for RailError {
fn from(err: std::string::FromUtf8Error) -> Self {
RailError::message(format!("invalid UTF-8: {}", err))
}
}
impl From<std::path::StripPrefixError> for RailError {
fn from(err: std::path::StripPrefixError) -> Self {
RailError::message(format!("path error: {}", err))
}
}
impl From<std::env::VarError> for RailError {
fn from(err: std::env::VarError) -> Self {
RailError::message(format!("environment variable error: {}", err))
}
}
#[derive(Debug)]
pub enum ConfigError {
NotFound {
workspace_root: PathBuf,
},
ParseError {
path: PathBuf,
message: String,
},
MissingField {
field: String,
},
CrateNotFound {
name: String,
},
InvalidValue {
field: String,
message: String,
},
InvalidField {
field: String,
reason: String,
},
InvalidGlobPattern {
pattern: String,
message: String,
},
}
impl ConfigError {
fn help_message(&self) -> Option<String> {
match self {
ConfigError::NotFound { .. } => Some("run 'cargo rail init' to create configuration".to_string()),
ConfigError::ParseError { .. } => Some("check the config file syntax and fix the error".to_string()),
ConfigError::CrateNotFound { name } => Some(format!(
"run 'cargo rail split --check' to list configured crates (did you mean '{}'?)",
name
)),
ConfigError::InvalidValue { field, .. } => Some(format!("check the '{}' field in your config file", field)),
ConfigError::InvalidField { field, .. } => Some(format!("check the '{}' field in your config file", field)),
ConfigError::InvalidGlobPattern { pattern, .. } => {
Some(format!("fix or remove the invalid glob pattern: '{}'", pattern))
}
ConfigError::MissingField { field } => Some(format!("add the required '{}' field to your config file", field)),
}
}
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::NotFound { workspace_root } => {
write!(
f,
"no configuration found in {}\n searched: rail.toml, .rail.toml, .cargo/rail.toml, .config/rail.toml",
workspace_root.display()
)
}
ConfigError::ParseError { path, message } => {
write!(f, "failed to parse config file {}: {}", path.display(), message)
}
ConfigError::MissingField { field } => {
write!(f, "missing required field: {}", field)
}
ConfigError::CrateNotFound { name } => {
write!(f, "crate '{}' not found in configuration", name)
}
ConfigError::InvalidValue { field, message } => {
write!(f, "invalid value for '{}': {}", field, message)
}
ConfigError::InvalidField { field, reason } => {
write!(f, "invalid configuration for '{}': {}", field, reason)
}
ConfigError::InvalidGlobPattern { pattern, message } => {
write!(f, "invalid glob pattern '{}': {}", pattern, message)
}
}
}
}
#[derive(Debug)]
pub enum GitError {
CommandFailed {
command: String,
stderr: String,
},
RepoNotFound {
path: PathBuf,
},
CommitNotFound {
sha: String,
},
PushFailed {
remote: String,
branch: String,
reason: String,
},
DirtyWorktree {
files: Vec<String>,
},
}
impl GitError {
fn help_message(&self) -> Option<String> {
match self {
GitError::PushFailed { reason, .. } => {
if reason.contains("non-fast-forward") {
Some("pull first, or use --force".to_string())
} else if reason.contains("permission denied") || reason.contains("403") {
Some("check SSH key and repository permissions".to_string())
} else {
None
}
}
GitError::RepoNotFound { path } => Some(format!("run 'git init {}' or verify the path", path.display())),
GitError::DirtyWorktree { .. } => Some("commit or stash changes, or use --allow-dirty".to_string()),
_ => None,
}
}
}
impl fmt::Display for GitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GitError::CommandFailed { command, stderr } => {
let stderr = stderr.trim();
if stderr.is_empty() {
write!(f, "git {} failed", command)
} else {
write!(f, "git {} failed: {}", command, stderr)
}
}
GitError::RepoNotFound { path } => {
write!(f, "not a git repository: {}", path.display())
}
GitError::CommitNotFound { sha } => {
write!(f, "commit not found: {}", sha)
}
GitError::PushFailed { remote, branch, reason } => {
write!(f, "push to {}/{} failed: {}", remote, branch, reason.trim())
}
GitError::DirtyWorktree { files } => {
let count = files.len();
if count <= 5 {
write!(f, "working tree has uncommitted changes:\n{}", files.join("\n"))
} else {
let shown: Vec<_> = files.iter().take(5).cloned().collect();
write!(
f,
"working tree has uncommitted changes:\n{}\n ... and {} more",
shown.join("\n"),
count - 5
)
}
}
}
}
}
pub type RailResult<T> = Result<T, RailError>;
pub trait ResultExt<T> {
fn context(self, ctx: impl Into<String>) -> RailResult<T>;
fn with_context<F>(self, f: F) -> RailResult<T>
where
F: FnOnce() -> String;
}
impl<T, E> ResultExt<T> for Result<T, E>
where
E: Into<RailError>,
{
fn context(self, ctx: impl Into<String>) -> RailResult<T> {
self.map_err(|e| e.into().context(ctx))
}
fn with_context<F>(self, f: F) -> RailResult<T>
where
F: FnOnce() -> String,
{
self.map_err(|e| e.into().context(f()))
}
}
#[derive(serde::Serialize)]
struct JsonError {
error: bool,
code: i32,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
context: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
help: Option<String>,
}
pub fn print_error(error: &RailError) {
if matches!(
error,
RailError::CheckHasPendingChanges | RailError::ExitWithCode { .. }
) {
return;
}
if crate::output::is_json_mode() {
print_error_json(error);
} else {
crate::error!("{}", error);
if let Some(help) = error.help_message() {
crate::help!("{}", help);
}
}
}
fn print_error_json(error: &RailError) {
let (message, context) = match error {
RailError::Message { message, context, .. } => (message.clone(), context.clone()),
_ => (error.to_string(), None),
};
let json_error = JsonError {
error: true,
code: error.exit_code().as_i32(),
message,
context,
help: error.help_message(),
};
if let Ok(json) = serde_json::to_string_pretty(&json_error) {
println!("{}", json);
} else {
crate::error!("{}", error);
}
}