Skip to main content

pdfluent/
error.rs

1//! Error types for `pdfluent`.
2//!
3//! One [`Error`] enum covers all public operations. The enum is
4//! `#[non_exhaustive]` to permit additional variants in minor releases
5//! without breaking match exhaustiveness.
6//!
7//! Each variant carries:
8//! - a stable [`code`](Error::code) string of the form `E-<CATEGORY>-<SPECIFIC>`,
9//! - a deep-linked [`docs_url`](Error::docs_url) to
10//!   `https://pdfluent.com/errors/<code>`,
11//! - a human-readable message via the [`std::fmt::Display`] implementation.
12//!
13//! See RFC 0001 §5 for the full contract.
14
15use std::path::PathBuf;
16
17use crate::capability::Capability;
18use crate::compliance::{PdfAProfile, Violation};
19use crate::tier::Tier;
20
21/// Unified result type for the `pdfluent` crate.
22pub type Result<T> = std::result::Result<T, Error>;
23
24/// Top-level error type for all `pdfluent` operations.
25#[derive(Debug)]
26#[non_exhaustive]
27pub enum Error {
28    // ---------- I/O ----------
29    /// Underlying I/O operation failed.
30    Io {
31        /// The original `std::io::Error`.
32        source: std::io::Error,
33        /// Path under operation, if applicable.
34        path: Option<PathBuf>,
35    },
36    /// File not found at the given path.
37    FileNotFound {
38        /// Path that was searched.
39        path: PathBuf,
40    },
41
42    // ---------- Parsing ----------
43    /// PDF is structurally invalid.
44    InvalidPdf {
45        /// Byte offset where parsing failed, if known.
46        byte_offset: Option<u64>,
47        /// Human-readable reason.
48        reason: String,
49    },
50    /// PDF version is newer than the supported maximum.
51    UnsupportedPdfVersion {
52        /// Version in the document header.
53        found: String,
54        /// Highest supported by this build.
55        supported_up_to: String,
56    },
57
58    // ---------- Compliance ----------
59    /// PDF/A validation failed against the requested profile.
60    PdfaValidationFailed {
61        /// Profile under validation.
62        profile: PdfAProfile,
63        /// All detected violations.
64        violations: Vec<Violation>,
65    },
66
67    // ---------- Security ----------
68    /// Decryption failed — wrong password or unsupported algorithm.
69    DecryptionFailed {
70        /// Specific failure cause.
71        reason: DecryptionFailureReason,
72    },
73    /// A digital signature is invalid.
74    InvalidSignature {
75        /// Form field name holding the signature.
76        field: String,
77        /// Reason the signature is invalid.
78        reason: String,
79    },
80
81    // ---------- Licensing ----------
82    /// Required capability is not available in the current tier.
83    FeatureNotInTier {
84        /// Capability that was requested.
85        capability: Capability,
86        /// The tier the user currently holds.
87        current_tier: Tier,
88        /// The minimum tier required.
89        required_tier: Tier,
90    },
91    /// Capability is gated behind a Cargo feature that is not compiled in.
92    CapabilityNotCompiled {
93        /// Capability that was requested.
94        capability: Capability,
95        /// Cargo feature flag to enable.
96        feature_flag: &'static str,
97    },
98    /// License key is malformed or expired.
99    InvalidLicense {
100        /// Human-readable reason.
101        reason: String,
102    },
103    /// License key is well-formed and signed, but its `expires_at` is in
104    /// the past.
105    ///
106    /// Surfaced by the signed-payload pathway only — mock `tier:X` keys
107    /// have no expiry and never produce this variant.
108    LicenseExpired {
109        /// Unix timestamp from the payload's `expires_at` field.
110        expires_at: u64,
111    },
112    /// License key is structurally a signed JSON payload but the Ed25519
113    /// signature does not verify against the configured public key.
114    ///
115    /// Indicates either a tampered payload or a payload signed by a
116    /// different private key. Treat as a hard failure — never fall
117    /// through to Trial.
118    LicenseInvalidSignature,
119    /// A license-enforced rate or usage limit was exceeded at runtime.
120    ///
121    /// Returned by `LicenseGuard::record_*` calls during operation; not
122    /// an activation-time error. Carries the metered resource, used
123    /// value, and configured cap.
124    LicenseRateLimited {
125        /// Metered resource name, e.g. `"api_calls"` or `"pages"`.
126        resource: String,
127        /// How many units have been consumed in the current window.
128        used: u64,
129        /// The license's hard cap for this resource in the current window.
130        limit: u64,
131    },
132
133    // ---------- Environment ----------
134    /// Operation is not supported in WebAssembly builds.
135    UnsupportedOnWasm {
136        /// Name of the attempted operation.
137        operation: &'static str,
138    },
139    /// A native dependency is required but not installed or discoverable.
140    MissingDependency {
141        /// Name of the missing dependency.
142        dep: &'static str,
143        /// Installation hint.
144        install_hint: &'static str,
145    },
146
147    // ---------- Budget ----------
148    /// Memory budget set via [`crate::OpenOptions::strict_memory_limit`] exceeded.
149    MemoryBudgetExceeded {
150        /// Bytes that would have been allocated.
151        requested: usize,
152        /// Configured limit.
153        limit: usize,
154    },
155    /// A configured [`ProcessingLimits`](pdf_engine::ProcessingLimits)
156    /// resource cap was exceeded while loading or processing the
157    /// document.
158    ///
159    /// Returned when the caller has set a limits object via
160    /// [`crate::OpenOptions::with_processing_limits`] and the input
161    /// breaches one of those caps. The `kind` field discriminates which
162    /// cap fired so callers can tell a "file too large" rejection from
163    /// e.g. an "image too large" rejection without parsing the message.
164    ResourceLimitExceeded {
165        /// Which resource cap fired.
166        kind: ResourceLimitKind,
167        /// Observed value (size in bytes / pixel count / depth, by kind).
168        observed: u64,
169        /// Configured limit (same units as `observed`).
170        limit: u64,
171    },
172
173    // ---------- Unsupported ----------
174    /// The requested operation or parameter is unsupported.
175    Unsupported(String),
176
177    // ---------- Internal ----------
178    /// Internal safety-net. Should never fire under normal operation.
179    Internal {
180        /// Diagnostic message.
181        message: String,
182        /// Crate version at build time.
183        crate_version: &'static str,
184    },
185}
186
187/// Specific cause of a decryption failure.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189#[non_exhaustive]
190pub enum DecryptionFailureReason {
191    /// Wrong password.
192    WrongPassword,
193    /// Encryption algorithm not supported.
194    UnsupportedAlgorithm,
195    /// Encryption dictionary is malformed.
196    MalformedDictionary,
197}
198
199/// Discriminator for a [`Error::ResourceLimitExceeded`] error.
200///
201/// Each variant maps onto one of the caps declared by
202/// [`pdf_engine::ProcessingLimits`]. The variants are deliberately
203/// stable across 1.x — a caller can branch on `kind` without parsing
204/// human-readable messages.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206#[non_exhaustive]
207pub enum ResourceLimitKind {
208    /// PDF file size exceeded
209    /// [`ProcessingLimits::max_file_bytes`](pdf_engine::ProcessingLimits::max_file_bytes).
210    FileTooLarge,
211    /// A decompressed stream exceeded
212    /// [`ProcessingLimits::max_stream_bytes`](pdf_engine::ProcessingLimits::max_stream_bytes).
213    StreamTooLarge,
214    /// An image XObject exceeded
215    /// [`ProcessingLimits::max_image_pixels`](pdf_engine::ProcessingLimits::max_image_pixels).
216    ImageTooLarge,
217    /// Indirect-reference depth exceeded
218    /// [`ProcessingLimits::max_object_depth`](pdf_engine::ProcessingLimits::max_object_depth).
219    ObjectDepthExceeded,
220    /// Content-stream operator count exceeded
221    /// [`ProcessingLimits::max_operator_count`](pdf_engine::ProcessingLimits::max_operator_count).
222    TooManyOperators,
223    /// XFA template nesting exceeded
224    /// [`ProcessingLimits::max_xfa_nesting_depth`](pdf_engine::ProcessingLimits::max_xfa_nesting_depth).
225    XfaNestingTooDeep,
226    /// FormCalc recursion exceeded
227    /// [`ProcessingLimits::max_formcalc_depth`](pdf_engine::ProcessingLimits::max_formcalc_depth).
228    FormCalcRecursionTooDeep,
229}
230
231impl std::fmt::Display for ResourceLimitKind {
232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        match self {
234            Self::FileTooLarge => f.write_str("file too large"),
235            Self::StreamTooLarge => f.write_str("decompressed stream too large"),
236            Self::ImageTooLarge => f.write_str("image too large (pixel count)"),
237            Self::ObjectDepthExceeded => f.write_str("object reference depth exceeded"),
238            Self::TooManyOperators => f.write_str("content stream operator count exceeded"),
239            Self::XfaNestingTooDeep => f.write_str("XFA template nesting too deep"),
240            Self::FormCalcRecursionTooDeep => f.write_str("FormCalc recursion too deep"),
241        }
242    }
243}
244
245impl From<pdf_engine::LimitError> for Error {
246    fn from(e: pdf_engine::LimitError) -> Self {
247        use pdf_engine::LimitError as LE;
248        let (kind, observed, limit) = match e {
249            LE::FileTooLarge {
250                actual_bytes,
251                limit_bytes,
252            } => (ResourceLimitKind::FileTooLarge, actual_bytes, limit_bytes),
253            LE::StreamTooLarge {
254                actual_bytes,
255                limit_bytes,
256            } => (ResourceLimitKind::StreamTooLarge, actual_bytes, limit_bytes),
257            LE::ImageTooLarge {
258                pixels,
259                limit_pixels,
260                ..
261            } => (ResourceLimitKind::ImageTooLarge, pixels, limit_pixels),
262            LE::ObjectDepthExceeded { depth, limit } => (
263                ResourceLimitKind::ObjectDepthExceeded,
264                depth as u64,
265                limit as u64,
266            ),
267            LE::TooManyOperators { count, limit } => {
268                (ResourceLimitKind::TooManyOperators, count, limit)
269            }
270            LE::XfaNestingTooDeep { depth, limit } => (
271                ResourceLimitKind::XfaNestingTooDeep,
272                depth as u64,
273                limit as u64,
274            ),
275            LE::FormCalcRecursionTooDeep { depth, limit } => (
276                ResourceLimitKind::FormCalcRecursionTooDeep,
277                depth as u64,
278                limit as u64,
279            ),
280        };
281        Error::ResourceLimitExceeded {
282            kind,
283            observed,
284            limit,
285        }
286    }
287}
288
289impl std::fmt::Display for DecryptionFailureReason {
290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291        match self {
292            Self::WrongPassword => f.write_str("wrong password"),
293            Self::UnsupportedAlgorithm => f.write_str("unsupported encryption algorithm"),
294            Self::MalformedDictionary => f.write_str("malformed encryption dictionary"),
295        }
296    }
297}
298
299impl Error {
300    /// Stable error code (`E-<CATEGORY>-<SPECIFIC>`), frozen per snapshot test.
301    ///
302    /// # Append-only policy
303    ///
304    /// Codes are **frozen** once assigned. You may:
305    /// - Add a new variant with a new code.
306    ///
307    /// You must **never**:
308    /// - Remove a code.
309    /// - Rename an existing code.
310    /// - Reassign a code to a different variant.
311    ///
312    /// Violating this policy breaks any consumer that stores or compares codes
313    /// (logs, analytics, downstream SDKs, client-side switch statements).
314    /// See `scripts/release/error_catalogue_sync.sh` for the CI gate.
315    pub const fn code(&self) -> &'static str {
316        match self {
317            Error::Io { .. } => "E-IO-GENERIC",
318            Error::FileNotFound { .. } => "E-IO-FILE-NOT-FOUND",
319            Error::InvalidPdf { .. } => "E-PARSE-INVALID-PDF",
320            Error::UnsupportedPdfVersion { .. } => "E-PARSE-UNSUPPORTED-VERSION",
321            Error::PdfaValidationFailed { .. } => "E-COMPLIANCE-PDFA-INVALID",
322            Error::DecryptionFailed { .. } => "E-SECURITY-DECRYPTION-FAILED",
323            Error::InvalidSignature { .. } => "E-SECURITY-INVALID-SIGNATURE",
324            Error::FeatureNotInTier { .. } => "E-LICENSE-FEATURE-NOT-IN-TIER",
325            Error::CapabilityNotCompiled { .. } => "E-LICENSE-CAPABILITY-NOT-COMPILED",
326            Error::InvalidLicense { .. } => "E-LICENSE-INVALID",
327            Error::LicenseExpired { .. } => "E-LICENSE-EXPIRED",
328            Error::LicenseInvalidSignature => "E-LICENSE-INVALID-SIGNATURE",
329            Error::LicenseRateLimited { .. } => "E-LICENSE-RATE-LIMITED",
330            Error::UnsupportedOnWasm { .. } => "E-ENV-UNSUPPORTED-ON-WASM",
331            Error::MissingDependency { .. } => "E-ENV-MISSING-DEPENDENCY",
332            Error::MemoryBudgetExceeded { .. } => "E-BUDGET-MEMORY-EXCEEDED",
333            Error::ResourceLimitExceeded { .. } => "E-BUDGET-RESOURCE-LIMIT",
334            Error::Unsupported(_) => "E-UNSUPPORTED",
335            Error::Internal { .. } => "E-INTERNAL",
336        }
337    }
338
339    /// Static deep-link to the documentation page for this error code.
340    pub const fn docs_url(&self) -> &'static str {
341        match self {
342            Error::Io { .. } => "https://pdfluent.com/errors/E-IO-GENERIC",
343            Error::FileNotFound { .. } => "https://pdfluent.com/errors/E-IO-FILE-NOT-FOUND",
344            Error::InvalidPdf { .. } => "https://pdfluent.com/errors/E-PARSE-INVALID-PDF",
345            Error::UnsupportedPdfVersion { .. } => {
346                "https://pdfluent.com/errors/E-PARSE-UNSUPPORTED-VERSION"
347            }
348            Error::PdfaValidationFailed { .. } => {
349                "https://pdfluent.com/errors/E-COMPLIANCE-PDFA-INVALID"
350            }
351            Error::DecryptionFailed { .. } => {
352                "https://pdfluent.com/errors/E-SECURITY-DECRYPTION-FAILED"
353            }
354            Error::InvalidSignature { .. } => {
355                "https://pdfluent.com/errors/E-SECURITY-INVALID-SIGNATURE"
356            }
357            Error::FeatureNotInTier { .. } => {
358                "https://pdfluent.com/errors/E-LICENSE-FEATURE-NOT-IN-TIER"
359            }
360            Error::CapabilityNotCompiled { .. } => {
361                "https://pdfluent.com/errors/E-LICENSE-CAPABILITY-NOT-COMPILED"
362            }
363            Error::InvalidLicense { .. } => "https://pdfluent.com/errors/E-LICENSE-INVALID",
364            Error::LicenseExpired { .. } => "https://pdfluent.com/errors/E-LICENSE-EXPIRED",
365            Error::LicenseInvalidSignature => {
366                "https://pdfluent.com/errors/E-LICENSE-INVALID-SIGNATURE"
367            }
368            Error::LicenseRateLimited { .. } => {
369                "https://pdfluent.com/errors/E-LICENSE-RATE-LIMITED"
370            }
371            Error::UnsupportedOnWasm { .. } => {
372                "https://pdfluent.com/errors/E-ENV-UNSUPPORTED-ON-WASM"
373            }
374            Error::MissingDependency { .. } => {
375                "https://pdfluent.com/errors/E-ENV-MISSING-DEPENDENCY"
376            }
377            Error::MemoryBudgetExceeded { .. } => {
378                "https://pdfluent.com/errors/E-BUDGET-MEMORY-EXCEEDED"
379            }
380            Error::ResourceLimitExceeded { .. } => {
381                "https://pdfluent.com/errors/E-BUDGET-RESOURCE-LIMIT"
382            }
383            Error::Unsupported(_) => "https://pdfluent.com/errors/E-UNSUPPORTED",
384            Error::Internal { .. } => "https://pdfluent.com/errors/E-INTERNAL",
385        }
386    }
387}
388
389impl std::fmt::Display for Error {
390    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391        match self {
392            Error::Io { source, path } => match path {
393                Some(p) => write!(f, "I/O error on {}: {source}", p.display()),
394                None => write!(f, "I/O error: {source}"),
395            },
396            Error::FileNotFound { path } => write!(f, "File not found: {}", path.display()),
397            Error::InvalidPdf { byte_offset, reason } => match byte_offset {
398                Some(o) => write!(f, "Invalid PDF at byte {o}: {reason}"),
399                None => write!(f, "Invalid PDF: {reason}"),
400            },
401            Error::UnsupportedPdfVersion { found, supported_up_to } => write!(
402                f,
403                "Unsupported PDF version {found} (this build supports up to {supported_up_to})"
404            ),
405            Error::PdfaValidationFailed { profile, violations } => write!(
406                f,
407                "PDF/A validation failed for profile {profile:?} with {} violation(s)",
408                violations.len()
409            ),
410            Error::DecryptionFailed { reason } => write!(f, "Decryption failed: {reason}"),
411            Error::InvalidSignature { field, reason } => {
412                write!(f, "Signature '{field}' is invalid: {reason}")
413            }
414            Error::FeatureNotInTier {
415                capability,
416                current_tier,
417                required_tier,
418            } => write!(
419                f,
420                "Capability {capability:?} requires tier {required_tier:?}; current tier is {current_tier:?}.\n  Upgrade: https://pdfluent.com/pricing\n  Docs: {}",
421                self.docs_url()
422            ),
423            Error::CapabilityNotCompiled {
424                capability,
425                feature_flag,
426            } => write!(
427                f,
428                "Capability {capability:?} requires the `{feature_flag}` Cargo feature, which is not enabled in this build.\n  Docs: {}",
429                self.docs_url()
430            ),
431            Error::InvalidLicense { reason } => {
432                write!(f, "Invalid license: {reason}\n  Docs: {}", self.docs_url())
433            }
434            Error::LicenseExpired { expires_at } => write!(
435                f,
436                "License expired at unix timestamp {expires_at}.\n  Renew: https://pdfluent.com/pricing\n  Docs: {}",
437                self.docs_url()
438            ),
439            Error::LicenseInvalidSignature => write!(
440                f,
441                "License signature does not verify against the configured public key — tampered or wrong-key payload.\n  Docs: {}",
442                self.docs_url()
443            ),
444            Error::LicenseRateLimited {
445                resource,
446                used,
447                limit,
448            } => write!(
449                f,
450                "Rate limit exceeded: {used}/{limit} {resource} in the current window.\n  Upgrade or wait for window reset.\n  Docs: {}",
451                self.docs_url()
452            ),
453            Error::UnsupportedOnWasm { operation } => write!(
454                f,
455                "Operation `{operation}` is not supported on wasm32 targets.\n  Docs: {}",
456                self.docs_url()
457            ),
458            Error::MissingDependency {
459                dep,
460                install_hint,
461            } => write!(
462                f,
463                "Missing dependency: {dep}.\n  Install: {install_hint}\n  Docs: {}",
464                self.docs_url()
465            ),
466            Error::MemoryBudgetExceeded { requested, limit } => write!(
467                f,
468                "Memory budget exceeded: requested {requested} bytes, limit is {limit}"
469            ),
470            Error::ResourceLimitExceeded {
471                kind,
472                observed,
473                limit,
474            } => write!(
475                f,
476                "Resource limit exceeded: {kind} (observed {observed}, limit {limit}).\n  Docs: {}",
477                self.docs_url()
478            ),
479            Error::Unsupported(reason) => write!(f, "Unsupported operation: {reason}"),
480            Error::Internal { message, crate_version } => write!(
481                f,
482                "Internal error (please report): {message} [pdfluent {crate_version}]"
483            ),
484        }
485    }
486}
487
488impl std::error::Error for Error {
489    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
490        match self {
491            Error::Io { source, .. } => Some(source),
492            _ => None,
493        }
494    }
495}
496
497// ---------------------------------------------------------------------------
498// Internal helpers (pub(crate) — not part of the public API)
499// ---------------------------------------------------------------------------
500
501/// Build an [`Error::Internal`] with the given message and the current
502/// crate version. Used for runtime invariant checks that should never fire
503/// under normal operation (e.g. out-of-range page index after bounds
504/// validation).
505pub(crate) fn internal_error(message: impl Into<String>) -> Error {
506    Error::Internal {
507        message: message.into(),
508        crate_version: env!("CARGO_PKG_VERSION"),
509    }
510}
511
512// ---------------------------------------------------------------------------
513// From<internal error> conversions
514// ---------------------------------------------------------------------------
515//
516// These impls replace the earlier ad-hoc `map_*_error` helpers in
517// `document.rs` and siblings. With `From` impls in place, call-sites can
518// use the `?` operator directly instead of `.map_err(map_engine_error)?`.
519//
520// The impls are at the Rust item-level public (an `impl From<X> for Y`
521// block has no visibility modifier), but since the internal error types
522// (`pdf_engine::EngineError`, `lopdf::Error`, `pdf_manip::ManipError`,
523// `pdf_sign::SignError`, `pdf_redact::RedactError`) are not re-exported
524// from the `pdfluent` public surface, end users of the `pdfluent` crate
525// never encounter them: the leak is theoretical only.
526//
527// Every conversion preserves a short textual reason but **never** wraps
528// the internal error as `source()` — that would expose the internal type
529// through `std::error::Error::source`. Peek at the `source()` impl above
530// to confirm: only `Error::Io { source, .. }` chains, and its source is
531// `std::io::Error` which is public std.
532
533impl From<pdf_engine::EngineError> for Error {
534    fn from(e: pdf_engine::EngineError) -> Self {
535        use pdf_engine::EngineError as E;
536        match e {
537            E::Encrypted(_reason) => Error::DecryptionFailed {
538                reason: DecryptionFailureReason::WrongPassword,
539            },
540            E::InvalidPdf(reason) => Error::InvalidPdf {
541                byte_offset: None,
542                reason,
543            },
544            // #1467: LimitExceeded surfaces as ResourceLimitExceeded via the
545            // existing From<LimitError> impl — the full chain is now closed.
546            E::LimitExceeded(le) => Error::from(le),
547            other => Error::InvalidPdf {
548                byte_offset: None,
549                reason: format!("{other:?}"),
550            },
551        }
552    }
553}
554
555impl From<lopdf::Error> for Error {
556    fn from(e: lopdf::Error) -> Self {
557        Error::InvalidPdf {
558            byte_offset: None,
559            reason: e.to_string(),
560        }
561    }
562}
563
564impl From<pdf_manip::ManipError> for Error {
565    fn from(e: pdf_manip::ManipError) -> Self {
566        use pdf_manip::ManipError as M;
567        match e {
568            M::DecryptionFailed => Error::DecryptionFailed {
569                reason: DecryptionFailureReason::WrongPassword,
570            },
571            other => Error::InvalidPdf {
572                byte_offset: None,
573                reason: other.to_string(),
574            },
575        }
576    }
577}
578
579impl From<pdf_sign::SignError> for Error {
580    fn from(e: pdf_sign::SignError) -> Self {
581        use pdf_sign::SignError as S;
582        match e {
583            S::Pkcs12Load(reason)
584            | S::UnsupportedKeyType(reason)
585            | S::CmsBuild(reason)
586            | S::SigningFailed(reason) => Error::InvalidSignature {
587                field: "<signing>".into(),
588                reason,
589            },
590            S::NoPrivateKey => Error::InvalidSignature {
591                field: "<signing>".into(),
592                reason: "PKCS#12 identity contained no private key".into(),
593            },
594            S::NoCertificate => Error::InvalidSignature {
595                field: "<signing>".into(),
596                reason: "PKCS#12 identity contained no certificate".into(),
597            },
598        }
599    }
600}
601
602impl From<pdf_redact::RedactError> for Error {
603    fn from(e: pdf_redact::RedactError) -> Self {
604        Error::InvalidPdf {
605            byte_offset: None,
606            reason: e.to_string(),
607        }
608    }
609}
610
611// ---------------------------------------------------------------------------
612// Tests
613// ---------------------------------------------------------------------------
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use std::collections::HashSet;
619
620    /// Every Error variant must produce a unique stable code string.
621    ///
622    /// This test is the freeze-gate for RFC 0001 §5: if two variants share a
623    /// code the catalogue is broken by definition.
624    #[test]
625    fn error_codes_are_unique() {
626        use std::path::PathBuf;
627
628        // One representative instance per variant.
629        let variants: Vec<Error> = vec![
630            Error::Io {
631                source: std::io::Error::other("test"),
632                path: None,
633            },
634            Error::FileNotFound {
635                path: PathBuf::from("/tmp/test.pdf"),
636            },
637            Error::InvalidPdf {
638                byte_offset: None,
639                reason: "test".into(),
640            },
641            Error::UnsupportedPdfVersion {
642                found: "2.1".into(),
643                supported_up_to: "2.0".into(),
644            },
645            Error::PdfaValidationFailed {
646                profile: crate::compliance::PdfAProfile::A1b,
647                violations: vec![],
648            },
649            Error::DecryptionFailed {
650                reason: DecryptionFailureReason::WrongPassword,
651            },
652            Error::InvalidSignature {
653                field: "sig1".into(),
654                reason: "bad cert".into(),
655            },
656            Error::FeatureNotInTier {
657                capability: crate::capability::Capability::XfaFlatten,
658                current_tier: crate::tier::Tier::Trial,
659                required_tier: crate::tier::Tier::Developer,
660            },
661            Error::CapabilityNotCompiled {
662                capability: crate::capability::Capability::XfaFlatten,
663                feature_flag: "xfa",
664            },
665            Error::InvalidLicense {
666                reason: "expired".into(),
667            },
668            Error::LicenseExpired {
669                expires_at: 1_700_000_000,
670            },
671            Error::LicenseInvalidSignature,
672            Error::LicenseRateLimited {
673                resource: "api_calls".into(),
674                used: 1100,
675                limit: 1000,
676            },
677            Error::UnsupportedOnWasm { operation: "sign" },
678            Error::MissingDependency {
679                dep: "pdfium",
680                install_hint: "see README",
681            },
682            Error::MemoryBudgetExceeded {
683                requested: 1024,
684                limit: 512,
685            },
686            Error::ResourceLimitExceeded {
687                kind: ResourceLimitKind::FileTooLarge,
688                observed: 2000,
689                limit: 1000,
690            },
691            Error::Unsupported("test".into()),
692            Error::Internal {
693                message: "test".into(),
694                crate_version: "0.0.0",
695            },
696        ];
697
698        let mut seen: HashSet<&'static str> = HashSet::new();
699        for v in &variants {
700            let code = v.code();
701            assert!(seen.insert(code), "Duplicate error code detected: {code}");
702        }
703
704        // Confirm every variant is covered (count guard).
705        assert_eq!(
706            variants.len(),
707            19,
708            "Update this test when new Error variants are added"
709        );
710    }
711
712    /// docs_url must be consistent with code() — both must use the same slug.
713    #[test]
714    fn docs_url_matches_code() {
715        use std::path::PathBuf;
716
717        let sample = Error::FileNotFound {
718            path: PathBuf::from("/tmp/x.pdf"),
719        };
720        let code = sample.code();
721        let url = sample.docs_url();
722        assert!(
723            url.ends_with(code),
724            "docs_url {url:?} must end with code {code:?}"
725        );
726    }
727}