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