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, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FailureClass {
Toolchain,
BuildScript,
Source,
Manifest,
Lockfile,
CargoRail,
Interrupted,
Cargo,
}
impl FailureClass {
pub const fn as_str(self) -> &'static str {
match self {
Self::Toolchain => "toolchain",
Self::BuildScript => "build_script",
Self::Source => "source",
Self::Manifest => "manifest",
Self::Lockfile => "lockfile",
Self::CargoRail => "cargo_rail",
Self::Interrupted => "interrupted",
Self::Cargo => "cargo",
}
}
}
#[derive(Debug)]
pub struct ClassifiedFailure {
class: FailureClass,
message: String,
help: String,
detail: Option<String>,
}
impl ClassifiedFailure {
pub const fn class(&self) -> FailureClass {
self.class
}
pub fn detail(&self) -> Option<&str> {
self.detail.as_deref()
}
}
fn joined_help(help: Option<String>, additional: String) -> String {
match help {
Some(help) => format!("{help}\n{additional}"),
None => additional,
}
}
#[derive(Debug)]
pub enum RailError {
Config(ConfigError),
Git(GitError),
Io(io::Error),
External {
message: &'static str,
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
Context {
context: String,
source: Box<RailError>,
},
WithWarnings {
warnings: Vec<String>,
source: Box<RailError>,
},
Message {
message: String,
help: Option<String>,
},
Failure(Box<ClassifiedFailure>),
CheckHasPendingChanges,
ExitWithCode {
code: i32,
},
}
impl RailError {
pub fn message(msg: impl Into<String>) -> Self {
RailError::Message {
message: msg.into(),
help: None,
}
}
pub fn with_help(msg: impl Into<String>, help: impl Into<String>) -> Self {
RailError::Message {
message: msg.into(),
help: Some(help.into()),
}
}
pub fn failure(
class: FailureClass,
message: impl Into<String>,
help: impl Into<String>,
detail: Option<String>,
) -> Self {
RailError::Failure(Box::new(ClassifiedFailure {
class,
message: message.into(),
help: help.into(),
detail: detail.filter(|detail| !detail.trim().is_empty()),
}))
}
pub fn classified(&self) -> Option<&ClassifiedFailure> {
match self {
RailError::Failure(failure) => Some(failure),
RailError::Context { source, .. } | RailError::WithWarnings { source, .. } => source.classified(),
_ => None,
}
}
pub(crate) fn with_additional_help(self, additional: impl Into<String>) -> Self {
let additional = additional.into();
match self {
RailError::Failure(mut failure) => {
failure.help.push('\n');
failure.help.push_str(&additional);
RailError::Failure(failure)
}
RailError::Context { context, source } => RailError::Context {
context,
source: Box::new(source.with_additional_help(additional)),
},
RailError::WithWarnings { warnings, source } => RailError::WithWarnings {
warnings,
source: Box::new(source.with_additional_help(additional)),
},
RailError::Message { message, help } => RailError::Message {
message,
help: Some(joined_help(help, additional)),
},
RailError::CheckHasPendingChanges | RailError::ExitWithCode { .. } => self,
other => {
let help = joined_help(other.help_message(), additional);
RailError::with_help(other.to_string(), help)
}
}
}
pub fn context(self, ctx: impl Into<String>) -> Self {
match self {
RailError::CheckHasPendingChanges | RailError::ExitWithCode { .. } => self,
source => RailError::Context {
context: ctx.into(),
source: Box::new(source),
},
}
}
pub(crate) fn with_warnings(self, warnings: Vec<String>) -> Self {
if warnings.is_empty() || matches!(self, Self::CheckHasPendingChanges | Self::ExitWithCode { .. }) {
self
} else {
Self::WithWarnings {
warnings,
source: Box::new(self),
}
}
}
fn warnings(&self) -> Vec<&str> {
match self {
Self::WithWarnings { warnings, source } => {
warnings.iter().map(String::as_str).chain(source.warnings()).collect()
}
Self::Context { source, .. } => source.warnings(),
_ => Vec::new(),
}
}
fn external(message: &'static str, source: impl std::error::Error + Send + Sync + 'static) -> Self {
Self::External {
message,
source: Box::new(source),
}
}
pub fn exit_code(&self) -> ExitCode {
match self {
RailError::CheckHasPendingChanges => ExitCode::CheckFailed,
RailError::ExitWithCode { code } => ExitCode::Custom(*code),
RailError::Context { source, .. } | RailError::WithWarnings { source, .. } => source.exit_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(),
RailError::Failure(failure) => Some(failure.help.clone()),
RailError::Context { source, .. } | RailError::WithWarnings { source, .. } => source.help_message(),
_ => None,
}
}
pub fn is_broken_pipe(&self) -> bool {
match self {
RailError::Io(error) => error.kind() == io::ErrorKind::BrokenPipe,
RailError::Context { source, .. } | RailError::WithWarnings { source, .. } => source.is_broken_pipe(),
_ => false,
}
}
fn machine_parts(&self) -> (String, Option<String>) {
let mut contexts = Vec::new();
let mut current = self;
loop {
match current {
RailError::Context { context, source } => {
contexts.push(context.as_str());
current = source;
}
RailError::WithWarnings { source, .. } => current = source,
_ => break,
}
}
let message = match current {
RailError::Message { message, .. } => message.clone(),
RailError::Failure(failure) => failure.message.clone(),
_ => current.to_string(),
};
let context = (!contexts.is_empty()).then(|| contexts.join("\n"));
(message, context)
}
}
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::External { message, source } => write!(f, "{}: {}", message, source),
RailError::Context { context, source } => write!(f, "{}\n{}", source, context),
RailError::WithWarnings { source, .. } => write!(f, "{source}"),
RailError::Message { message, .. } => write!(f, "{}", message),
RailError::Failure(failure) => write!(f, "{}", failure.message),
RailError::CheckHasPendingChanges => Ok(()), RailError::ExitWithCode { .. } => Ok(()), }
}
}
impl std::error::Error for RailError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
RailError::Config(e) => Some(e),
RailError::Git(e) => Some(e),
RailError::Io(e) => Some(e),
RailError::External { source, .. } => Some(source.as_ref()),
RailError::Context { source, .. } | RailError::WithWarnings { source, .. } => Some(source.as_ref()),
_ => 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::external("invalid TOML", err)
}
}
impl From<cargo_metadata::Error> for RailError {
fn from(err: cargo_metadata::Error) -> Self {
RailError::external("cargo metadata failed", err)
}
}
impl From<std::num::ParseIntError> for RailError {
fn from(err: std::num::ParseIntError) -> Self {
RailError::external("invalid number", err)
}
}
impl From<toml_edit::de::Error> for RailError {
fn from(err: toml_edit::de::Error) -> Self {
RailError::external("invalid TOML", err)
}
}
impl From<toml_edit::ser::Error> for RailError {
fn from(err: toml_edit::ser::Error) -> Self {
RailError::external("TOML serialization failed", err)
}
}
impl From<serde_json::Error> for RailError {
fn from(err: serde_json::Error) -> Self {
RailError::external("invalid JSON", err)
}
}
impl From<std::str::Utf8Error> for RailError {
fn from(err: std::str::Utf8Error) -> Self {
RailError::external("invalid UTF-8", err)
}
}
impl From<std::string::FromUtf8Error> for RailError {
fn from(err: std::string::FromUtf8Error) -> Self {
RailError::external("invalid UTF-8", err)
}
}
impl From<std::path::StripPrefixError> for RailError {
fn from(err: std::path::StripPrefixError) -> Self {
RailError::external("path error", err)
}
}
impl From<std::env::VarError> for RailError {
fn from(err: std::env::VarError) -> Self {
RailError::external("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!(
"check the '[crates.{name}]' table in rail.toml or run 'cargo rail config validate'"
)),
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)
}
}
}
}
impl std::error::Error for ConfigError {}
#[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("fetch and reconcile the remote branch, then retry".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, "{} failed", command)
} else {
write!(f, "{} 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 {
write!(
f,
"working tree has uncommitted changes:\n{}\n ... and {} more",
files[..5].join("\n"),
count - 5
)
}
}
}
}
}
impl std::error::Error for GitError {}
pub type RailResult<T> = Result<T, RailError>;
pub(crate) fn git_command_diagnostics(stdout: &[u8], stderr: &[u8]) -> String {
let stdout = String::from_utf8_lossy(stdout);
let stderr = String::from_utf8_lossy(stderr);
let stdout = stdout.trim_end();
let stderr = stderr.trim_end();
match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => String::new(),
(false, true) => stdout.to_string(),
(true, false) => stderr.to_string(),
(false, false) => format!("stdout:\n{}\nstderr:\n{}", stdout, stderr),
}
}
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<'a> {
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>,
#[serde(skip_serializing_if = "Option::is_none")]
failure_class: Option<&'static str>,
#[serde(skip_serializing_if = "Vec::is_empty")]
warnings: Vec<&'a str>,
}
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(detail) = error.classified().and_then(ClassifiedFailure::detail) {
eprintln!("{}", detail.trim_end());
}
if let Some(help) = error.help_message() {
crate::help!("{}", help);
}
}
}
fn print_error_json(error: &RailError) {
let (message, context) = error.machine_parts();
let json_error = JsonError {
error: true,
code: error.exit_code().as_i32(),
message,
context,
help: error.help_message(),
failure_class: error.classified().map(|failure| failure.class().as_str()),
warnings: error.warnings(),
};
if let Ok(json) = serde_json::to_string_pretty(&json_error) {
println!("{}", json);
} else {
crate::error!("{}", error);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error as _;
#[test]
fn warning_context_preserves_failure_authority_and_silent_exits() {
let failure = RailError::failure(FailureClass::Source, "compile failed", "fix source", None)
.with_warnings(vec!["tag collision".into()])
.context("release check")
.with_additional_help("retry");
assert_eq!(failure.warnings(), ["tag collision"]);
assert_eq!(
failure.machine_parts(),
("compile failed".into(), Some("release check".into()))
);
assert_eq!(failure.classified().unwrap().class(), FailureClass::Source);
assert_eq!(failure.help_message().as_deref(), Some("fix source\nretry"));
assert_eq!(failure.exit_code().as_i32(), 2);
assert!(matches!(
RailError::CheckHasPendingChanges.with_warnings(vec!["tag collision".into()]),
RailError::CheckHasPendingChanges
));
assert!(matches!(
RailError::ExitWithCode { code: 2 }.with_warnings(vec!["tag collision".into()]),
RailError::ExitWithCode { code: 2 }
));
}
fn rail_source(error: &RailError) -> &RailError {
error
.source()
.and_then(|source| source.downcast_ref::<RailError>())
.expect("context must retain its RailError source")
}
#[test]
fn additional_help_keeps_the_failure_class_through_context() {
let failure = RailError::failure(FailureClass::BuildScript, "cause", "recover", None)
.context("affected views: a, b")
.with_additional_help("resume with 'cargo rail surface --resume'");
assert_eq!(
failure.classified().map(ClassifiedFailure::class),
Some(FailureClass::BuildScript)
);
assert_eq!(
failure.help_message().as_deref(),
Some("recover\nresume with 'cargo rail surface --resume'")
);
assert_eq!(
failure.machine_parts(),
("cause".to_string(), Some("affected views: a, b".to_string()))
);
let message = RailError::message("cause").with_additional_help("resume");
assert_eq!(message.help_message().as_deref(), Some("resume"));
let typed = RailError::Io(io::Error::other("disk")).with_additional_help("resume");
assert_eq!(
(typed.to_string(), typed.help_message()),
("disk".to_string(), Some("resume".to_string()))
);
}
#[test]
fn context_retains_configuration_git_and_filesystem_sources() {
let config = RailError::Config(ConfigError::MissingField {
field: "release.change_dir".to_string(),
})
.context("validating release policy");
let config = rail_source(&config);
assert!(matches!(config, RailError::Config(ConfigError::MissingField { .. })));
assert!(
config
.source()
.is_some_and(|source| source.downcast_ref::<ConfigError>().is_some())
);
let git = RailError::Git(GitError::CommitNotFound {
sha: "deadbeef".to_string(),
})
.context("resolving release base");
let git = rail_source(&git);
assert!(matches!(git, RailError::Git(GitError::CommitNotFound { .. })));
assert!(
git.source()
.is_some_and(|source| source.downcast_ref::<GitError>().is_some())
);
let io = RailError::from(io::Error::new(io::ErrorKind::PermissionDenied, "denied"))
.context("opening recovery journal");
let io = rail_source(&io);
assert!(matches!(io, RailError::Io(_)));
assert!(
io.source()
.is_some_and(|source| source.downcast_ref::<io::Error>().is_some())
);
}
#[test]
fn external_conversions_retain_cargo_and_serialization_sources() {
let cargo = RailError::from(cargo_metadata::Error::NoJson).context("capturing Cargo metadata");
let cargo = rail_source(&cargo);
assert!(matches!(cargo, RailError::External { .. }));
assert!(
cargo
.source()
.is_some_and(|source| source.downcast_ref::<cargo_metadata::Error>().is_some())
);
let serde = serde_json::from_str::<serde_json::Value>("{").expect_err("JSON must be invalid");
let serde = RailError::from(serde).context("reading recovery receipt");
let serde = rail_source(&serde);
assert!(matches!(serde, RailError::External { .. }));
assert!(
serde
.source()
.is_some_and(|source| source.downcast_ref::<serde_json::Error>().is_some())
);
}
#[test]
fn nested_context_survives_text_machine_help_and_exit_boundaries() {
let error = RailError::with_help("recovery failed", "resume with the recorded transaction ID")
.context("restoring manifests")
.context("aborting release transaction");
assert_eq!(
error.to_string(),
"recovery failed\nrestoring manifests\naborting release transaction"
);
assert_eq!(error.exit_code(), ExitCode::Error);
assert_eq!(
error.help_message().as_deref(),
Some("resume with the recorded transaction ID")
);
let (message, context) = error.machine_parts();
assert_eq!(message, "recovery failed");
assert_eq!(
context.as_deref(),
Some("aborting release transaction\nrestoring manifests")
);
}
#[test]
fn context_preserves_control_outcomes_and_broken_pipe_detection() {
assert!(matches!(
RailError::CheckHasPendingChanges.context("ignored"),
RailError::CheckHasPendingChanges
));
assert!(matches!(
RailError::ExitWithCode { code: 42 }.context("ignored"),
RailError::ExitWithCode { code: 42 }
));
let broken_pipe =
RailError::from(io::Error::new(io::ErrorKind::BrokenPipe, "closed")).context("writing report");
assert!(broken_pipe.is_broken_pipe());
}
}