use serde::Serialize;
pub const DIAGNOSTIC_SCHEMA: &str = "rk.diagnostic/1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Reason {
Usage,
TargetNotFound,
ForgeUndetected,
ForgeUnsupported,
PrerequisiteUnmet,
ForgeAuthentication,
ForgePermission,
ForgeRateLimit,
ForgeTemporary,
RemoteConflict,
DestructiveRefusal,
StateDrift,
UnsupportedSchema,
JournalUnavailable,
SubprocessSpawn,
SubprocessFailed,
Io,
Internal,
}
pub const REASONS: [Reason; 18] = [
Reason::Usage,
Reason::TargetNotFound,
Reason::ForgeUndetected,
Reason::ForgeUnsupported,
Reason::PrerequisiteUnmet,
Reason::ForgeAuthentication,
Reason::ForgePermission,
Reason::ForgeRateLimit,
Reason::ForgeTemporary,
Reason::RemoteConflict,
Reason::DestructiveRefusal,
Reason::StateDrift,
Reason::UnsupportedSchema,
Reason::JournalUnavailable,
Reason::SubprocessSpawn,
Reason::SubprocessFailed,
Reason::Io,
Reason::Internal,
];
impl Reason {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Usage => "usage",
Self::TargetNotFound => "target-not-found",
Self::ForgeUndetected => "forge-undetected",
Self::ForgeUnsupported => "forge-unsupported",
Self::PrerequisiteUnmet => "prerequisite-unmet",
Self::ForgeAuthentication => "forge-authentication",
Self::ForgePermission => "forge-permission",
Self::ForgeRateLimit => "forge-rate-limit",
Self::ForgeTemporary => "forge-temporary",
Self::RemoteConflict => "remote-conflict",
Self::DestructiveRefusal => "destructive-refusal",
Self::StateDrift => "state-drift",
Self::UnsupportedSchema => "unsupported-schema",
Self::JournalUnavailable => "journal-unavailable",
Self::SubprocessSpawn => "subprocess-spawn",
Self::SubprocessFailed => "subprocess-failed",
Self::Io => "io",
Self::Internal => "internal",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Diagnostic {
pub schema: &'static str,
pub reason: Reason,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expected: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub step: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub run: Option<String>,
}
impl Diagnostic {
#[must_use]
pub fn new(reason: Reason, message: impl Into<String>) -> Self {
Self {
schema: DIAGNOSTIC_SCHEMA,
reason,
message: message.into(),
expected: None,
action: None,
retry: None,
target_state: None,
step: None,
run: None,
}
}
#[must_use]
pub fn expected(mut self, expected: impl Into<String>) -> Self {
self.expected = Some(expected.into());
self
}
#[must_use]
pub fn action(mut self, action: impl Into<String>) -> Self {
self.action = Some(action.into());
self
}
#[must_use]
pub fn target_state(mut self, state: impl Into<String>) -> Self {
self.target_state = Some(state.into());
self
}
#[must_use]
pub fn step(mut self, step: impl Into<String>) -> Self {
self.step = Some(step.into());
self
}
#[must_use]
pub fn run(mut self, run: impl Into<String>) -> Self {
self.run = Some(run.into());
self
}
#[must_use]
pub fn render_human(&self) -> String {
use std::fmt::Write as _;
let mut text = format!("error: {}", self.message);
if let Some(expected) = &self.expected {
let _ = write!(text, "\n expected {expected}");
}
if let Some(action) = &self.action {
let _ = write!(text, "\n next {action}");
}
if let Some(retry) = self.retry {
let answer = if retry {
"rerunning as-is can succeed"
} else {
"rerunning as-is fails the same way"
};
let _ = write!(text, "\n retry {answer}");
}
if let Some(state) = &self.target_state {
let _ = write!(text, "\n state {state}");
}
if let Some(run) = &self.run {
let _ = write!(text, "\n run {run}");
}
text
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::{Diagnostic, REASONS, Reason};
#[test]
fn the_reason_vocabulary_is_closed() {
let wire: Vec<&str> = REASONS.iter().map(|reason| reason.as_str()).collect();
assert_eq!(
wire,
[
"usage",
"target-not-found",
"forge-undetected",
"forge-unsupported",
"prerequisite-unmet",
"forge-authentication",
"forge-permission",
"forge-rate-limit",
"forge-temporary",
"remote-conflict",
"destructive-refusal",
"state-drift",
"unsupported-schema",
"journal-unavailable",
"subprocess-spawn",
"subprocess-failed",
"io",
"internal",
]
);
}
#[test]
fn the_serde_rendering_and_the_wire_form_agree() {
for reason in REASONS {
let json = serde_json::to_string(&reason).expect("a reason serializes");
assert_eq!(json, format!("\"{}\"", reason.as_str()));
}
}
#[test]
fn the_diagnostic_schema_snapshot_holds() {
let full = Diagnostic::new(Reason::StateDrift, "what happened")
.expected("what would have had to be true")
.action("the command that fixes it")
.target_state("what the run left behind");
assert_eq!(
serde_json::to_string(&full).expect("a diagnostic serializes"),
r#"{"schema":"rk.diagnostic/1","reason":"state-drift","message":"what happened","expected":"what would have had to be true","action":"the command that fixes it","target_state":"what the run left behind"}"#
);
let bare = Diagnostic::new(Reason::Io, "disk fell over");
assert_eq!(
serde_json::to_string(&bare).expect("a diagnostic serializes"),
r#"{"schema":"rk.diagnostic/1","reason":"io","message":"disk fell over"}"#,
"an unknown hint must be omitted, not serialized as null"
);
}
#[test]
fn the_human_rendering_answers_only_what_is_known() {
let bare = Diagnostic::new(Reason::Io, "disk fell over");
assert_eq!(bare.render_human(), "error: disk fell over");
let full = Diagnostic::new(Reason::StateDrift, "the target drifted")
.expected("a clean target")
.action("rk init --apply")
.target_state("nothing was written");
assert_eq!(
full.render_human(),
"error: the target drifted\n expected a clean target\n next rk init --apply\n state nothing was written"
);
}
}