1use serde::Serialize;
12
13pub const DIAGNOSTIC_SCHEMA: &str = "rk.diagnostic/1";
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum Reason {
25 Usage,
27 TargetNotFound,
29 ForgeUndetected,
31 ForgeUnsupported,
33 PrerequisiteUnmet,
35 ForgeAuthentication,
37 ForgePermission,
39 ForgeRateLimit,
41 ForgeTemporary,
43 RemoteConflict,
45 DestructiveRefusal,
47 StateDrift,
49 UnsupportedSchema,
51 JournalUnavailable,
53 SubprocessSpawn,
55 SubprocessFailed,
57 Io,
59 Internal,
61 ConfigInvalid,
63 RegistryUnreachable,
66 BundleUnverified,
69 PlanNotReady,
72 PostconditionFailed,
75 TargetBusy,
77 ObservationIncomplete,
80 RecordInvalid,
82 MergeFormMismatch,
85 PresetContradiction,
88}
89
90pub const REASONS: [Reason; 28] = [
93 Reason::Usage,
94 Reason::TargetNotFound,
95 Reason::ForgeUndetected,
96 Reason::ForgeUnsupported,
97 Reason::PrerequisiteUnmet,
98 Reason::ForgeAuthentication,
99 Reason::ForgePermission,
100 Reason::ForgeRateLimit,
101 Reason::ForgeTemporary,
102 Reason::RemoteConflict,
103 Reason::DestructiveRefusal,
104 Reason::StateDrift,
105 Reason::UnsupportedSchema,
106 Reason::JournalUnavailable,
107 Reason::SubprocessSpawn,
108 Reason::SubprocessFailed,
109 Reason::Io,
110 Reason::Internal,
111 Reason::ConfigInvalid,
112 Reason::RegistryUnreachable,
113 Reason::BundleUnverified,
114 Reason::PlanNotReady,
115 Reason::PostconditionFailed,
116 Reason::TargetBusy,
117 Reason::ObservationIncomplete,
118 Reason::RecordInvalid,
119 Reason::MergeFormMismatch,
120 Reason::PresetContradiction,
121];
122
123impl Reason {
124 #[must_use]
126 pub const fn as_str(self) -> &'static str {
127 match self {
128 Self::Usage => "usage",
129 Self::TargetNotFound => "target-not-found",
130 Self::ForgeUndetected => "forge-undetected",
131 Self::ForgeUnsupported => "forge-unsupported",
132 Self::PrerequisiteUnmet => "prerequisite-unmet",
133 Self::ForgeAuthentication => "forge-authentication",
134 Self::ForgePermission => "forge-permission",
135 Self::ForgeRateLimit => "forge-rate-limit",
136 Self::ForgeTemporary => "forge-temporary",
137 Self::RemoteConflict => "remote-conflict",
138 Self::DestructiveRefusal => "destructive-refusal",
139 Self::StateDrift => "state-drift",
140 Self::UnsupportedSchema => "unsupported-schema",
141 Self::JournalUnavailable => "journal-unavailable",
142 Self::SubprocessSpawn => "subprocess-spawn",
143 Self::SubprocessFailed => "subprocess-failed",
144 Self::Io => "io",
145 Self::Internal => "internal",
146 Self::ConfigInvalid => "config-invalid",
147 Self::RegistryUnreachable => "registry-unreachable",
148 Self::BundleUnverified => "bundle-unverified",
149 Self::PlanNotReady => "plan-not-ready",
150 Self::PostconditionFailed => "postcondition-failed",
151 Self::TargetBusy => "target-busy",
152 Self::ObservationIncomplete => "observation-incomplete",
153 Self::RecordInvalid => "record-invalid",
154 Self::MergeFormMismatch => "merge-form-mismatch",
155 Self::PresetContradiction => "preset-contradiction",
156 }
157 }
158}
159
160#[derive(Debug, Clone, Serialize)]
165pub struct Diagnostic {
166 pub schema: &'static str,
168 pub reason: Reason,
170 pub message: String,
172 #[serde(skip_serializing_if = "Option::is_none")]
174 pub expected: Option<String>,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub action: Option<String>,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub retry: Option<bool>,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub target_state: Option<String>,
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub step: Option<String>,
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub run: Option<String>,
190}
191
192impl Diagnostic {
193 #[must_use]
196 pub fn new(reason: Reason, message: impl Into<String>) -> Self {
197 Self {
198 schema: DIAGNOSTIC_SCHEMA,
199 reason,
200 message: message.into(),
201 expected: None,
202 action: None,
203 retry: None,
204 target_state: None,
205 step: None,
206 run: None,
207 }
208 }
209
210 #[must_use]
212 pub fn expected(mut self, expected: impl Into<String>) -> Self {
213 self.expected = Some(expected.into());
214 self
215 }
216
217 #[must_use]
219 pub fn action(mut self, action: impl Into<String>) -> Self {
220 self.action = Some(action.into());
221 self
222 }
223
224 #[must_use]
226 pub fn target_state(mut self, state: impl Into<String>) -> Self {
227 self.target_state = Some(state.into());
228 self
229 }
230
231 #[must_use]
233 pub fn step(mut self, step: impl Into<String>) -> Self {
234 self.step = Some(step.into());
235 self
236 }
237
238 #[must_use]
240 pub fn run(mut self, run: impl Into<String>) -> Self {
241 self.run = Some(run.into());
242 self
243 }
244
245 #[must_use]
249 pub fn render_human(&self) -> String {
250 use std::fmt::Write as _;
251 let mut text = format!("error: {}", self.message);
252 if let Some(expected) = &self.expected {
253 let _ = write!(text, "\n expected {expected}");
254 }
255 if let Some(action) = &self.action {
256 let _ = write!(text, "\n next {action}");
257 }
258 if let Some(retry) = self.retry {
259 let answer = if retry {
260 "rerunning as-is can succeed"
261 } else {
262 "rerunning as-is fails the same way"
263 };
264 let _ = write!(text, "\n retry {answer}");
265 }
266 if let Some(state) = &self.target_state {
267 let _ = write!(text, "\n state {state}");
268 }
269 if let Some(run) = &self.run {
270 let _ = write!(text, "\n run {run}");
271 }
272 text
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::{Diagnostic, REASONS, Reason};
279
280 #[test]
283 fn the_reason_vocabulary_is_closed() {
284 let wire: Vec<&str> = REASONS.iter().map(|reason| reason.as_str()).collect();
285 assert_eq!(
286 wire,
287 [
288 "usage",
289 "target-not-found",
290 "forge-undetected",
291 "forge-unsupported",
292 "prerequisite-unmet",
293 "forge-authentication",
294 "forge-permission",
295 "forge-rate-limit",
296 "forge-temporary",
297 "remote-conflict",
298 "destructive-refusal",
299 "state-drift",
300 "unsupported-schema",
301 "journal-unavailable",
302 "subprocess-spawn",
303 "subprocess-failed",
304 "io",
305 "internal",
306 "config-invalid",
307 "registry-unreachable",
308 "bundle-unverified",
309 "plan-not-ready",
310 "postcondition-failed",
311 "target-busy",
312 "observation-incomplete",
313 "record-invalid",
314 "merge-form-mismatch",
315 "preset-contradiction",
316 ]
317 );
318 }
319
320 #[test]
321 fn the_serde_rendering_and_the_wire_form_agree() {
322 for reason in REASONS {
323 let json = serde_json::to_string(&reason).expect("a reason serializes");
324 assert_eq!(json, format!("\"{}\"", reason.as_str()));
325 }
326 }
327
328 #[test]
332 fn the_diagnostic_schema_snapshot_holds() {
333 let full = Diagnostic::new(Reason::StateDrift, "what happened")
334 .expected("what would have had to be true")
335 .action("the command that fixes it")
336 .target_state("what the run left behind");
337 assert_eq!(
338 serde_json::to_string(&full).expect("a diagnostic serializes"),
339 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"}"#
340 );
341 let bare = Diagnostic::new(Reason::Io, "disk fell over");
342 assert_eq!(
343 serde_json::to_string(&bare).expect("a diagnostic serializes"),
344 r#"{"schema":"rk.diagnostic/1","reason":"io","message":"disk fell over"}"#,
345 "an unknown hint must be omitted, not serialized as null"
346 );
347 }
348
349 #[test]
350 fn the_human_rendering_answers_only_what_is_known() {
351 let bare = Diagnostic::new(Reason::Io, "disk fell over");
352 assert_eq!(bare.render_human(), "error: disk fell over");
353 let full = Diagnostic::new(Reason::StateDrift, "the target drifted")
354 .expected("a clean target")
355 .action("rk init --apply")
356 .target_state("nothing was written");
357 assert_eq!(
358 full.render_human(),
359 "error: the target drifted\n expected a clean target\n next rk init --apply\n state nothing was written"
360 );
361 }
362}