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