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 DefaultCiToken,
91 ToolOffPin,
93 ReleaseConflict,
96}
97
98pub 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 #[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#[derive(Debug, Clone, Serialize)]
179pub struct Diagnostic {
180 pub schema: &'static str,
182 pub reason: Reason,
184 pub message: String,
186 #[serde(skip_serializing_if = "Option::is_none")]
188 pub expected: Option<String>,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub action: Option<String>,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub retry: Option<bool>,
195 #[serde(skip_serializing_if = "Option::is_none")]
197 pub target_state: Option<String>,
198 #[serde(skip_serializing_if = "Option::is_none")]
200 pub step: Option<String>,
201 #[serde(skip_serializing_if = "Option::is_none")]
203 pub run: Option<String>,
204}
205
206impl Diagnostic {
207 #[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 #[must_use]
226 pub fn expected(mut self, expected: impl Into<String>) -> Self {
227 self.expected = Some(expected.into());
228 self
229 }
230
231 #[must_use]
233 pub fn action(mut self, action: impl Into<String>) -> Self {
234 self.action = Some(action.into());
235 self
236 }
237
238 #[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 #[must_use]
247 pub fn step(mut self, step: impl Into<String>) -> Self {
248 self.step = Some(step.into());
249 self
250 }
251
252 #[must_use]
254 pub fn run(mut self, run: impl Into<String>) -> Self {
255 self.run = Some(run.into());
256 self
257 }
258
259 #[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 #[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 #[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}