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