1use std::fmt;
14
15use thiserror::Error;
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub enum ErrorCode {
20 SchemaSyntax,
22 SchemaVersion,
24 SchemaField,
26 DataType,
28 DataCycle,
30 AssetSandbox,
32 AssetInvalid,
34 FontMissing,
36 GeometryInvalid,
38 LayoutInvalid,
40 LayoutNonConvergent,
42 PatchInvalid,
44 PatchLocked,
46 ExportUnsupported,
48 ExportWrite,
50 LimitExceeded,
52 Cancelled,
54 Validation,
56}
57
58impl ErrorCode {
59 #[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#[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 #[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 #[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 #[must_use]
119 pub fn with_text_context(mut self, context: TextFailureContextV1) -> Self {
120 self.layout_context = Some(context);
121 self
122 }
123
124 #[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 #[must_use]
149 pub const fn code(&self) -> ErrorCode {
150 self.code.0
151 }
152
153 #[must_use]
155 pub fn message(&self) -> &str {
156 &self.message
157 }
158
159 #[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
175pub type Result<T> = std::result::Result<T, FileMakerError>;
177
178pub(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#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
193pub struct TextFailureContextV1 {
194 pub available_width_raw: i64,
196 pub available_height_raw: i64,
198 pub font_size_raw: i64,
200 pub minimum_font_size_raw: i64,
202}
203#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
205pub struct FileMakerDiagnosticV1 {
206 pub schema_version: u16,
208 pub code: String,
210 pub source_path: Option<String>,
212 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}