use serde_json::{Value, json};
use std::collections::BTreeMap;
use thiserror::Error;
pub type AppResult<T> = Result<T, AppError>;
#[derive(Debug, Error)]
#[error("{message}")]
pub struct AppError {
pub code: &'static str,
pub message: String,
pub details: Value,
pub retryable: bool,
pub suggested_fix: String,
pub exit_code: i32,
}
pub struct ErrorContract {
pub code: &'static str,
pub exit_code: i32,
pub description: &'static str,
}
pub const EXIT_65_DESCRIPTION: &str =
"invalid input data, including an ambiguous ID or an unsupported log version";
pub const ERROR_CONTRACT: &[ErrorContract] = &[
ErrorContract {
code: "invalid_argument",
exit_code: 2,
description: "invalid arguments",
},
ErrorContract {
code: "invalid_input",
exit_code: 65,
description: EXIT_65_DESCRIPTION,
},
ErrorContract {
code: "not_found",
exit_code: 66,
description: "missing explicit file or unknown ID",
},
ErrorContract {
code: "ambiguous_id",
exit_code: 65,
description: EXIT_65_DESCRIPTION,
},
ErrorContract {
code: "unsupported_log_version",
exit_code: 65,
description: EXIT_65_DESCRIPTION,
},
ErrorContract {
code: "io_error",
exit_code: 74,
description: "I/O error",
},
ErrorContract {
code: "permission_denied",
exit_code: 77,
description: "permission denied",
},
ErrorContract {
code: "lock_timeout",
exit_code: 75,
description: "lock timeout; retryable",
},
ErrorContract {
code: "config_error",
exit_code: 78,
description: "configuration error",
},
ErrorContract {
code: "internal",
exit_code: 70,
description: "internal error",
},
];
pub fn unsupported_log_version_message(line: usize, found_version: Option<&Value>) -> String {
let found = match found_version {
Some(value) => format!("found v {value}"),
None => "record has no v field".to_owned(),
};
format!("unsupported log version on line {line}: {found}")
}
pub fn exit_code_for(code: &str) -> i32 {
ERROR_CONTRACT
.iter()
.find(|entry| entry.code == code)
.map_or(70, |entry| entry.exit_code)
}
pub fn error_codes() -> Vec<&'static str> {
ERROR_CONTRACT.iter().map(|entry| entry.code).collect()
}
pub fn exit_code_map() -> BTreeMap<i32, &'static str> {
let mut map = BTreeMap::new();
map.insert(0, "success or empty result");
for entry in ERROR_CONTRACT {
map.insert(entry.exit_code, entry.description);
}
map.insert(
1,
"command findings: doctor unhealthy, triage clusters, verify recurrences, or retrospect candidates",
);
map
}
impl AppError {
pub fn invalid_argument(message: impl Into<String>, fix: impl Into<String>) -> Self {
Self::new("invalid_argument", message, false, fix)
}
pub fn invalid_input(message: impl Into<String>, fix: impl Into<String>) -> Self {
Self::new("invalid_input", message, false, fix)
}
pub fn not_found(message: impl Into<String>, fix: impl Into<String>) -> Self {
Self::new("not_found", message, false, fix)
}
pub fn ambiguous_id(prefix: &str, candidates: Vec<String>) -> Self {
let mut error = Self::new(
"ambiguous_id",
format!("ID prefix '{prefix}' matches multiple records"),
false,
"Use one of the full IDs listed in error.details.candidates.",
);
error.details = json!({ "candidates": candidates });
error
}
pub fn unsupported_log_version(
path: &std::path::Path,
line: usize,
found_version: Option<&Value>,
) -> Self {
let mut error = Self::new(
"unsupported_log_version",
unsupported_log_version_message(line, found_version),
false,
format!(
"Rename {} to a path that does not yet exist, then run `blotter add` to create a fresh v2 log.",
path.display()
),
);
error.details = json!({ "file": path.to_string_lossy(), "line": line });
if let Some(value) = found_version {
error.details["found_version"] = value.clone();
}
error
}
pub fn config(message: impl Into<String>, fix: impl Into<String>) -> Self {
Self::new("config_error", message, false, fix)
}
pub fn lock_timeout(path: &std::path::Path) -> Self {
Self::new(
"lock_timeout",
format!(
"timed out waiting for the blotter file lock: {}",
path.display()
),
true,
"Retry the same command after the other blotter process finishes.",
)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(
"internal",
message,
false,
"Run `blotter doctor`; if the problem persists, report the command and blotter version.",
)
}
pub fn from_io(error: std::io::Error, path: &std::path::Path) -> Self {
match error.kind() {
std::io::ErrorKind::PermissionDenied => Self::new(
"permission_denied",
format!("permission denied for {}: {error}", path.display()),
false,
"Choose a writable path with --file or correct the file permissions.",
),
_ => Self::new(
"io_error",
format!("I/O error for {}: {error}", path.display()),
false,
"Check that the path exists and its filesystem is available, then retry.",
),
}
}
pub fn from_log_open(error: std::io::Error, path: &std::path::Path) -> Self {
match error.kind() {
std::io::ErrorKind::NotFound => Self::new(
"not_found",
format!("blotter file not found: {}", path.display()),
false,
"Run `blotter add` to create the file or pass an existing --file PATH.",
),
std::io::ErrorKind::IsADirectory => Self::invalid_input(
format!("blotter file is not a regular file: {}", path.display()),
"Point --file PATH or BLOTTER_FILE at a regular JSONL file; FIFOs and devices are not accepted.",
),
_ => Self::from_io(error, path),
}
}
pub fn from_registry_file(error: std::io::Error, path: &std::path::Path) -> Self {
match error.kind() {
std::io::ErrorKind::NotFound => Self::new(
"not_found",
format!("sweep registry file not found: {}", path.display()),
false,
"Pass an existing registry file to --registry PATH.",
),
std::io::ErrorKind::InvalidData => Self::invalid_input(
format!("sweep registry file is not valid UTF-8: {}", path.display()),
"Save the registry as UTF-8 text with one path per line, then retry.",
),
std::io::ErrorKind::IsADirectory => Self::invalid_input(
format!(
"sweep registry file is not a regular file: {}",
path.display()
),
"Pass a UTF-8 text file with one repository path per line to --registry PATH.",
),
_ => Self::from_io(error, path),
}
}
pub fn from_evidence_file(error: std::io::Error, path: &std::path::Path) -> Self {
match error.kind() {
std::io::ErrorKind::NotFound => Self::new(
"not_found",
format!("stderr evidence file not found: {}", path.display()),
false,
"Pass an existing regular UTF-8 file to --stderr-file PATH.",
),
std::io::ErrorKind::PermissionDenied => Self::new(
"permission_denied",
format!(
"permission denied reading stderr evidence file {}: {error}",
path.display()
),
false,
"Grant read permission to the stderr evidence file or pass a readable --stderr-file PATH.",
),
_ => Self::new(
"io_error",
format!(
"I/O error reading stderr evidence file {}: {error}",
path.display()
),
false,
"Check that --stderr-file PATH is a readable regular file, then retry.",
),
}
}
pub fn stale_backup(path: &std::path::Path) -> Self {
Self::new(
"io_error",
format!("backup path already exists: {}", path.display()),
false,
format!(
"Remove or rename the leftover backup {}; it is from an aborted repair, not a completed one, then retry.",
path.display()
),
)
}
fn new(
code: &'static str,
message: impl Into<String>,
retryable: bool,
suggested_fix: impl Into<String>,
) -> Self {
Self {
code,
message: message.into(),
details: json!({}),
retryable,
suggested_fix: suggested_fix.into(),
exit_code: exit_code_for(code),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::ErrorKind;
#[test]
fn io_not_found_maps_to_io_error_74() {
let error = std::io::Error::new(ErrorKind::NotFound, "missing");
let err = AppError::from_io(error, std::path::Path::new("/tmp/x"));
assert_eq!(err.code, "io_error");
assert_eq!(err.exit_code, 74);
}
#[test]
fn log_open_not_found_maps_to_not_found_66() {
let error = std::io::Error::new(ErrorKind::NotFound, "missing");
let err = AppError::from_log_open(error, std::path::Path::new("/tmp/x"));
assert_eq!(err.code, "not_found");
assert_eq!(err.exit_code, 66);
}
#[test]
fn log_open_directory_maps_to_invalid_input_65() {
let error = std::io::Error::new(ErrorKind::IsADirectory, "is a directory");
let err = AppError::from_log_open(error, std::path::Path::new("/tmp/log-dir"));
assert_eq!(err.code, "invalid_input");
assert_eq!(err.exit_code, 65);
assert!(err.message.contains("not a regular file"));
}
#[test]
fn registry_not_found_maps_to_not_found_66() {
let error = std::io::Error::new(ErrorKind::NotFound, "missing");
let err = AppError::from_registry_file(error, std::path::Path::new("/tmp/x"));
assert_eq!(err.code, "not_found");
assert_eq!(err.exit_code, 66);
}
#[test]
fn registry_permission_denied_maps_to_permission_denied_77() {
let error = std::io::Error::new(ErrorKind::PermissionDenied, "denied");
let err = AppError::from_registry_file(error, std::path::Path::new("/tmp/x"));
assert_eq!(err.code, "permission_denied");
assert_eq!(err.exit_code, 77);
}
#[test]
fn registry_invalid_data_maps_to_invalid_input_65() {
let error =
std::io::Error::new(ErrorKind::InvalidData, "stream did not contain valid UTF-8");
let err = AppError::from_registry_file(error, std::path::Path::new("/tmp/repos.txt"));
assert_eq!(err.code, "invalid_input");
assert_eq!(err.exit_code, 65);
assert!(err.message.contains("not valid UTF-8"));
}
#[test]
fn registry_directory_maps_to_invalid_input_65() {
let error = std::io::Error::new(ErrorKind::IsADirectory, "is a directory");
let err = AppError::from_registry_file(error, std::path::Path::new("/tmp/repos"));
assert_eq!(err.code, "invalid_input");
assert_eq!(err.exit_code, 65);
assert!(err.message.contains("not a regular file"));
}
}