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