Skip to main content

ethos_core/
error.rs

1/*
2 * Copyright 2026 The Ethos maintainers
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! Stable error codes (PRD §10) and their CLI exit codes (docs/architecture.md).
18//! A PDF that cannot be parsed safely fails with one of these — never a panic.
19//! Code changes are `contract-change` events; exit codes are public API.
20
21use serde::{Deserialize, Serialize};
22
23/// The 10 stable error codes. Wire format is snake_case.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ErrorCode {
27    /// Not a PDF / unparseable header.
28    InvalidPdf,
29    /// Structurally corrupt PDF.
30    CorruptPdf,
31    /// Encrypted/password-protected input.
32    PasswordProtected,
33    /// Page count exceeds the configured limit.
34    PageLimitExceeded,
35    /// File size exceeds the configured limit.
36    FileTooLarge,
37    /// No extractable text — OCR would be required (out of Release 1 base).
38    OcrRequired,
39    /// PDF feature outside the supported profile.
40    UnsupportedPdfFeature,
41    /// Wall-time limit exceeded.
42    ParseTimeout,
43    /// Memory budget exceeded.
44    MemoryLimitExceeded,
45    /// Any internal failure (incl. ID-width overflow, c14n errors at runtime).
46    InternalError,
47}
48
49impl ErrorCode {
50    /// All codes, in contract order.
51    pub const ALL: [ErrorCode; 10] = [
52        ErrorCode::InvalidPdf,
53        ErrorCode::CorruptPdf,
54        ErrorCode::PasswordProtected,
55        ErrorCode::PageLimitExceeded,
56        ErrorCode::FileTooLarge,
57        ErrorCode::OcrRequired,
58        ErrorCode::UnsupportedPdfFeature,
59        ErrorCode::ParseTimeout,
60        ErrorCode::MemoryLimitExceeded,
61        ErrorCode::InternalError,
62    ];
63
64    /// Stable wire string (snake_case).
65    pub fn as_str(self) -> &'static str {
66        match self {
67            ErrorCode::InvalidPdf => "invalid_pdf",
68            ErrorCode::CorruptPdf => "corrupt_pdf",
69            ErrorCode::PasswordProtected => "password_protected",
70            ErrorCode::PageLimitExceeded => "page_limit_exceeded",
71            ErrorCode::FileTooLarge => "file_too_large",
72            ErrorCode::OcrRequired => "ocr_required",
73            ErrorCode::UnsupportedPdfFeature => "unsupported_pdf_feature",
74            ErrorCode::ParseTimeout => "parse_timeout",
75            ErrorCode::MemoryLimitExceeded => "memory_limit_exceeded",
76            ErrorCode::InternalError => "internal_error",
77        }
78    }
79
80    /// Public CLI exit code (docs/architecture.md). 0 = success, 2 = usage error (clap).
81    pub fn exit_code(self) -> i32 {
82        match self {
83            ErrorCode::InvalidPdf => 3,
84            ErrorCode::CorruptPdf => 4,
85            ErrorCode::PasswordProtected => 5,
86            ErrorCode::PageLimitExceeded => 6,
87            ErrorCode::FileTooLarge => 7,
88            ErrorCode::OcrRequired => 8,
89            ErrorCode::UnsupportedPdfFeature => 9,
90            ErrorCode::ParseTimeout => 10,
91            ErrorCode::MemoryLimitExceeded => 11,
92            ErrorCode::InternalError => 12,
93        }
94    }
95}
96
97impl core::fmt::Display for ErrorCode {
98    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
99        f.write_str(self.as_str())
100    }
101}
102
103/// The Ethos error type: stable code + deterministic message.
104/// Messages follow fixed templates (determinism contract §8) — no paths, no timestamps.
105#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
106#[error("{code}: {message}")]
107pub struct EthosError {
108    /// Stable code.
109    pub code: ErrorCode,
110    /// Deterministic, template-derived message.
111    pub message: String,
112}
113
114impl EthosError {
115    /// Construct with a template-derived message.
116    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
117        EthosError {
118            code,
119            message: message.into(),
120        }
121    }
122
123    /// Internal error helper.
124    pub fn internal(message: impl Into<String>) -> Self {
125        EthosError::new(ErrorCode::InternalError, message)
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn exit_codes_are_dense_and_disjoint() {
135        let mut seen = std::collections::BTreeSet::new();
136        for code in ErrorCode::ALL {
137            assert!(seen.insert(code.exit_code()), "duplicate exit code");
138        }
139        assert_eq!(*seen.first().unwrap(), 3);
140        assert_eq!(*seen.last().unwrap(), 12);
141        assert_eq!(seen.len(), 10);
142    }
143
144    #[test]
145    fn wire_format_round_trips() {
146        for code in ErrorCode::ALL {
147            let json = serde_json::to_string(&code).unwrap();
148            assert_eq!(json, format!("\"{}\"", code.as_str()));
149            assert_eq!(serde_json::from_str::<ErrorCode>(&json).unwrap(), code);
150        }
151    }
152}