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    layout_context: Option<TextFailureContextV1>,
93}
94
95impl FileMakerError {
96    /// Creates an error with at most 1,024 UTF-8 bytes of diagnostic text.
97    /// Oversized backing allocations are replaced with the complete-character prefix.
98    #[must_use]
99    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
100        let message = bounded_diagnostic(message.into(), 1_024);
101        Self {
102            code: CodeDisplay(code),
103            message,
104            source_path: None,
105            layout_context: None,
106        }
107    }
108
109    /// Attaches at most 512 UTF-8 bytes of the logical source path, never a partial character.
110    #[must_use]
111    pub fn at(mut self, source_path: impl Into<String>) -> Self {
112        let source_path = bounded_diagnostic(source_path.into(), 512);
113        self.source_path = Some(source_path);
114        self
115    }
116
117    /// Attaches payload-free text geometry in fixed-point units (1,000,000 per point).
118    #[must_use]
119    pub fn with_text_context(mut self, context: TextFailureContextV1) -> Self {
120        self.layout_context = Some(context);
121        self
122    }
123
124    /// Produces safe IPC evidence without free-form messages or document content.
125    /// Only bounded logical paths with safe schema-identifier characters are retained.
126    #[must_use]
127    pub fn diagnostic_v1(&self) -> FileMakerDiagnosticV1 {
128        FileMakerDiagnosticV1 {
129            schema_version: 1,
130            code: self.code().as_str().to_owned(),
131            source_path: self
132                .source_path
133                .as_ref()
134                .filter(|path| {
135                    !path.starts_with('/')
136                        && !path.contains("..")
137                        && path.bytes().all(|byte| {
138                            byte.is_ascii_alphanumeric()
139                                || matches!(byte, b'.' | b'_' | b'-' | b'/' | b'[' | b']')
140                        })
141                })
142                .cloned(),
143            text: self.layout_context,
144        }
145    }
146
147    /// Returns the stable code.
148    #[must_use]
149    pub const fn code(&self) -> ErrorCode {
150        self.code.0
151    }
152
153    /// Returns the bounded human-readable diagnostic.
154    #[must_use]
155    pub fn message(&self) -> &str {
156        &self.message
157    }
158
159    /// Returns the logical input path, when available.
160    #[must_use]
161    pub fn source_path(&self) -> Option<&str> {
162        self.source_path.as_deref()
163    }
164}
165
166#[derive(Debug)]
167struct CodeDisplay(ErrorCode);
168
169impl fmt::Display for CodeDisplay {
170    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
171        formatter.write_str(self.0.as_str())
172    }
173}
174
175/// Result returned by `FileMaker` compiler operations.
176pub type Result<T> = std::result::Result<T, FileMakerError>;
177
178// A byte cap must preserve UTF-8 and must not retain an oversized caller buffer.
179pub(crate) fn bounded_diagnostic(mut text: String, limit: usize) -> String {
180    let mut end = text.len().min(limit);
181    while !text.is_char_boundary(end) {
182        end -= 1;
183    }
184    if text.capacity() > limit {
185        return text[..end].to_owned();
186    }
187    text.truncate(end);
188    text
189}
190
191/// Payload-free context for text fitting failures. Units are millionths of a point.
192#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
193pub struct TextFailureContextV1 {
194    /// Width available after padding.
195    pub available_width_raw: i64,
196    /// Height available after padding.
197    pub available_height_raw: i64,
198    /// Requested font size.
199    pub font_size_raw: i64,
200    /// Minimum permitted font size.
201    pub minimum_font_size_raw: i64,
202}
203/// Stable serializable IPC diagnostic; UI wording and template changes are consumer-owned.
204#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
205pub struct FileMakerDiagnosticV1 {
206    /// Exact diagnostic schema version.
207    pub schema_version: u16,
208    /// Stable FM code; no free-form error message.
209    pub code: String,
210    /// Safe logical template/input path, never an absolute host filesystem path.
211    pub source_path: Option<String>,
212    /// Numeric layout evidence where available.
213    pub text: Option<TextFailureContextV1>,
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn diagnostic_reports_share_utf8_bounds_and_release_oversized_buffers() {
222        let mut text = String::with_capacity(1024 * 1024);
223        text.push_str("short");
224        let bounded = bounded_diagnostic(text, 512);
225        assert_eq!(bounded, "short");
226        assert!(bounded.capacity() <= 512);
227        let text = "日".repeat(200);
228        let mut report = crate::ValidationReport::default();
229        report.push(
230            crate::ValidationSeverity::Warning,
231            crate::ValidationCode::Overflow,
232            None,
233            None,
234            text.clone(),
235            1,
236        );
237        assert_eq!(report.issues[0].message, "日".repeat(170));
238        let mut losses = crate::ExportLossReport::default();
239        losses.push(crate::ExportLossKind::UnsupportedElement, None, text);
240        assert_eq!(losses.losses[0].message, "日".repeat(170));
241    }
242
243    #[test]
244    fn unicode_diagnostics_are_bounded_without_splitting_characters() {
245        for symbol in ["a", "é", "日", "🦀"] {
246            for prefix in 0..4 {
247                let text = format!("{}{}", "x".repeat(prefix), symbol.repeat(1100));
248                let error =
249                    FileMakerError::new(ErrorCode::SchemaField, text.clone()).at(text.clone());
250                assert!(error.message().len() <= 1024);
251                assert!(text.starts_with(error.message()));
252                assert!(error.source_path().unwrap().len() <= 512);
253                assert!(text.starts_with(error.source_path().unwrap()));
254            }
255        }
256    }
257}