use serde::{Deserialize, Serialize};
pub const ERROR_TYPE_BASE_URI: &str =
"https://modeled-information-format.github.io/mif-rs/references/errors";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Applicability {
MachineApplicable,
MaybeIncorrect,
HasPlaceholders,
#[default]
Unspecified,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SuggestedFix {
pub description: String,
pub applicability: Applicability,
}
impl SuggestedFix {
#[must_use]
pub fn new(description: impl Into<String>, applicability: Applicability) -> Self {
Self {
description: description.into(),
applicability,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CodeAction {
pub title: String,
pub kind: String,
pub applicability: Applicability,
}
impl CodeAction {
#[must_use]
pub fn new(
title: impl Into<String>,
kind: impl Into<String>,
applicability: Applicability,
) -> Self {
Self {
title: title.into(),
kind: kind.into(),
applicability,
}
}
}
#[must_use]
pub fn classify_io_error(error: &std::io::Error) -> (u16, SuggestedFix, CodeAction) {
match error.kind() {
std::io::ErrorKind::NotFound => (
404,
SuggestedFix::new(
"Verify the path exists, then retry.",
Applicability::MaybeIncorrect,
),
CodeAction::new(
"Correct the file path",
"quickfix",
Applicability::MaybeIncorrect,
),
),
std::io::ErrorKind::PermissionDenied => (
403,
SuggestedFix::new(
"Verify the path is readable (check file/directory permissions), then retry.",
Applicability::MaybeIncorrect,
),
CodeAction::new(
"Correct the file permissions or path",
"quickfix",
Applicability::MaybeIncorrect,
),
),
_ => (
500,
SuggestedFix::new(
"This indicates an I/O problem, not a mistaken path. Check disk and \
permissions state and retry.",
Applicability::Unspecified,
),
CodeAction::new(
"Retry the operation",
"quickfix",
Applicability::Unspecified,
),
),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ProblemDetails {
#[serde(rename = "type")]
pub problem_type: String,
pub title: String,
pub status: u16,
pub detail: String,
pub instance: String,
pub retry_after: Option<u64>,
pub suggested_fix: Option<SuggestedFix>,
pub code_actions: Vec<CodeAction>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<u8>,
}
impl ProblemDetails {
#[must_use]
pub fn new(
problem_type: impl Into<String>,
title: impl Into<String>,
status: u16,
detail: impl Into<String>,
instance: impl Into<String>,
) -> Self {
Self {
problem_type: problem_type.into(),
title: title.into(),
status,
detail: detail.into(),
instance: instance.into(),
retry_after: None,
suggested_fix: None,
code_actions: Vec::new(),
exit_code: None,
}
}
#[must_use]
pub const fn with_retry_after(mut self, seconds: u64) -> Self {
self.retry_after = Some(seconds);
self
}
#[must_use]
pub fn with_suggested_fix(mut self, fix: SuggestedFix) -> Self {
self.suggested_fix = Some(fix);
self
}
#[must_use]
pub fn with_code_action(mut self, action: CodeAction) -> Self {
self.code_actions.push(action);
self
}
#[must_use]
pub const fn with_exit_code(mut self, code: u8) -> Self {
self.exit_code = Some(code);
self
}
#[must_use]
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| String::from("{}"))
}
#[must_use]
pub fn to_json_pretty(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_else(|_| String::from("{}"))
}
}
#[derive(Debug, Clone, Copy)]
pub struct ProblemMeta {
pub slug: &'static str,
pub version: &'static str,
pub title: &'static str,
pub status: u16,
pub exit_code: u8,
}
impl ProblemMeta {
#[must_use]
pub fn type_uri(&self) -> String {
format!("{ERROR_TYPE_BASE_URI}/{}/{}", self.slug, self.version)
}
#[must_use]
pub fn into_details(self, crate_name: &str, detail: impl Into<String>) -> ProblemDetails {
ProblemDetails::new(
self.type_uri(),
self.title,
self.status,
detail,
format!("urn:{crate_name}:{}", self.slug),
)
.with_exit_code(self.exit_code)
}
}
pub trait ToProblem: std::fmt::Display {
fn to_problem(&self) -> ProblemDetails;
fn render(&self, format: OutputFormat) -> String {
match format {
OutputFormat::Pretty => format!("Error: {self}"),
OutputFormat::Json => self.to_problem().to_json(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum OutputFormat {
Pretty,
Json,
}
impl OutputFormat {
#[must_use]
pub fn select(explicit: Option<&str>, is_terminal: bool) -> Self {
match explicit {
Some("json") => Self::Json,
Some("pretty") => Self::Pretty,
_ if is_terminal => Self::Pretty,
_ => Self::Json,
}
}
}
#[cfg(test)]
mod tests {
use super::{
Applicability, CodeAction, OutputFormat, ProblemDetails, ProblemMeta, SuggestedFix,
ToProblem, classify_io_error,
};
#[derive(Debug, thiserror::Error)]
enum TestError {
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("operation failed")]
OperationFailed,
}
impl TestError {
const fn meta(&self) -> ProblemMeta {
match self {
Self::InvalidInput(_) => ProblemMeta {
slug: "invalid-input",
version: "v1",
title: "Invalid input",
status: 400,
exit_code: 2,
},
Self::OperationFailed => ProblemMeta {
slug: "operation-failed",
version: "v1",
title: "Operation failed",
status: 500,
exit_code: 1,
},
}
}
}
impl ToProblem for TestError {
fn to_problem(&self) -> ProblemDetails {
self.meta()
.into_details("mif-problem-tests", self.to_string())
}
}
#[test]
fn applicability_serializes_snake_case() {
let json = serde_json::to_string(&Applicability::MachineApplicable).unwrap();
assert_eq!(json, "\"machine_applicable\"");
assert_eq!(Applicability::default(), Applicability::Unspecified);
}
#[test]
fn builder_sets_every_extension() {
let problem = ProblemDetails::new("t", "T", 429, "d", "urn:x")
.with_retry_after(180)
.with_suggested_fix(SuggestedFix::new("wait", Applicability::MachineApplicable))
.with_code_action(CodeAction::new(
"retry",
"quickfix",
Applicability::MachineApplicable,
))
.with_exit_code(2);
assert_eq!(problem.retry_after, Some(180));
assert_eq!(problem.exit_code, Some(2));
assert_eq!(problem.code_actions.len(), 1);
assert_eq!(
problem.suggested_fix.unwrap().applicability,
Applicability::MachineApplicable
);
}
#[test]
fn distinct_variants_map_to_distinct_versioned_envelopes() {
let invalid = TestError::InvalidInput("bad".to_string()).to_problem();
let failed = TestError::OperationFailed.to_problem();
assert_eq!(
invalid.problem_type,
"https://modeled-information-format.github.io/mif-rs/references/errors/invalid-input/v1"
);
assert_eq!(invalid.status, 400);
assert_eq!(invalid.detail, "invalid input: bad");
assert_eq!(invalid.instance, "urn:mif-problem-tests:invalid-input");
assert_eq!(invalid.retry_after, None);
assert_eq!(invalid.exit_code, Some(2));
assert_eq!(
failed.problem_type,
"https://modeled-information-format.github.io/mif-rs/references/errors/operation-failed/v1"
);
assert_eq!(failed.status, 500);
assert_eq!(failed.exit_code, Some(1));
assert_ne!(invalid.problem_type, failed.problem_type);
}
#[test]
fn json_envelope_carries_all_required_members() {
let json = TestError::OperationFailed.to_problem().to_json();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
for member in ["type", "title", "status", "detail", "instance"] {
assert!(value.get(member).is_some(), "missing {member}");
}
assert!(value.get("retry_after").is_some());
assert!(value["retry_after"].is_null());
assert_eq!(value["exit_code"], 1);
}
#[test]
fn pretty_render_is_error_prefixed_display() {
let err = TestError::OperationFailed;
assert_eq!(err.render(OutputFormat::Pretty), "Error: operation failed");
}
#[test]
fn json_render_matches_envelope_json() {
let err = TestError::OperationFailed;
assert_eq!(err.render(OutputFormat::Json), err.to_problem().to_json());
}
#[test]
fn format_selection_honors_flag_then_tty() {
assert_eq!(OutputFormat::select(Some("json"), true), OutputFormat::Json);
assert_eq!(
OutputFormat::select(Some("pretty"), false),
OutputFormat::Pretty
);
assert_eq!(OutputFormat::select(None, true), OutputFormat::Pretty);
assert_eq!(OutputFormat::select(None, false), OutputFormat::Json);
assert_eq!(
OutputFormat::select(Some("xml"), true),
OutputFormat::Pretty
);
}
#[test]
fn envelope_round_trips_through_json() {
let problem = TestError::OperationFailed.to_problem();
let json = problem.to_json();
let back: ProblemDetails = serde_json::from_str(&json).unwrap();
assert_eq!(problem, back);
}
#[test]
fn pretty_json_is_indented() {
let pretty = TestError::OperationFailed.to_problem().to_json_pretty();
assert!(pretty.contains('\n'));
assert!(pretty.contains(" \"type\""));
}
#[test]
fn classify_io_error_treats_not_found_as_a_likely_path_mistake() {
let error = std::io::Error::from(std::io::ErrorKind::NotFound);
let (status, fix, action) = classify_io_error(&error);
assert_eq!(status, 404);
assert_eq!(fix.applicability, Applicability::MaybeIncorrect);
assert_eq!(action.applicability, Applicability::MaybeIncorrect);
}
#[test]
fn classify_io_error_treats_permission_denied_as_a_likely_path_mistake() {
let error = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
let (status, fix, action) = classify_io_error(&error);
assert_eq!(status, 403);
assert_eq!(fix.applicability, Applicability::MaybeIncorrect);
assert_eq!(action.applicability, Applicability::MaybeIncorrect);
}
#[test]
fn classify_io_error_keeps_a_genuine_io_fault_at_500_and_does_not_imply_user_error() {
let error = std::io::Error::from(std::io::ErrorKind::Other);
let (status, fix, action) = classify_io_error(&error);
assert_eq!(status, 500);
assert_eq!(fix.applicability, Applicability::Unspecified);
assert_eq!(action.applicability, Applicability::Unspecified);
}
fn collect_rs_files(
dir: &std::path::Path,
out: &mut Vec<std::path::PathBuf>,
) -> Result<(), String> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(format!("failed to read dir {}: {e}", dir.display())),
};
for entry in entries {
let path = entry
.map_err(|e| format!("failed to read entry in {}: {e}", dir.display()))?
.path();
if path.is_dir() {
collect_rs_files(&path, out)?;
continue;
}
if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
Ok(())
}
fn slugs_in_file(path: &std::path::Path) -> Result<Vec<String>, String> {
let contents = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read {}: {e}", path.display()))?;
Ok(contents
.lines()
.filter_map(|line| {
let rest = line.trim_start().strip_prefix("slug: \"")?;
let end = rest.find('"')?;
Some(rest[..end].to_string())
})
.collect())
}
#[test]
fn every_problem_meta_slug_is_workspace_unique() {
const ALLOWED_SHARED: &[&str] = &["delegated"];
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(std::path::Path::parent)
.expect("mif-problem lives at <workspace_root>/crates/mif-problem")
.to_path_buf();
let crates_dir = workspace_root.join("crates");
let mut files = Vec::new();
for crate_dir in std::fs::read_dir(&crates_dir)
.expect("workspace crates/ directory must exist")
.filter_map(Result::ok)
{
collect_rs_files(&crate_dir.path().join("src"), &mut files)
.expect("crate src tree walk failed");
}
let mut occurrences: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for file in &files {
for slug in slugs_in_file(file).expect("failed to read a collected .rs file") {
occurrences
.entry(slug)
.or_default()
.push(file.display().to_string());
}
}
let collisions: Vec<(String, Vec<String>)> = occurrences
.into_iter()
.filter(|(slug, files)| files.len() > 1 && !ALLOWED_SHARED.contains(&slug.as_str()))
.collect();
assert!(
collisions.is_empty(),
"duplicate ProblemMeta slug(s) collide into the same type_uri: {collisions:#?}"
);
}
}