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 use super::{Diagnostic, REASONS, Reason};
236
237 #[test]
240 fn the_reason_vocabulary_is_closed() {
241 let wire: Vec<&str> = REASONS.iter().map(|reason| reason.as_str()).collect();
242 assert_eq!(
243 wire,
244 [
245 "usage",
246 "target-not-found",
247 "forge-undetected",
248 "forge-unsupported",
249 "prerequisite-unmet",
250 "forge-authentication",
251 "forge-permission",
252 "forge-rate-limit",
253 "forge-temporary",
254 "remote-conflict",
255 "destructive-refusal",
256 "state-drift",
257 "unsupported-schema",
258 "journal-unavailable",
259 "subprocess-spawn",
260 "subprocess-failed",
261 "io",
262 "internal",
263 "config-invalid",
264 ]
265 );
266 }
267
268 #[test]
269 fn the_serde_rendering_and_the_wire_form_agree() {
270 for reason in REASONS {
271 let json = serde_json::to_string(&reason).expect("a reason serializes");
272 assert_eq!(json, format!("\"{}\"", reason.as_str()));
273 }
274 }
275
276 #[test]
280 fn the_diagnostic_schema_snapshot_holds() {
281 let full = Diagnostic::new(Reason::StateDrift, "what happened")
282 .expected("what would have had to be true")
283 .action("the command that fixes it")
284 .target_state("what the run left behind");
285 assert_eq!(
286 serde_json::to_string(&full).expect("a diagnostic serializes"),
287 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"}"#
288 );
289 let bare = Diagnostic::new(Reason::Io, "disk fell over");
290 assert_eq!(
291 serde_json::to_string(&bare).expect("a diagnostic serializes"),
292 r#"{"schema":"rk.diagnostic/1","reason":"io","message":"disk fell over"}"#,
293 "an unknown hint must be omitted, not serialized as null"
294 );
295 }
296
297 #[test]
298 fn the_human_rendering_answers_only_what_is_known() {
299 let bare = Diagnostic::new(Reason::Io, "disk fell over");
300 assert_eq!(bare.render_human(), "error: disk fell over");
301 let full = Diagnostic::new(Reason::StateDrift, "the target drifted")
302 .expected("a clean target")
303 .action("rk init --apply")
304 .target_state("nothing was written");
305 assert_eq!(
306 full.render_human(),
307 "error: the target drifted\n expected a clean target\n next rk init --apply\n state nothing was written"
308 );
309 }
310}