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