Skip to main content

release_kit/
diagnostic.rs

1//! Diagnostics as data: the closed reason vocabulary and the typed error
2//! report.
3//!
4//! An error is a value with named parts, rendered at the boundary — never
5//! a pre-formatted string assembled where the failure happened. The exit
6//! code stays the coarse machine signal; the `reason` beside it is the
7//! fine one, drawn from one closed vocabulary agents can branch on the
8//! way scripts branch on exit codes. The vocabulary is append-only: a
9//! reason is never renamed and never reused.
10
11use serde::Serialize;
12
13/// The version of the JSON diagnostic's shape.
14pub const DIAGNOSTIC_SCHEMA: &str = "rk.diagnostic/1";
15
16/// The closed reason vocabulary, the machine twin of the exit-code matrix.
17///
18/// Many reasons map to one exit code — that is the point: the code carries
19/// the category, the reason carries the instance. Classification is honest
20/// or absent: a failure nothing can classify further stays [`Reason::Io`]
21/// or [`Reason::Internal`] rather than guessing.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum Reason {
25    /// Semantically invalid arguments or names.
26    Usage,
27    /// The named target does not exist or is not usable as a target.
28    TargetNotFound,
29    /// No forge could be detected from the repository's remote.
30    ForgeUndetected,
31    /// The detected or named forge is not one the binary supports.
32    ForgeUnsupported,
33    /// A prerequisite probe failed before any side effect.
34    PrerequisiteUnmet,
35    /// The forge CLI is present and not authenticated.
36    ForgeAuthentication,
37    /// The forge refused the caller's permissions.
38    ForgePermission,
39    /// The forge rate-limited the call.
40    ForgeRateLimit,
41    /// The forge failed in a way a retry can cure.
42    ForgeTemporary,
43    /// The remote state changed under the run.
44    RemoteConflict,
45    /// The command refused an action it judged destructive.
46    DestructiveRefusal,
47    /// The state found differs from what would permit the action.
48    StateDrift,
49    /// A record or document declares a schema this binary does not know.
50    UnsupportedSchema,
51    /// A mutating run could not create its journal.
52    JournalUnavailable,
53    /// A child process could not be spawned.
54    SubprocessSpawn,
55    /// A child process ran and failed without a finer classification.
56    SubprocessFailed,
57    /// Filesystem failure.
58    Io,
59    /// A defect in this binary.
60    Internal,
61    /// A hand-authored target configuration is invalid.
62    ConfigInvalid,
63}
64
65/// Every reason, in declaration order; a test asserts against this so an
66/// addition is deliberate and a rename impossible.
67pub const REASONS: [Reason; 19] = [
68    Reason::Usage,
69    Reason::TargetNotFound,
70    Reason::ForgeUndetected,
71    Reason::ForgeUnsupported,
72    Reason::PrerequisiteUnmet,
73    Reason::ForgeAuthentication,
74    Reason::ForgePermission,
75    Reason::ForgeRateLimit,
76    Reason::ForgeTemporary,
77    Reason::RemoteConflict,
78    Reason::DestructiveRefusal,
79    Reason::StateDrift,
80    Reason::UnsupportedSchema,
81    Reason::JournalUnavailable,
82    Reason::SubprocessSpawn,
83    Reason::SubprocessFailed,
84    Reason::Io,
85    Reason::Internal,
86    Reason::ConfigInvalid,
87];
88
89impl Reason {
90    /// The kebab-case wire form, identical to the serde rendering.
91    #[must_use]
92    pub const fn as_str(self) -> &'static str {
93        match self {
94            Self::Usage => "usage",
95            Self::TargetNotFound => "target-not-found",
96            Self::ForgeUndetected => "forge-undetected",
97            Self::ForgeUnsupported => "forge-unsupported",
98            Self::PrerequisiteUnmet => "prerequisite-unmet",
99            Self::ForgeAuthentication => "forge-authentication",
100            Self::ForgePermission => "forge-permission",
101            Self::ForgeRateLimit => "forge-rate-limit",
102            Self::ForgeTemporary => "forge-temporary",
103            Self::RemoteConflict => "remote-conflict",
104            Self::DestructiveRefusal => "destructive-refusal",
105            Self::StateDrift => "state-drift",
106            Self::UnsupportedSchema => "unsupported-schema",
107            Self::JournalUnavailable => "journal-unavailable",
108            Self::SubprocessSpawn => "subprocess-spawn",
109            Self::SubprocessFailed => "subprocess-failed",
110            Self::Io => "io",
111            Self::Internal => "internal",
112            Self::ConfigInvalid => "config-invalid",
113        }
114    }
115}
116
117/// One failure, with its parts named.
118///
119/// A hint that is not known is omitted rather than invented, so every
120/// optional field serializes only when present.
121#[derive(Debug, Clone, Serialize)]
122pub struct Diagnostic {
123    /// The shape version of the JSON rendering.
124    pub schema: &'static str,
125    /// One entry from the closed vocabulary.
126    pub reason: Reason,
127    /// What happened, one line.
128    pub message: String,
129    /// What would have had to be true.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub expected: Option<String>,
132    /// The exact command or change that fixes it, when known.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub action: Option<String>,
135    /// Whether rerunning as-is can succeed.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub retry: Option<bool>,
138    /// What the run left behind, stated plainly.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub target_state: Option<String>,
141    /// The step it happened in, where there is one.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub step: Option<String>,
144    /// The run journal explaining it, where one was written.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub run: Option<String>,
147}
148
149impl Diagnostic {
150    /// A diagnostic carrying only its reason and message; the builder
151    /// methods add what is actually known.
152    #[must_use]
153    pub fn new(reason: Reason, message: impl Into<String>) -> Self {
154        Self {
155            schema: DIAGNOSTIC_SCHEMA,
156            reason,
157            message: message.into(),
158            expected: None,
159            action: None,
160            retry: None,
161            target_state: None,
162            step: None,
163            run: None,
164        }
165    }
166
167    /// State what would have had to be true.
168    #[must_use]
169    pub fn expected(mut self, expected: impl Into<String>) -> Self {
170        self.expected = Some(expected.into());
171        self
172    }
173
174    /// Name the command or change that fixes it.
175    #[must_use]
176    pub fn action(mut self, action: impl Into<String>) -> Self {
177        self.action = Some(action.into());
178        self
179    }
180
181    /// State what the run left behind.
182    #[must_use]
183    pub fn target_state(mut self, state: impl Into<String>) -> Self {
184        self.target_state = Some(state.into());
185        self
186    }
187
188    /// Name the step the failure happened in.
189    #[must_use]
190    pub fn step(mut self, step: impl Into<String>) -> Self {
191        self.step = Some(step.into());
192        self
193    }
194
195    /// Name the run journal that explains the failure.
196    #[must_use]
197    pub fn run(mut self, run: impl Into<String>) -> Self {
198        self.run = Some(run.into());
199        self
200    }
201
202    /// The human rendering: the five questions in order — what happened,
203    /// what was expected, what to do, how to resume, what state the target
204    /// is in — with unknown lines absent rather than invented.
205    #[must_use]
206    pub fn render_human(&self) -> String {
207        use std::fmt::Write as _;
208        let mut text = format!("error: {}", self.message);
209        if let Some(expected) = &self.expected {
210            let _ = write!(text, "\n  expected  {expected}");
211        }
212        if let Some(action) = &self.action {
213            let _ = write!(text, "\n  next      {action}");
214        }
215        if let Some(retry) = self.retry {
216            let answer = if retry {
217                "rerunning as-is can succeed"
218            } else {
219                "rerunning as-is fails the same way"
220            };
221            let _ = write!(text, "\n  retry     {answer}");
222        }
223        if let Some(state) = &self.target_state {
224            let _ = write!(text, "\n  state     {state}");
225        }
226        if let Some(run) = &self.run {
227            let _ = write!(text, "\n  run       {run}");
228        }
229        text
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    #![allow(clippy::expect_used)]
236
237    use super::{Diagnostic, REASONS, Reason};
238
239    /// The vocabulary is closed and append-only: this list is the one a
240    /// deliberate addition extends, and a rename fails here first.
241    #[test]
242    fn the_reason_vocabulary_is_closed() {
243        let wire: Vec<&str> = REASONS.iter().map(|reason| reason.as_str()).collect();
244        assert_eq!(
245            wire,
246            [
247                "usage",
248                "target-not-found",
249                "forge-undetected",
250                "forge-unsupported",
251                "prerequisite-unmet",
252                "forge-authentication",
253                "forge-permission",
254                "forge-rate-limit",
255                "forge-temporary",
256                "remote-conflict",
257                "destructive-refusal",
258                "state-drift",
259                "unsupported-schema",
260                "journal-unavailable",
261                "subprocess-spawn",
262                "subprocess-failed",
263                "io",
264                "internal",
265                "config-invalid",
266            ]
267        );
268    }
269
270    #[test]
271    fn the_serde_rendering_and_the_wire_form_agree() {
272        for reason in REASONS {
273            let json = serde_json::to_string(&reason).expect("a reason serializes");
274            assert_eq!(json, format!("\"{}\"", reason.as_str()));
275        }
276    }
277
278    /// The `rk.diagnostic/1` schema, held by snapshot: a field rename or
279    /// removal fails here and becomes a schema-version bump instead of a
280    /// silent parser break at some agent.
281    #[test]
282    fn the_diagnostic_schema_snapshot_holds() {
283        let full = Diagnostic::new(Reason::StateDrift, "what happened")
284            .expected("what would have had to be true")
285            .action("the command that fixes it")
286            .target_state("what the run left behind");
287        assert_eq!(
288            serde_json::to_string(&full).expect("a diagnostic serializes"),
289            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"}"#
290        );
291        let bare = Diagnostic::new(Reason::Io, "disk fell over");
292        assert_eq!(
293            serde_json::to_string(&bare).expect("a diagnostic serializes"),
294            r#"{"schema":"rk.diagnostic/1","reason":"io","message":"disk fell over"}"#,
295            "an unknown hint must be omitted, not serialized as null"
296        );
297    }
298
299    #[test]
300    fn the_human_rendering_answers_only_what_is_known() {
301        let bare = Diagnostic::new(Reason::Io, "disk fell over");
302        assert_eq!(bare.render_human(), "error: disk fell over");
303        let full = Diagnostic::new(Reason::StateDrift, "the target drifted")
304            .expected("a clean target")
305            .action("rk init --apply")
306            .target_state("nothing was written");
307        assert_eq!(
308            full.render_human(),
309            "error: the target drifted\n  expected  a clean target\n  next      rk init --apply\n  state     nothing was written"
310        );
311    }
312}