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