Skip to main content

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}