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}
83
84pub const REASONS: [Reason; 26] = [
87 Reason::Usage,
88 Reason::TargetNotFound,
89 Reason::ForgeUndetected,
90 Reason::ForgeUnsupported,
91 Reason::PrerequisiteUnmet,
92 Reason::ForgeAuthentication,
93 Reason::ForgePermission,
94 Reason::ForgeRateLimit,
95 Reason::ForgeTemporary,
96 Reason::RemoteConflict,
97 Reason::DestructiveRefusal,
98 Reason::StateDrift,
99 Reason::UnsupportedSchema,
100 Reason::JournalUnavailable,
101 Reason::SubprocessSpawn,
102 Reason::SubprocessFailed,
103 Reason::Io,
104 Reason::Internal,
105 Reason::ConfigInvalid,
106 Reason::RegistryUnreachable,
107 Reason::BundleUnverified,
108 Reason::PlanNotReady,
109 Reason::PostconditionFailed,
110 Reason::TargetBusy,
111 Reason::ObservationIncomplete,
112 Reason::RecordInvalid,
113];
114
115impl Reason {
116 #[must_use]
118 pub const fn as_str(self) -> &'static str {
119 match self {
120 Self::Usage => "usage",
121 Self::TargetNotFound => "target-not-found",
122 Self::ForgeUndetected => "forge-undetected",
123 Self::ForgeUnsupported => "forge-unsupported",
124 Self::PrerequisiteUnmet => "prerequisite-unmet",
125 Self::ForgeAuthentication => "forge-authentication",
126 Self::ForgePermission => "forge-permission",
127 Self::ForgeRateLimit => "forge-rate-limit",
128 Self::ForgeTemporary => "forge-temporary",
129 Self::RemoteConflict => "remote-conflict",
130 Self::DestructiveRefusal => "destructive-refusal",
131 Self::StateDrift => "state-drift",
132 Self::UnsupportedSchema => "unsupported-schema",
133 Self::JournalUnavailable => "journal-unavailable",
134 Self::SubprocessSpawn => "subprocess-spawn",
135 Self::SubprocessFailed => "subprocess-failed",
136 Self::Io => "io",
137 Self::Internal => "internal",
138 Self::ConfigInvalid => "config-invalid",
139 Self::RegistryUnreachable => "registry-unreachable",
140 Self::BundleUnverified => "bundle-unverified",
141 Self::PlanNotReady => "plan-not-ready",
142 Self::PostconditionFailed => "postcondition-failed",
143 Self::TargetBusy => "target-busy",
144 Self::ObservationIncomplete => "observation-incomplete",
145 Self::RecordInvalid => "record-invalid",
146 }
147 }
148}
149
150#[derive(Debug, Clone, Serialize)]
155pub struct Diagnostic {
156 pub schema: &'static str,
158 pub reason: Reason,
160 pub message: String,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub expected: Option<String>,
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub action: Option<String>,
168 #[serde(skip_serializing_if = "Option::is_none")]
170 pub retry: Option<bool>,
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub target_state: Option<String>,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub step: Option<String>,
177 #[serde(skip_serializing_if = "Option::is_none")]
179 pub run: Option<String>,
180}
181
182impl Diagnostic {
183 #[must_use]
186 pub fn new(reason: Reason, message: impl Into<String>) -> Self {
187 Self {
188 schema: DIAGNOSTIC_SCHEMA,
189 reason,
190 message: message.into(),
191 expected: None,
192 action: None,
193 retry: None,
194 target_state: None,
195 step: None,
196 run: None,
197 }
198 }
199
200 #[must_use]
202 pub fn expected(mut self, expected: impl Into<String>) -> Self {
203 self.expected = Some(expected.into());
204 self
205 }
206
207 #[must_use]
209 pub fn action(mut self, action: impl Into<String>) -> Self {
210 self.action = Some(action.into());
211 self
212 }
213
214 #[must_use]
216 pub fn target_state(mut self, state: impl Into<String>) -> Self {
217 self.target_state = Some(state.into());
218 self
219 }
220
221 #[must_use]
223 pub fn step(mut self, step: impl Into<String>) -> Self {
224 self.step = Some(step.into());
225 self
226 }
227
228 #[must_use]
230 pub fn run(mut self, run: impl Into<String>) -> Self {
231 self.run = Some(run.into());
232 self
233 }
234
235 #[must_use]
239 pub fn render_human(&self) -> String {
240 use std::fmt::Write as _;
241 let mut text = format!("error: {}", self.message);
242 if let Some(expected) = &self.expected {
243 let _ = write!(text, "\n expected {expected}");
244 }
245 if let Some(action) = &self.action {
246 let _ = write!(text, "\n next {action}");
247 }
248 if let Some(retry) = self.retry {
249 let answer = if retry {
250 "rerunning as-is can succeed"
251 } else {
252 "rerunning as-is fails the same way"
253 };
254 let _ = write!(text, "\n retry {answer}");
255 }
256 if let Some(state) = &self.target_state {
257 let _ = write!(text, "\n state {state}");
258 }
259 if let Some(run) = &self.run {
260 let _ = write!(text, "\n run {run}");
261 }
262 text
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::{Diagnostic, REASONS, Reason};
269
270 #[test]
273 fn the_reason_vocabulary_is_closed() {
274 let wire: Vec<&str> = REASONS.iter().map(|reason| reason.as_str()).collect();
275 assert_eq!(
276 wire,
277 [
278 "usage",
279 "target-not-found",
280 "forge-undetected",
281 "forge-unsupported",
282 "prerequisite-unmet",
283 "forge-authentication",
284 "forge-permission",
285 "forge-rate-limit",
286 "forge-temporary",
287 "remote-conflict",
288 "destructive-refusal",
289 "state-drift",
290 "unsupported-schema",
291 "journal-unavailable",
292 "subprocess-spawn",
293 "subprocess-failed",
294 "io",
295 "internal",
296 "config-invalid",
297 "registry-unreachable",
298 "bundle-unverified",
299 "plan-not-ready",
300 "postcondition-failed",
301 "target-busy",
302 "observation-incomplete",
303 "record-invalid",
304 ]
305 );
306 }
307
308 #[test]
309 fn the_serde_rendering_and_the_wire_form_agree() {
310 for reason in REASONS {
311 let json = serde_json::to_string(&reason).expect("a reason serializes");
312 assert_eq!(json, format!("\"{}\"", reason.as_str()));
313 }
314 }
315
316 #[test]
320 fn the_diagnostic_schema_snapshot_holds() {
321 let full = Diagnostic::new(Reason::StateDrift, "what happened")
322 .expected("what would have had to be true")
323 .action("the command that fixes it")
324 .target_state("what the run left behind");
325 assert_eq!(
326 serde_json::to_string(&full).expect("a diagnostic serializes"),
327 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"}"#
328 );
329 let bare = Diagnostic::new(Reason::Io, "disk fell over");
330 assert_eq!(
331 serde_json::to_string(&bare).expect("a diagnostic serializes"),
332 r#"{"schema":"rk.diagnostic/1","reason":"io","message":"disk fell over"}"#,
333 "an unknown hint must be omitted, not serialized as null"
334 );
335 }
336
337 #[test]
338 fn the_human_rendering_answers_only_what_is_known() {
339 let bare = Diagnostic::new(Reason::Io, "disk fell over");
340 assert_eq!(bare.render_human(), "error: disk fell over");
341 let full = Diagnostic::new(Reason::StateDrift, "the target drifted")
342 .expected("a clean target")
343 .action("rk init --apply")
344 .target_state("nothing was written");
345 assert_eq!(
346 full.render_human(),
347 "error: the target drifted\n expected a clean target\n next rk init --apply\n state nothing was written"
348 );
349 }
350}