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    /// A registry did not answer. No live path emits it; the vocabulary
64    /// is append-only, so the word stays.
65    RegistryUnreachable,
66    /// A fetched artifact failed its verification. No live path emits
67    /// it; the vocabulary is append-only, so the word stays.
68    BundleUnverified,
69    /// A precondition did not hold before a write. No live path emits
70    /// it; the vocabulary is append-only, so the word stays.
71    PlanNotReady,
72    /// A postcondition found the target short after a write. No live
73    /// path emits it; the vocabulary is append-only, so the word stays.
74    PostconditionFailed,
75    /// Another run holds the target, and this one wrote nothing.
76    TargetBusy,
77    /// A read-only observation could not decide a step from where it ran,
78    /// and found no step wrong.
79    ObservationIncomplete,
80    /// The landing record exists and does not parse as one.
81    RecordInvalid,
82    /// An argument the recorded merge form does not take, such as a trunk
83    /// message under `rebase`, where every commit keeps its own.
84    MergeFormMismatch,
85    /// An explicit flag contradicts an answer a preset flag sets, such as
86    /// `--integration forge` beside `--workflow-only`.
87    PresetContradiction,
88    /// The release would act with the forge's default CI token, whose
89    /// push starts no pipeline.
90    DefaultCiToken,
91    /// A pinned tool runs at another version than its pin.
92    ToolOffPin,
93    /// The forge or the registry already holds a release that conflicts
94    /// with this commit's: a tag at another commit, or other bytes.
95    ReleaseConflict,
96}
97
98/// Every reason, in declaration order; a test asserts against this so an
99/// addition is deliberate and a rename impossible.
100pub const REASONS: [Reason; 31] = [
101    Reason::Usage,
102    Reason::TargetNotFound,
103    Reason::ForgeUndetected,
104    Reason::ForgeUnsupported,
105    Reason::PrerequisiteUnmet,
106    Reason::ForgeAuthentication,
107    Reason::ForgePermission,
108    Reason::ForgeRateLimit,
109    Reason::ForgeTemporary,
110    Reason::RemoteConflict,
111    Reason::DestructiveRefusal,
112    Reason::StateDrift,
113    Reason::UnsupportedSchema,
114    Reason::JournalUnavailable,
115    Reason::SubprocessSpawn,
116    Reason::SubprocessFailed,
117    Reason::Io,
118    Reason::Internal,
119    Reason::ConfigInvalid,
120    Reason::RegistryUnreachable,
121    Reason::BundleUnverified,
122    Reason::PlanNotReady,
123    Reason::PostconditionFailed,
124    Reason::TargetBusy,
125    Reason::ObservationIncomplete,
126    Reason::RecordInvalid,
127    Reason::MergeFormMismatch,
128    Reason::PresetContradiction,
129    Reason::DefaultCiToken,
130    Reason::ToolOffPin,
131    Reason::ReleaseConflict,
132];
133
134impl Reason {
135    /// The kebab-case wire form, identical to the serde rendering.
136    #[must_use]
137    pub const fn as_str(self) -> &'static str {
138        match self {
139            Self::Usage => "usage",
140            Self::TargetNotFound => "target-not-found",
141            Self::ForgeUndetected => "forge-undetected",
142            Self::ForgeUnsupported => "forge-unsupported",
143            Self::PrerequisiteUnmet => "prerequisite-unmet",
144            Self::ForgeAuthentication => "forge-authentication",
145            Self::ForgePermission => "forge-permission",
146            Self::ForgeRateLimit => "forge-rate-limit",
147            Self::ForgeTemporary => "forge-temporary",
148            Self::RemoteConflict => "remote-conflict",
149            Self::DestructiveRefusal => "destructive-refusal",
150            Self::StateDrift => "state-drift",
151            Self::UnsupportedSchema => "unsupported-schema",
152            Self::JournalUnavailable => "journal-unavailable",
153            Self::SubprocessSpawn => "subprocess-spawn",
154            Self::SubprocessFailed => "subprocess-failed",
155            Self::Io => "io",
156            Self::Internal => "internal",
157            Self::ConfigInvalid => "config-invalid",
158            Self::RegistryUnreachable => "registry-unreachable",
159            Self::BundleUnverified => "bundle-unverified",
160            Self::PlanNotReady => "plan-not-ready",
161            Self::PostconditionFailed => "postcondition-failed",
162            Self::TargetBusy => "target-busy",
163            Self::ObservationIncomplete => "observation-incomplete",
164            Self::RecordInvalid => "record-invalid",
165            Self::MergeFormMismatch => "merge-form-mismatch",
166            Self::PresetContradiction => "preset-contradiction",
167            Self::DefaultCiToken => "default-ci-token",
168            Self::ToolOffPin => "tool-off-pin",
169            Self::ReleaseConflict => "release-conflict",
170        }
171    }
172}
173
174/// One failure, with its parts named.
175///
176/// A hint that is not known is omitted rather than invented, so every
177/// optional field serializes only when present.
178#[derive(Debug, Clone, Serialize)]
179pub struct Diagnostic {
180    /// The shape version of the JSON rendering.
181    pub schema: &'static str,
182    /// One entry from the closed vocabulary.
183    pub reason: Reason,
184    /// What happened, one line.
185    pub message: String,
186    /// What would have had to be true.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub expected: Option<String>,
189    /// The exact command or change that fixes it, when known.
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub action: Option<String>,
192    /// Whether rerunning as-is can succeed.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub retry: Option<bool>,
195    /// What the run left behind, stated plainly.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub target_state: Option<String>,
198    /// The step it happened in, where there is one.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub step: Option<String>,
201    /// The run journal explaining it, where one was written.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub run: Option<String>,
204}
205
206impl Diagnostic {
207    /// A diagnostic carrying only its reason and message; the builder
208    /// methods add what is actually known.
209    #[must_use]
210    pub fn new(reason: Reason, message: impl Into<String>) -> Self {
211        Self {
212            schema: DIAGNOSTIC_SCHEMA,
213            reason,
214            message: message.into(),
215            expected: None,
216            action: None,
217            retry: None,
218            target_state: None,
219            step: None,
220            run: None,
221        }
222    }
223
224    /// State what would have had to be true.
225    #[must_use]
226    pub fn expected(mut self, expected: impl Into<String>) -> Self {
227        self.expected = Some(expected.into());
228        self
229    }
230
231    /// Name the command or change that fixes it.
232    #[must_use]
233    pub fn action(mut self, action: impl Into<String>) -> Self {
234        self.action = Some(action.into());
235        self
236    }
237
238    /// State what the run left behind.
239    #[must_use]
240    pub fn target_state(mut self, state: impl Into<String>) -> Self {
241        self.target_state = Some(state.into());
242        self
243    }
244
245    /// Name the step the failure happened in.
246    #[must_use]
247    pub fn step(mut self, step: impl Into<String>) -> Self {
248        self.step = Some(step.into());
249        self
250    }
251
252    /// Name the run journal that explains the failure.
253    #[must_use]
254    pub fn run(mut self, run: impl Into<String>) -> Self {
255        self.run = Some(run.into());
256        self
257    }
258
259    /// The human rendering: the five questions in order — what happened,
260    /// what was expected, what to do, how to resume, what state the target
261    /// is in — with unknown lines absent rather than invented.
262    #[must_use]
263    pub fn render_human(&self) -> String {
264        use std::fmt::Write as _;
265        let mut text = format!("error: {}", self.message);
266        if let Some(expected) = &self.expected {
267            let _ = write!(text, "\n  expected  {expected}");
268        }
269        if let Some(action) = &self.action {
270            let _ = write!(text, "\n  next      {action}");
271        }
272        if let Some(retry) = self.retry {
273            let answer = if retry {
274                "rerunning as-is can succeed"
275            } else {
276                "rerunning as-is fails the same way"
277            };
278            let _ = write!(text, "\n  retry     {answer}");
279        }
280        if let Some(state) = &self.target_state {
281            let _ = write!(text, "\n  state     {state}");
282        }
283        if let Some(run) = &self.run {
284            let _ = write!(text, "\n  run       {run}");
285        }
286        text
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::{Diagnostic, REASONS, Reason};
293
294    /// The vocabulary is closed and append-only: this list is the one a
295    /// deliberate addition extends, and a rename fails here first.
296    #[test]
297    fn the_reason_vocabulary_is_closed() {
298        let wire: Vec<&str> = REASONS.iter().map(|reason| reason.as_str()).collect();
299        assert_eq!(
300            wire,
301            [
302                "usage",
303                "target-not-found",
304                "forge-undetected",
305                "forge-unsupported",
306                "prerequisite-unmet",
307                "forge-authentication",
308                "forge-permission",
309                "forge-rate-limit",
310                "forge-temporary",
311                "remote-conflict",
312                "destructive-refusal",
313                "state-drift",
314                "unsupported-schema",
315                "journal-unavailable",
316                "subprocess-spawn",
317                "subprocess-failed",
318                "io",
319                "internal",
320                "config-invalid",
321                "registry-unreachable",
322                "bundle-unverified",
323                "plan-not-ready",
324                "postcondition-failed",
325                "target-busy",
326                "observation-incomplete",
327                "record-invalid",
328                "merge-form-mismatch",
329                "preset-contradiction",
330                "default-ci-token",
331                "tool-off-pin",
332                "release-conflict",
333            ]
334        );
335    }
336
337    #[test]
338    fn the_serde_rendering_and_the_wire_form_agree() {
339        for reason in REASONS {
340            let json = serde_json::to_string(&reason).expect("a reason serializes");
341            assert_eq!(json, format!("\"{}\"", reason.as_str()));
342        }
343    }
344
345    /// The `rk.diagnostic/1` schema, held by snapshot: a field rename or
346    /// removal fails here and becomes a schema-version bump instead of a
347    /// silent parser break at some agent.
348    #[test]
349    fn the_diagnostic_schema_snapshot_holds() {
350        let full = Diagnostic::new(Reason::StateDrift, "what happened")
351            .expected("what would have had to be true")
352            .action("the command that fixes it")
353            .target_state("what the run left behind");
354        assert_eq!(
355            serde_json::to_string(&full).expect("a diagnostic serializes"),
356            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"}"#
357        );
358        let bare = Diagnostic::new(Reason::Io, "disk fell over");
359        assert_eq!(
360            serde_json::to_string(&bare).expect("a diagnostic serializes"),
361            r#"{"schema":"rk.diagnostic/1","reason":"io","message":"disk fell over"}"#,
362            "an unknown hint must be omitted, not serialized as null"
363        );
364    }
365
366    #[test]
367    fn the_human_rendering_answers_only_what_is_known() {
368        let bare = Diagnostic::new(Reason::Io, "disk fell over");
369        assert_eq!(bare.render_human(), "error: disk fell over");
370        let full = Diagnostic::new(Reason::StateDrift, "the target drifted")
371            .expected("a clean target")
372            .action("rk init --apply")
373            .target_state("nothing was written");
374        assert_eq!(
375            full.render_human(),
376            "error: the target drifted\n  expected  a clean target\n  next      rk init --apply\n  state     nothing was written"
377        );
378    }
379}