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}