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}
64
65pub const REASONS: [Reason; 19] = [
68 Reason::Usage,
69 Reason::TargetNotFound,
70 Reason::ForgeUndetected,
71 Reason::ForgeUnsupported,
72 Reason::PrerequisiteUnmet,
73 Reason::ForgeAuthentication,
74 Reason::ForgePermission,
75 Reason::ForgeRateLimit,
76 Reason::ForgeTemporary,
77 Reason::RemoteConflict,
78 Reason::DestructiveRefusal,
79 Reason::StateDrift,
80 Reason::UnsupportedSchema,
81 Reason::JournalUnavailable,
82 Reason::SubprocessSpawn,
83 Reason::SubprocessFailed,
84 Reason::Io,
85 Reason::Internal,
86 Reason::ConfigInvalid,
87];
88
89impl Reason {
90 #[must_use]
92 pub const fn as_str(self) -> &'static str {
93 match self {
94 Self::Usage => "usage",
95 Self::TargetNotFound => "target-not-found",
96 Self::ForgeUndetected => "forge-undetected",
97 Self::ForgeUnsupported => "forge-unsupported",
98 Self::PrerequisiteUnmet => "prerequisite-unmet",
99 Self::ForgeAuthentication => "forge-authentication",
100 Self::ForgePermission => "forge-permission",
101 Self::ForgeRateLimit => "forge-rate-limit",
102 Self::ForgeTemporary => "forge-temporary",
103 Self::RemoteConflict => "remote-conflict",
104 Self::DestructiveRefusal => "destructive-refusal",
105 Self::StateDrift => "state-drift",
106 Self::UnsupportedSchema => "unsupported-schema",
107 Self::JournalUnavailable => "journal-unavailable",
108 Self::SubprocessSpawn => "subprocess-spawn",
109 Self::SubprocessFailed => "subprocess-failed",
110 Self::Io => "io",
111 Self::Internal => "internal",
112 Self::ConfigInvalid => "config-invalid",
113 }
114 }
115}
116
117#[derive(Debug, Clone, Serialize)]
122pub struct Diagnostic {
123 pub schema: &'static str,
125 pub reason: Reason,
127 pub message: String,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub expected: Option<String>,
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub action: Option<String>,
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub retry: Option<bool>,
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub target_state: Option<String>,
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub step: Option<String>,
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub run: Option<String>,
147}
148
149impl Diagnostic {
150 #[must_use]
153 pub fn new(reason: Reason, message: impl Into<String>) -> Self {
154 Self {
155 schema: DIAGNOSTIC_SCHEMA,
156 reason,
157 message: message.into(),
158 expected: None,
159 action: None,
160 retry: None,
161 target_state: None,
162 step: None,
163 run: None,
164 }
165 }
166
167 #[must_use]
169 pub fn expected(mut self, expected: impl Into<String>) -> Self {
170 self.expected = Some(expected.into());
171 self
172 }
173
174 #[must_use]
176 pub fn action(mut self, action: impl Into<String>) -> Self {
177 self.action = Some(action.into());
178 self
179 }
180
181 #[must_use]
183 pub fn target_state(mut self, state: impl Into<String>) -> Self {
184 self.target_state = Some(state.into());
185 self
186 }
187
188 #[must_use]
190 pub fn step(mut self, step: impl Into<String>) -> Self {
191 self.step = Some(step.into());
192 self
193 }
194
195 #[must_use]
197 pub fn run(mut self, run: impl Into<String>) -> Self {
198 self.run = Some(run.into());
199 self
200 }
201
202 #[must_use]
206 pub fn render_human(&self) -> String {
207 use std::fmt::Write as _;
208 let mut text = format!("error: {}", self.message);
209 if let Some(expected) = &self.expected {
210 let _ = write!(text, "\n expected {expected}");
211 }
212 if let Some(action) = &self.action {
213 let _ = write!(text, "\n next {action}");
214 }
215 if let Some(retry) = self.retry {
216 let answer = if retry {
217 "rerunning as-is can succeed"
218 } else {
219 "rerunning as-is fails the same way"
220 };
221 let _ = write!(text, "\n retry {answer}");
222 }
223 if let Some(state) = &self.target_state {
224 let _ = write!(text, "\n state {state}");
225 }
226 if let Some(run) = &self.run {
227 let _ = write!(text, "\n run {run}");
228 }
229 text
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 #![allow(clippy::expect_used)]
236
237 use super::{Diagnostic, REASONS, Reason};
238
239 #[test]
242 fn the_reason_vocabulary_is_closed() {
243 let wire: Vec<&str> = REASONS.iter().map(|reason| reason.as_str()).collect();
244 assert_eq!(
245 wire,
246 [
247 "usage",
248 "target-not-found",
249 "forge-undetected",
250 "forge-unsupported",
251 "prerequisite-unmet",
252 "forge-authentication",
253 "forge-permission",
254 "forge-rate-limit",
255 "forge-temporary",
256 "remote-conflict",
257 "destructive-refusal",
258 "state-drift",
259 "unsupported-schema",
260 "journal-unavailable",
261 "subprocess-spawn",
262 "subprocess-failed",
263 "io",
264 "internal",
265 "config-invalid",
266 ]
267 );
268 }
269
270 #[test]
271 fn the_serde_rendering_and_the_wire_form_agree() {
272 for reason in REASONS {
273 let json = serde_json::to_string(&reason).expect("a reason serializes");
274 assert_eq!(json, format!("\"{}\"", reason.as_str()));
275 }
276 }
277
278 #[test]
282 fn the_diagnostic_schema_snapshot_holds() {
283 let full = Diagnostic::new(Reason::StateDrift, "what happened")
284 .expected("what would have had to be true")
285 .action("the command that fixes it")
286 .target_state("what the run left behind");
287 assert_eq!(
288 serde_json::to_string(&full).expect("a diagnostic serializes"),
289 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"}"#
290 );
291 let bare = Diagnostic::new(Reason::Io, "disk fell over");
292 assert_eq!(
293 serde_json::to_string(&bare).expect("a diagnostic serializes"),
294 r#"{"schema":"rk.diagnostic/1","reason":"io","message":"disk fell over"}"#,
295 "an unknown hint must be omitted, not serialized as null"
296 );
297 }
298
299 #[test]
300 fn the_human_rendering_answers_only_what_is_known() {
301 let bare = Diagnostic::new(Reason::Io, "disk fell over");
302 assert_eq!(bare.render_human(), "error: disk fell over");
303 let full = Diagnostic::new(Reason::StateDrift, "the target drifted")
304 .expected("a clean target")
305 .action("rk init --apply")
306 .target_state("nothing was written");
307 assert_eq!(
308 full.render_human(),
309 "error: the target drifted\n expected a clean target\n next rk init --apply\n state nothing was written"
310 );
311 }
312}