Skip to main content

appcore_filemaker/
error.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: error.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Stable failure codes and UTF-8-safe bounded diagnostic ownership.
12
13use std::fmt;
14
15use thiserror::Error;
16
17/// Stable machine-readable failure code.
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub enum ErrorCode {
20    /// Source is not syntactically valid.
21    SchemaSyntax,
22    /// Source schema version is absent or unsupported.
23    SchemaVersion,
24    /// Source contains an invalid field or value.
25    SchemaField,
26    /// A typed data value or binding is invalid.
27    DataType,
28    /// Computed data contains a dependency cycle.
29    DataCycle,
30    /// An asset reference violates the resolver sandbox.
31    AssetSandbox,
32    /// An asset is missing or invalid.
33    AssetInvalid,
34    /// A required font or glyph is unavailable.
35    FontMissing,
36    /// Geometry overflowed or violates an invariant.
37    GeometryInvalid,
38    /// Layout constraints cannot be resolved.
39    LayoutInvalid,
40    /// Collision/reflow did not converge within the configured bound.
41    LayoutNonConvergent,
42    /// A patch is malformed or targets an absent node.
43    PatchInvalid,
44    /// A patch attempted to mutate a locked node.
45    PatchLocked,
46    /// The requested exporter does not support a required feature.
47    ExportUnsupported,
48    /// The output writer failed.
49    ExportWrite,
50    /// A configured resource budget was exceeded.
51    LimitExceeded,
52    /// Cooperative cancellation was requested.
53    Cancelled,
54    /// Validation or preflight rejected the requested operation.
55    Validation,
56}
57
58impl ErrorCode {
59    /// Returns the stable `FM-*` representation.
60    #[must_use]
61    pub const fn as_str(self) -> &'static str {
62        match self {
63            Self::SchemaSyntax => "FM-SCHEMA-SYNTAX",
64            Self::SchemaVersion => "FM-SCHEMA-VERSION",
65            Self::SchemaField => "FM-SCHEMA-FIELD",
66            Self::DataType => "FM-DATA-TYPE",
67            Self::DataCycle => "FM-DATA-CYCLE",
68            Self::AssetSandbox => "FM-ASSET-SANDBOX",
69            Self::AssetInvalid => "FM-ASSET-INVALID",
70            Self::FontMissing => "FM-FONT-MISSING",
71            Self::GeometryInvalid => "FM-GEOM-INVALID",
72            Self::LayoutInvalid => "FM-LAYOUT-INVALID",
73            Self::LayoutNonConvergent => "FM-LAYOUT-NON-CONVERGENT",
74            Self::PatchInvalid => "FM-PATCH-INVALID",
75            Self::PatchLocked => "FM-PATCH-LOCKED",
76            Self::ExportUnsupported => "FM-EXPORT-UNSUPPORTED",
77            Self::ExportWrite => "FM-EXPORT-WRITE",
78            Self::LimitExceeded => "FM-LIMIT-EXCEEDED",
79            Self::Cancelled => "FM-CANCELLED",
80            Self::Validation => "FM-VALIDATION",
81        }
82    }
83}
84
85/// Typed compiler error with stable code and bounded context.
86#[derive(Debug, Error)]
87#[error("{code}: {message}")]
88pub struct FileMakerError {
89    code: CodeDisplay,
90    message: String,
91    source_path: Option<String>,
92}
93
94impl FileMakerError {
95    /// Creates an error with at most 1,024 UTF-8 bytes of diagnostic text.
96    /// Oversized backing allocations are replaced with the complete-character prefix.
97    #[must_use]
98    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
99        let message = bounded_diagnostic(message.into(), 1_024);
100        Self {
101            code: CodeDisplay(code),
102            message,
103            source_path: None,
104        }
105    }
106
107    /// Attaches at most 512 UTF-8 bytes of the logical source path, never a partial character.
108    #[must_use]
109    pub fn at(mut self, source_path: impl Into<String>) -> Self {
110        let source_path = bounded_diagnostic(source_path.into(), 512);
111        self.source_path = Some(source_path);
112        self
113    }
114
115    /// Returns the stable code.
116    #[must_use]
117    pub const fn code(&self) -> ErrorCode {
118        self.code.0
119    }
120
121    /// Returns the bounded human-readable diagnostic.
122    #[must_use]
123    pub fn message(&self) -> &str {
124        &self.message
125    }
126
127    /// Returns the logical input path, when available.
128    #[must_use]
129    pub fn source_path(&self) -> Option<&str> {
130        self.source_path.as_deref()
131    }
132}
133
134#[derive(Debug)]
135struct CodeDisplay(ErrorCode);
136
137impl fmt::Display for CodeDisplay {
138    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
139        formatter.write_str(self.0.as_str())
140    }
141}
142
143/// Result returned by `FileMaker` compiler operations.
144pub type Result<T> = std::result::Result<T, FileMakerError>;
145
146// A byte cap must preserve UTF-8 and must not retain an oversized caller buffer.
147pub(crate) fn bounded_diagnostic(mut text: String, limit: usize) -> String {
148    let mut end = text.len().min(limit);
149    while !text.is_char_boundary(end) {
150        end -= 1;
151    }
152    if text.capacity() > limit {
153        return text[..end].to_owned();
154    }
155    text.truncate(end);
156    text
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn diagnostic_reports_share_utf8_bounds_and_release_oversized_buffers() {
165        let mut text = String::with_capacity(1024 * 1024);
166        text.push_str("short");
167        let bounded = bounded_diagnostic(text, 512);
168        assert_eq!(bounded, "short");
169        assert!(bounded.capacity() <= 512);
170        let text = "日".repeat(200);
171        let mut report = crate::ValidationReport::default();
172        report.push(
173            crate::ValidationSeverity::Warning,
174            crate::ValidationCode::Overflow,
175            None,
176            None,
177            text.clone(),
178            1,
179        );
180        assert_eq!(report.issues[0].message, "日".repeat(170));
181        let mut losses = crate::ExportLossReport::default();
182        losses.push(crate::ExportLossKind::UnsupportedElement, None, text);
183        assert_eq!(losses.losses[0].message, "日".repeat(170));
184    }
185
186    #[test]
187    fn unicode_diagnostics_are_bounded_without_splitting_characters() {
188        for symbol in ["a", "é", "日", "🦀"] {
189            for prefix in 0..4 {
190                let text = format!("{}{}", "x".repeat(prefix), symbol.repeat(1100));
191                let error =
192                    FileMakerError::new(ErrorCode::SchemaField, text.clone()).at(text.clone());
193                assert!(error.message().len() <= 1024);
194                assert!(text.starts_with(error.message()));
195                assert!(error.source_path().unwrap().len() <= 512);
196                assert!(text.starts_with(error.source_path().unwrap()));
197            }
198        }
199    }
200}