mif_problem/lib.rs
1//! Dual-consumer error output: an RFC 9457 Problem Details envelope, shared
2//! across the MIF (Modeled Information Format) workspace's crates.
3//!
4//! A command-line tool or MCP server now answers to two audiences: the human
5//! who reads the terminal, and the LLM agent that parses the bytes and
6//! decides whether to retry, escalate, or abandon. The human is served by an
7//! error enum's ordinary `Display` output (unchanged). The agent is served by
8//! [`ProblemDetails`] — a serializable [RFC 9457] *Problem Details* envelope
9//! carrying the five standard members plus the three agent extensions
10//! (`retry_after`, `suggested_fix`, `code_actions`) and an [`Applicability`]
11//! marker on every suggested fix and code action.
12//!
13//! This workspace deliberately has **no shared top-level error type** (see
14//! this repo's `CLAUDE.md`, "Why `thiserror` for Errors") — `mif-schema`,
15//! `mif-ontology`, `mif-frontmatter`, `mif-embed`, and `mif-store` each fail
16//! in genuinely different ways and keep their own `thiserror` enum. This
17//! crate does not change that: instead of one central `Error` enum with a
18//! `meta()` match (the pattern this crate adapts from
19//! `attested-delivery/rust-template`'s `crates/problem.rs`), each crate's own
20//! error enum implements [`ToProblem`] directly, using [`ProblemMeta`] to
21//! keep its own per-variant type-URI/status/exit-code bookkeeping in one
22//! place.
23//!
24//! [RFC 9457]: https://www.rfc-editor.org/rfc/rfc9457
25
26use serde::{Deserialize, Serialize};
27
28/// Base URI under which this workspace's problem-type identifiers are
29/// namespaced.
30///
31/// Every implementer's `type` URI is derived as
32/// `{ERROR_TYPE_BASE_URI}/{slug}/{version}` (e.g.
33/// `https://mif-spec.dev/errors/invalid-input/v1`). This is an identifier
34/// namespace, not a claim that a reference page is published at that path
35/// today — whether to publish live problem-type documentation there is a
36/// separate, later decision.
37pub const ERROR_TYPE_BASE_URI: &str = "https://mif-spec.dev/errors";
38
39/// How confidently an agent may apply a [`SuggestedFix`] or [`CodeAction`].
40///
41/// Modeled on the rustc diagnostic `Applicability` enum. Without this marker
42/// an agent may apply a plausible-looking but wrong edit, so every suggested
43/// fix and code action carries one. `Unspecified` is the safe default and
44/// must be treated as `MaybeIncorrect` (escalate to a human) by consumers.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47#[non_exhaustive]
48pub enum Applicability {
49 /// The agent may apply the edit and retry without human confirmation.
50 MachineApplicable,
51 /// The agent must escalate to a human before applying.
52 MaybeIncorrect,
53 /// The fix contains slots the agent must fill; lower confidence.
54 HasPlaceholders,
55 /// Applicability is unknown; consumers treat this as [`Self::MaybeIncorrect`].
56 #[default]
57 Unspecified,
58}
59
60/// A recovery suggestion tagged with an [`Applicability`] marker.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[non_exhaustive]
63pub struct SuggestedFix {
64 /// Free-text description of the recovery action.
65 pub description: String,
66 /// How confidently the fix may be applied.
67 pub applicability: Applicability,
68}
69
70impl SuggestedFix {
71 /// Creates a suggested fix from a description and an applicability marker.
72 ///
73 /// # Arguments
74 ///
75 /// * `description` - What the consumer should do to recover.
76 /// * `applicability` - How confidently the fix may be applied.
77 ///
78 /// # Returns
79 ///
80 /// A new [`SuggestedFix`].
81 #[must_use]
82 pub fn new(description: impl Into<String>, applicability: Applicability) -> Self {
83 Self {
84 description: description.into(),
85 applicability,
86 }
87 }
88}
89
90/// A structured edit an agent can apply directly, modeled on the LSP
91/// `CodeAction` interface.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[non_exhaustive]
94pub struct CodeAction {
95 /// Short, human-readable title for the action.
96 pub title: String,
97 /// The kind of action (e.g. `"quickfix"`), following LSP conventions.
98 pub kind: String,
99 /// How confidently the action may be applied.
100 pub applicability: Applicability,
101}
102
103impl CodeAction {
104 /// Creates a code action from a title, kind, and applicability marker.
105 ///
106 /// # Arguments
107 ///
108 /// * `title` - Short summary of the action.
109 /// * `kind` - LSP-style action kind, e.g. `"quickfix"`.
110 /// * `applicability` - How confidently the action may be applied.
111 ///
112 /// # Returns
113 ///
114 /// A new [`CodeAction`].
115 #[must_use]
116 pub fn new(
117 title: impl Into<String>,
118 kind: impl Into<String>,
119 applicability: Applicability,
120 ) -> Self {
121 Self {
122 title: title.into(),
123 kind: kind.into(),
124 applicability,
125 }
126 }
127}
128
129/// Classifies a `std::io::Error` for RFC 9457 rendering.
130///
131/// Every crate wrapping a bare I/O failure (reading an input file, an
132/// ontology definition, a cached model file, ...) uses this to agree on how
133/// a likely path mistake differs from a genuine I/O fault.
134///
135/// [`std::io::ErrorKind::NotFound`] and [`std::io::ErrorKind::PermissionDenied`]
136/// are treated as probably-caller-input mistakes — a 4xx status
137/// (404/403 respectively) with [`Applicability::MaybeIncorrect`] and a
138/// "verify the path" suggested fix. Every other kind is treated as a
139/// genuine I/O fault — status 500 with [`Applicability::Unspecified`] and a
140/// fix that does not imply user error, since an agent branching on the
141/// numeric `status` field must not misclassify a disk/permissions failure as
142/// something the caller can simply correct and retry.
143///
144/// # Returns
145///
146/// The `(status, suggested_fix, code_action)` triple to attach to the
147/// envelope in place of the caller's own static defaults.
148#[must_use]
149pub fn classify_io_error(error: &std::io::Error) -> (u16, SuggestedFix, CodeAction) {
150 match error.kind() {
151 std::io::ErrorKind::NotFound => (
152 404,
153 SuggestedFix::new(
154 "Verify the path exists, then retry.",
155 Applicability::MaybeIncorrect,
156 ),
157 CodeAction::new(
158 "Correct the file path",
159 "quickfix",
160 Applicability::MaybeIncorrect,
161 ),
162 ),
163 std::io::ErrorKind::PermissionDenied => (
164 403,
165 SuggestedFix::new(
166 "Verify the path is readable (check file/directory permissions), then retry.",
167 Applicability::MaybeIncorrect,
168 ),
169 CodeAction::new(
170 "Correct the file permissions or path",
171 "quickfix",
172 Applicability::MaybeIncorrect,
173 ),
174 ),
175 _ => (
176 500,
177 SuggestedFix::new(
178 "This indicates an I/O problem, not a mistaken path. Check disk and \
179 permissions state and retry.",
180 Applicability::Unspecified,
181 ),
182 CodeAction::new(
183 "Retry the operation",
184 "quickfix",
185 Applicability::Unspecified,
186 ),
187 ),
188 }
189}
190
191/// An [RFC 9457] *Problem Details* envelope for machine consumers.
192///
193/// Serializes under the `application/problem+json` media type. It carries
194/// the five standard members (`type`, `title`, `status`, `detail`,
195/// `instance`), the three agent extensions (`retry_after`, `suggested_fix`,
196/// `code_actions`), and the optional `exit_code` extension. `retry_after`
197/// serializes even when `None` (as JSON `null`) so an agent never has to
198/// guess whether a class is transient.
199///
200/// Build one with [`ProblemDetails::new`] and the `with_*` methods, or
201/// (preferred, for a crate's own error enum) with [`ProblemMeta::into_details`].
202///
203/// [RFC 9457]: https://www.rfc-editor.org/rfc/rfc9457
204///
205/// # Examples
206///
207/// ```rust
208/// use mif_problem::{Applicability, ProblemDetails, SuggestedFix};
209///
210/// let problem = ProblemDetails::new(
211/// "https://mif-spec.dev/errors/invalid-input/v1",
212/// "Invalid input",
213/// 400,
214/// "the supplied file was not valid JSON",
215/// "urn:mif-cli:invalid-input",
216/// )
217/// .with_exit_code(2)
218/// .with_suggested_fix(SuggestedFix::new(
219/// "Check the file is well-formed JSON and retry.",
220/// Applicability::MaybeIncorrect,
221/// ));
222///
223/// assert_eq!(problem.status, 400);
224/// assert_eq!(problem.retry_after, None);
225/// assert!(problem.to_json().contains("\"type\""));
226/// ```
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[non_exhaustive]
229pub struct ProblemDetails {
230 /// A URI reference identifying the problem type. Stable and versioned.
231 #[serde(rename = "type")]
232 pub problem_type: String,
233 /// Short, human-readable summary of the problem type. Stable per `type`.
234 pub title: String,
235 /// Numeric status mapping to a status class (see also `exit_code`).
236 pub status: u16,
237 /// Human-readable explanation specific to this occurrence.
238 pub detail: String,
239 /// URI reference identifying this specific occurrence.
240 pub instance: String,
241 /// When the operation may safely be retried (delta-seconds). Explicitly
242 /// `null` for non-transient errors so agents do not have to guess.
243 pub retry_after: Option<u64>,
244 /// A recovery suggestion, tagged with an applicability marker.
245 pub suggested_fix: Option<SuggestedFix>,
246 /// Structured edits the agent can apply directly.
247 pub code_actions: Vec<CodeAction>,
248 /// The process exit code emitted alongside the error, if known.
249 #[serde(skip_serializing_if = "Option::is_none")]
250 pub exit_code: Option<u8>,
251}
252
253impl ProblemDetails {
254 /// Creates an envelope from the five RFC 9457 standard members.
255 ///
256 /// `retry_after`, `suggested_fix`, `code_actions`, and `exit_code` start
257 /// empty; add them with the `with_*` methods.
258 ///
259 /// # Arguments
260 ///
261 /// * `problem_type` - Stable, versioned problem-type URI.
262 /// * `title` - Short summary, stable per `problem_type`.
263 /// * `status` - Numeric status class.
264 /// * `detail` - This-occurrence explanation.
265 /// * `instance` - URI identifying this occurrence.
266 ///
267 /// # Returns
268 ///
269 /// A new [`ProblemDetails`] with no extensions set.
270 #[must_use]
271 pub fn new(
272 problem_type: impl Into<String>,
273 title: impl Into<String>,
274 status: u16,
275 detail: impl Into<String>,
276 instance: impl Into<String>,
277 ) -> Self {
278 Self {
279 problem_type: problem_type.into(),
280 title: title.into(),
281 status,
282 detail: detail.into(),
283 instance: instance.into(),
284 retry_after: None,
285 suggested_fix: None,
286 code_actions: Vec::new(),
287 exit_code: None,
288 }
289 }
290
291 /// Sets `retry_after` to `seconds`, marking the error as transient.
292 #[must_use]
293 pub const fn with_retry_after(mut self, seconds: u64) -> Self {
294 self.retry_after = Some(seconds);
295 self
296 }
297
298 /// Attaches a [`SuggestedFix`].
299 #[must_use]
300 pub fn with_suggested_fix(mut self, fix: SuggestedFix) -> Self {
301 self.suggested_fix = Some(fix);
302 self
303 }
304
305 /// Appends a [`CodeAction`] to `code_actions`.
306 #[must_use]
307 pub fn with_code_action(mut self, action: CodeAction) -> Self {
308 self.code_actions.push(action);
309 self
310 }
311
312 /// Sets the `exit_code` extension.
313 #[must_use]
314 pub const fn with_exit_code(mut self, code: u8) -> Self {
315 self.exit_code = Some(code);
316 self
317 }
318
319 /// Serializes the envelope as a compact `application/problem+json` string.
320 ///
321 /// # Returns
322 ///
323 /// The compact JSON representation. Returns `"{}"` only if serialization
324 /// fails, which cannot happen for this all-owned, self-describing struct.
325 #[must_use]
326 pub fn to_json(&self) -> String {
327 serde_json::to_string(self).unwrap_or_else(|_| String::from("{}"))
328 }
329
330 /// Serializes the envelope as pretty-printed `application/problem+json`.
331 ///
332 /// # Returns
333 ///
334 /// The indented JSON representation, suitable for human inspection.
335 #[must_use]
336 pub fn to_json_pretty(&self) -> String {
337 serde_json::to_string_pretty(self).unwrap_or_else(|_| String::from("{}"))
338 }
339}
340
341/// Reusable per-variant problem-type metadata.
342///
343/// An implementing crate defines one [`ProblemMeta`] per error variant (slug,
344/// version, title, status, exit code) and converts it to a full
345/// [`ProblemDetails`] with [`ProblemMeta::into_details`], keeping the
346/// URI/version/status/exit-code bookkeeping for that crate's errors in one
347/// place — extending the enum means adding one match arm, not editing several
348/// parallel constructions.
349#[derive(Debug, Clone, Copy)]
350pub struct ProblemMeta {
351 /// Stable, URL-safe slug for the problem type.
352 pub slug: &'static str,
353 /// Version segment of the type URI (e.g. `"v1"`). Per-type, so one type
354 /// can advance independently of the others.
355 pub version: &'static str,
356 /// Short, stable title for the problem type.
357 pub title: &'static str,
358 /// Numeric status class.
359 pub status: u16,
360 /// Process exit code emitted alongside the error.
361 pub exit_code: u8,
362}
363
364impl ProblemMeta {
365 /// The stable, version-embedded problem-type URI for this metadata.
366 ///
367 /// Derived as `{ERROR_TYPE_BASE_URI}/{slug}/{version}`. The version is
368 /// the stability commitment: the meaning of a given URI never changes; a
369 /// breaking change to a problem type ships a new version (e.g. `/v2`)
370 /// rather than redefining the existing one.
371 ///
372 /// # Returns
373 ///
374 /// The fully-qualified type URI for this metadata.
375 #[must_use]
376 pub fn type_uri(&self) -> String {
377 format!("{ERROR_TYPE_BASE_URI}/{}/{}", self.slug, self.version)
378 }
379
380 /// Builds a [`ProblemDetails`] from this metadata, an owning crate name,
381 /// and an occurrence-specific `detail` message.
382 ///
383 /// # Arguments
384 ///
385 /// * `crate_name` - The implementing crate's own name, e.g.
386 /// `env!("CARGO_PKG_NAME")` evaluated at the call site (this macro must
387 /// be invoked in the calling crate, not here, to expand correctly).
388 /// * `detail` - This-occurrence explanation, typically the error's own
389 /// `Display` string so the human and machine renderings never drift.
390 ///
391 /// # Returns
392 ///
393 /// A [`ProblemDetails`] with `exit_code` pre-populated from this
394 /// metadata; attach a `suggested_fix`/`code_action` with the `with_*`
395 /// methods as needed.
396 #[must_use]
397 pub fn into_details(self, crate_name: &str, detail: impl Into<String>) -> ProblemDetails {
398 ProblemDetails::new(
399 self.type_uri(),
400 self.title,
401 self.status,
402 detail,
403 format!("urn:{crate_name}:{}", self.slug),
404 )
405 .with_exit_code(self.exit_code)
406 }
407}
408
409/// Implemented by each crate's own error enum to map it to a
410/// [`ProblemDetails`] envelope.
411///
412/// Keeps error enums scoped to each crate's own failure modes (this
413/// workspace has no shared top-level error type) while sharing one envelope
414/// shape across the workspace. Requires [`std::fmt::Display`] (already
415/// derived by `thiserror::Error` on every implementing enum) so the default
416/// [`ToProblem::render`] can reuse it for pretty output.
417///
418/// # Examples
419///
420/// ```rust
421/// use mif_problem::{Applicability, CodeAction, ProblemDetails, ProblemMeta, SuggestedFix, ToProblem};
422///
423/// #[derive(Debug, thiserror::Error)]
424/// enum ExampleError {
425/// #[error("input was empty")]
426/// Empty,
427/// }
428///
429/// impl ToProblem for ExampleError {
430/// fn to_problem(&self) -> ProblemDetails {
431/// let meta = ProblemMeta {
432/// slug: "empty-input",
433/// version: "v1",
434/// title: "Empty input",
435/// status: 400,
436/// exit_code: 2,
437/// };
438/// meta.into_details(env!("CARGO_PKG_NAME"), self.to_string())
439/// .with_suggested_fix(SuggestedFix::new(
440/// "Supply a non-empty input.",
441/// Applicability::MaybeIncorrect,
442/// ))
443/// .with_code_action(CodeAction::new(
444/// "Provide a value",
445/// "quickfix",
446/// Applicability::MaybeIncorrect,
447/// ))
448/// }
449/// }
450///
451/// let err = ExampleError::Empty;
452/// assert_eq!(err.to_problem().status, 400);
453/// assert_eq!(err.render(mif_problem::OutputFormat::Pretty), "Error: input was empty");
454/// ```
455pub trait ToProblem: std::fmt::Display {
456 /// Maps `self` to a fully-populated [`ProblemDetails`] envelope.
457 fn to_problem(&self) -> ProblemDetails;
458
459 /// Renders `self` for the given [`OutputFormat`].
460 ///
461 /// Pretty rendering is `Error: {self}`, matching this workspace's
462 /// existing `mif-cli`/`mif-mcp` error text. JSON rendering is the
463 /// compact RFC 9457 envelope from [`ToProblem::to_problem`].
464 ///
465 /// # Arguments
466 ///
467 /// * `format` - The format to render.
468 ///
469 /// # Returns
470 ///
471 /// The rendered error string (without a trailing newline).
472 fn render(&self, format: OutputFormat) -> String {
473 match format {
474 OutputFormat::Pretty => format!("Error: {self}"),
475 OutputFormat::Json => self.to_problem().to_json(),
476 }
477 }
478}
479
480/// The rendering format for an error reported to a consumer.
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482#[non_exhaustive]
483pub enum OutputFormat {
484 /// The human-readable `Error: {display}` line.
485 Pretty,
486 /// The RFC 9457 `application/problem+json` envelope.
487 Json,
488}
489
490impl OutputFormat {
491 /// Selects the output format for a consumer.
492 ///
493 /// JSON when `--format=json` is given explicitly, or when no format is
494 /// given and the error stream is not a terminal. Pretty when
495 /// `--format=pretty` is given, or when no format is given and the error
496 /// stream is a terminal. An unrecognized explicit value falls back to
497 /// the TTY heuristic.
498 ///
499 /// # Arguments
500 ///
501 /// * `explicit` - The value of an explicit `--format` flag, if any.
502 /// * `is_terminal` - Whether the error stream is a TTY.
503 ///
504 /// # Returns
505 ///
506 /// The selected [`OutputFormat`].
507 #[must_use]
508 pub fn select(explicit: Option<&str>, is_terminal: bool) -> Self {
509 match explicit {
510 Some("json") => Self::Json,
511 Some("pretty") => Self::Pretty,
512 _ if is_terminal => Self::Pretty,
513 _ => Self::Json,
514 }
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::{
521 Applicability, CodeAction, OutputFormat, ProblemDetails, ProblemMeta, SuggestedFix,
522 ToProblem, classify_io_error,
523 };
524
525 #[derive(Debug, thiserror::Error)]
526 enum TestError {
527 #[error("invalid input: {0}")]
528 InvalidInput(String),
529 #[error("operation failed")]
530 OperationFailed,
531 }
532
533 impl TestError {
534 const fn meta(&self) -> ProblemMeta {
535 match self {
536 Self::InvalidInput(_) => ProblemMeta {
537 slug: "invalid-input",
538 version: "v1",
539 title: "Invalid input",
540 status: 400,
541 exit_code: 2,
542 },
543 Self::OperationFailed => ProblemMeta {
544 slug: "operation-failed",
545 version: "v1",
546 title: "Operation failed",
547 status: 500,
548 exit_code: 1,
549 },
550 }
551 }
552 }
553
554 impl ToProblem for TestError {
555 fn to_problem(&self) -> ProblemDetails {
556 self.meta()
557 .into_details("mif-problem-tests", self.to_string())
558 }
559 }
560
561 #[test]
562 fn applicability_serializes_snake_case() {
563 let json = serde_json::to_string(&Applicability::MachineApplicable).unwrap();
564 assert_eq!(json, "\"machine_applicable\"");
565 assert_eq!(Applicability::default(), Applicability::Unspecified);
566 }
567
568 #[test]
569 fn builder_sets_every_extension() {
570 let problem = ProblemDetails::new("t", "T", 429, "d", "urn:x")
571 .with_retry_after(180)
572 .with_suggested_fix(SuggestedFix::new("wait", Applicability::MachineApplicable))
573 .with_code_action(CodeAction::new(
574 "retry",
575 "quickfix",
576 Applicability::MachineApplicable,
577 ))
578 .with_exit_code(2);
579
580 assert_eq!(problem.retry_after, Some(180));
581 assert_eq!(problem.exit_code, Some(2));
582 assert_eq!(problem.code_actions.len(), 1);
583 assert_eq!(
584 problem.suggested_fix.unwrap().applicability,
585 Applicability::MachineApplicable
586 );
587 }
588
589 #[test]
590 fn distinct_variants_map_to_distinct_versioned_envelopes() {
591 let invalid = TestError::InvalidInput("bad".to_string()).to_problem();
592 let failed = TestError::OperationFailed.to_problem();
593
594 assert_eq!(
595 invalid.problem_type,
596 "https://mif-spec.dev/errors/invalid-input/v1"
597 );
598 assert_eq!(invalid.status, 400);
599 assert_eq!(invalid.detail, "invalid input: bad");
600 assert_eq!(invalid.instance, "urn:mif-problem-tests:invalid-input");
601 assert_eq!(invalid.retry_after, None);
602 assert_eq!(invalid.exit_code, Some(2));
603
604 assert_eq!(
605 failed.problem_type,
606 "https://mif-spec.dev/errors/operation-failed/v1"
607 );
608 assert_eq!(failed.status, 500);
609 assert_eq!(failed.exit_code, Some(1));
610 assert_ne!(invalid.problem_type, failed.problem_type);
611 }
612
613 #[test]
614 fn json_envelope_carries_all_required_members() {
615 let json = TestError::OperationFailed.to_problem().to_json();
616 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
617
618 for member in ["type", "title", "status", "detail", "instance"] {
619 assert!(value.get(member).is_some(), "missing {member}");
620 }
621 assert!(value.get("retry_after").is_some());
622 assert!(value["retry_after"].is_null());
623 assert_eq!(value["exit_code"], 1);
624 }
625
626 #[test]
627 fn pretty_render_is_error_prefixed_display() {
628 let err = TestError::OperationFailed;
629 assert_eq!(err.render(OutputFormat::Pretty), "Error: operation failed");
630 }
631
632 #[test]
633 fn json_render_matches_envelope_json() {
634 let err = TestError::OperationFailed;
635 assert_eq!(err.render(OutputFormat::Json), err.to_problem().to_json());
636 }
637
638 #[test]
639 fn format_selection_honors_flag_then_tty() {
640 assert_eq!(OutputFormat::select(Some("json"), true), OutputFormat::Json);
641 assert_eq!(
642 OutputFormat::select(Some("pretty"), false),
643 OutputFormat::Pretty
644 );
645 assert_eq!(OutputFormat::select(None, true), OutputFormat::Pretty);
646 assert_eq!(OutputFormat::select(None, false), OutputFormat::Json);
647 assert_eq!(
648 OutputFormat::select(Some("xml"), true),
649 OutputFormat::Pretty
650 );
651 }
652
653 #[test]
654 fn envelope_round_trips_through_json() {
655 let problem = TestError::OperationFailed.to_problem();
656 let json = problem.to_json();
657 let back: ProblemDetails = serde_json::from_str(&json).unwrap();
658 assert_eq!(problem, back);
659 }
660
661 #[test]
662 fn pretty_json_is_indented() {
663 let pretty = TestError::OperationFailed.to_problem().to_json_pretty();
664 assert!(pretty.contains('\n'));
665 assert!(pretty.contains(" \"type\""));
666 }
667
668 #[test]
669 fn classify_io_error_treats_not_found_as_a_likely_path_mistake() {
670 let error = std::io::Error::from(std::io::ErrorKind::NotFound);
671 let (status, fix, action) = classify_io_error(&error);
672 assert_eq!(status, 404);
673 assert_eq!(fix.applicability, Applicability::MaybeIncorrect);
674 assert_eq!(action.applicability, Applicability::MaybeIncorrect);
675 }
676
677 #[test]
678 fn classify_io_error_treats_permission_denied_as_a_likely_path_mistake() {
679 let error = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
680 let (status, fix, action) = classify_io_error(&error);
681 assert_eq!(status, 403);
682 assert_eq!(fix.applicability, Applicability::MaybeIncorrect);
683 assert_eq!(action.applicability, Applicability::MaybeIncorrect);
684 }
685
686 #[test]
687 fn classify_io_error_keeps_a_genuine_io_fault_at_500_and_does_not_imply_user_error() {
688 let error = std::io::Error::from(std::io::ErrorKind::Other);
689 let (status, fix, action) = classify_io_error(&error);
690 assert_eq!(status, 500);
691 assert_eq!(fix.applicability, Applicability::Unspecified);
692 assert_eq!(action.applicability, Applicability::Unspecified);
693 }
694}