Skip to main content

rustmotion_core/
error.rs

1use thiserror::Error;
2
3/// Crate-wide result type using `RustmotionError`.
4pub type Result<T> = std::result::Result<T, RustmotionError>;
5
6#[derive(Debug, Error)]
7pub enum RustmotionError {
8    #[error("{0}")]
9    Generic(String),
10
11    // --- IO / File errors ---
12    #[error("Failed to read '{path}': {source}")]
13    FileRead {
14        path: String,
15        source: std::io::Error,
16    },
17
18    // --- JSON parsing ---
19    #[error("Failed to parse JSON: {source}")]
20    JsonParse {
21        #[from]
22        source: serde_json::Error,
23    },
24
25    // --- HTML transpile ---
26    #[error("HTML transpile error: {0}")]
27    HtmlParse(String),
28
29    // --- Asset loading ---
30    #[error("Failed to load image '{path}': {reason}")]
31    ImageLoad { path: String, reason: String },
32
33    #[error("Failed to decode image '{path}'")]
34    ImageDecode { path: String },
35
36    #[error("SVG component must have either 'src' or 'data'")]
37    SvgMissingSrc,
38
39    #[error("Failed to load SVG '{path}': {reason}")]
40    SvgLoad { path: String, reason: String },
41
42    #[error("Failed to parse SVG: {reason}")]
43    SvgParse { reason: String },
44
45    #[error("Failed to create pixmap for {target}")]
46    PixmapCreation { target: String },
47
48    #[error("Invalid icon format: '{icon}' (expected 'prefix:name')")]
49    InvalidIconFormat { icon: String },
50
51    #[error("Failed to fetch icon '{icon}': {reason}")]
52    IconFetch { icon: String, reason: String },
53
54    #[error("Failed to parse icon SVG '{icon}': {reason}")]
55    IconParse { icon: String, reason: String },
56
57    #[error("Failed to create Skia image from {target}")]
58    SkiaImageCreation { target: String },
59
60    #[error("Failed to open GIF '{path}': {reason}")]
61    GifOpen { path: String, reason: String },
62
63    #[error("Failed to decode GIF '{path}': {reason}")]
64    GifDecode { path: String, reason: String },
65
66    #[error("QR code generation failed: {reason}")]
67    QrCodeGeneration { reason: String },
68
69    #[error("No fonts available on this system")]
70    FontNotFound,
71
72    // --- Google Fonts ---
73    #[error(
74        "FontEntry for '{family}' has source=\"google\" but also sets 'path' — use one or the other"
75    )]
76    FontSourceAndPathConflict { family: String },
77
78    #[error(
79        "FontEntry for '{family}' requires either 'path' (local file) or 'source' (e.g. \"google\")"
80    )]
81    FontMissingPath { family: String },
82
83    #[error(
84        "Google Fonts: failed to fetch CSS for '{family}' (url: {url}): {reason}\n\
85         Tip: if you are offline, manually place a TTF for each weight at:\n  {cache_hint}"
86    )]
87    GoogleFontsFetch {
88        family: String,
89        url: String,
90        reason: String,
91        cache_hint: String,
92    },
93
94    #[error(
95        "Google Fonts: no font URLs found in CSS response for '{family}' — \
96         the family name may be misspelled or unavailable"
97    )]
98    GoogleFontsNoUrls { family: String },
99
100    #[error("Google Fonts: failed to download TTF for '{family}' weight {weight}: {reason}")]
101    GoogleFontsTtfFetch {
102        family: String,
103        weight: u16,
104        reason: String,
105    },
106
107    // --- Include system ---
108    #[error("Include depth limit ({limit}) exceeded while resolving '{path}'")]
109    IncludeDepthExceeded { limit: u8, path: String },
110
111    #[error("Include: scenes[{index}] is out of bounds in '{path}' (file has {total} scenes)")]
112    IncludeSceneOutOfBounds {
113        index: usize,
114        path: String,
115        total: usize,
116    },
117
118    #[error("Include: cannot resolve relative path '{path}' from inline JSON (use a file path or URL instead)")]
119    IncludeInlinePath { path: String },
120
121    #[error("Include: failed to fetch '{url}': {reason}")]
122    IncludeRemoteFetch { url: String, reason: String },
123
124    #[error("Include: file not found '{path}'")]
125    IncludeFileNotFound { path: String },
126
127    #[error(
128        "Scenario cannot have both top-level 'scenes' and 'composition' — use one or the other"
129    )]
130    CompositionAndScenesConflict,
131
132    #[error("Unknown background template '{name}' referenced via $ref")]
133    UnknownBackgroundTemplate { name: String },
134
135    #[error("Unknown heropattern '{name}' — see heropatterns.com for available patterns")]
136    UnknownHeropattern { name: String },
137
138    // --- Variables ---
139    #[error("Variable '${name}' is not defined in '{path}'")]
140    UndefinedVariable { name: String, path: String },
141
142    #[error("Variable '{name}' in '{path}' is missing a default value")]
143    VariableMissingDefault { name: String, path: String },
144
145    #[error("Unresolved variable reference '${name}' after substitution in '{path}'")]
146    UnresolvedVariable { name: String, path: String },
147
148    #[error("Cannot interpolate non-string variable '${name}' into string in '{path}'")]
149    VariableInterpolationTypeError { name: String, path: String },
150
151    // --- Templates: `components` / `use` / `for-each` (see rustmotion_core::expand) ---
152    #[error("'components' at '{path}' must be an object mapping names to definitions")]
153    ComponentsBlockNotObject { path: String },
154
155    #[error("Component definition '{name}' at '{path}' is invalid: {reason}")]
156    ComponentDefinitionInvalid {
157        name: String,
158        path: String,
159        reason: String,
160    },
161
162    #[error("'use' directive at '{path}' is invalid: {reason}")]
163    UseDirectiveInvalid { path: String, reason: String },
164
165    #[error(
166        "Unknown component '{name}' referenced via 'use' at '{path}' — no such name in this \
167         file's 'components' block"
168    )]
169    UnknownComponent { name: String, path: String },
170
171    #[error(
172        "Missing required parameter '{param}' for component '{component}' at '{path}' — it has \
173         no default and was not supplied via 'props'"
174    )]
175    ComponentParamMissing {
176        component: String,
177        param: String,
178        path: String,
179    },
180
181    #[error(
182        "Unknown parameter '{param}' passed to component '{component}' at '{path}' — not \
183         declared in its 'params'"
184    )]
185    UnknownComponentParam {
186        component: String,
187        param: String,
188        path: String,
189    },
190
191    #[error("Component instantiation cycle at '{path}': {chain}")]
192    ComponentCycle { chain: String, path: String },
193
194    #[error("'for-each' directive at '{path}' is invalid: {reason}")]
195    ForEachDirectiveInvalid { path: String, reason: String },
196
197    #[error("'for-each' at '{path}' must resolve to an array; found {found}")]
198    ForEachNotArray { path: String, found: String },
199
200    #[error(
201        "Template/component expansion depth limit ({limit}) exceeded at '{path}' — likely a \
202         runaway nested 'use'/'for-each' template"
203    )]
204    ExpansionDepthExceeded { limit: u32, path: String },
205
206    // --- Encoding ---
207    #[error("No frames to render (total duration is 0)")]
208    NoFrames,
209
210    // ffmpeg is otherwise the one that finds out, and only after every frame
211    // has been rendered: it exits with "Nothing was written into output file"
212    // and a raw -22 dump. Naming the working combination costs one line.
213    #[error("codec '{codec}' cannot be written into a .{container} file — {fix}")]
214    CodecContainerMismatch {
215        codec: String,
216        container: String,
217        fix: String,
218    },
219
220    #[error("Failed to run ffmpeg: {reason}. Is ffmpeg installed?")]
221    FfmpegSpawn { reason: String },
222
223    #[error("FFmpeg encoding failed{}", .stderr.as_ref().map(|s| format!(": {}", s)).unwrap_or_default())]
224    FfmpegFailed { stderr: Option<String> },
225
226    #[error("Failed to open FFmpeg stdin pipe")]
227    FfmpegPipe,
228
229    // A broken pipe here nearly always means ffmpeg already died on its own
230    // arguments, so the useful diagnostic is ffmpeg's stderr rather than our
231    // write error. Carry it in the error so it survives `--quiet`.
232    #[error("Failed to write to FFmpeg pipe: {reason}{}", .stderr.as_ref().map(|s| format!("\nffmpeg reported:\n{}", s)).unwrap_or_default())]
233    FfmpegWrite {
234        reason: String,
235        stderr: Option<String>,
236    },
237
238    #[error("Failed to wait for FFmpeg: {reason}")]
239    FfmpegWait { reason: String },
240
241    #[error("ffmpeg failed to extract frame from '{src}'")]
242    FfmpegFrameExtract { src: String },
243
244    #[error("Failed to create GIF encoder: {reason}")]
245    GifEncoder { reason: String },
246
247    #[error("Failed to set GIF repeat: {reason}")]
248    GifRepeat { reason: String },
249
250    #[error("Failed to write GIF frame: {reason}")]
251    GifFrame { reason: String },
252
253    // --- Audio ---
254    #[error("Failed to open audio file '{path}': {reason}")]
255    AudioOpen { path: String, reason: String },
256
257    #[error("Failed to probe audio format for '{path}': {reason}")]
258    AudioProbe { path: String, reason: String },
259
260    #[error("No audio track found in '{path}'")]
261    AudioNoTrack { path: String },
262
263    #[error("Failed to create decoder for '{path}': {reason}")]
264    AudioDecoder { path: String, reason: String },
265
266    // --- Rendering ---
267    #[error("Failed to create Skia surface")]
268    SurfaceCreation,
269
270    #[error("Failed to create image from pixels")]
271    PixelImage,
272
273    #[error("Failed to read pixels from Skia surface")]
274    PixelRead,
275
276    #[error("Failed to create motion blur surface")]
277    MotionBlurSurface,
278
279    // --- CLI ---
280    #[error("Cannot use both input file and --json")]
281    ConflictingInput,
282
283    #[error("Provide either an input file or --json")]
284    MissingInput,
285
286    #[error("--watch requires an input file path (cannot use --json or stdin)")]
287    WatchRequiresFile,
288
289    #[error("Frame {frame} is out of range (total frames: {total})")]
290    FrameOutOfRange { frame: u32, total: u32 },
291
292    #[error("Frame range {start}-{end} is out of range (total frames: {total})")]
293    FrameRangeOutOfRange { start: u32, end: u32, total: u32 },
294
295    #[error("Time {time:.2}s is beyond video duration")]
296    TimeOutOfRange { time: f64 },
297
298    #[error("File watcher channel closed")]
299    WatcherClosed,
300
301    #[error("Validation failed: {schema_errors} schema error(s), {geometry_violations} geometry violation(s), {unresolved_vars} unresolved variable(s). Run `rustmotion validate -f <file>` to see details.")]
302    ValidationFailed {
303        schema_errors: usize,
304        geometry_violations: usize,
305        unresolved_vars: usize,
306    },
307
308    #[error("Incremental encoding unsupported: {reason}")]
309    IncrementalUnsupported { reason: String },
310
311    #[error("Invalid CRF value {value}: must be between 0 and 51")]
312    InvalidCrf { value: u8 },
313
314    #[error("Unknown codec '{codec}'. Supported: h264, h265, vp9, prores")]
315    UnknownCodec { codec: String },
316
317    #[error("Path is not valid UTF-8: '{path}'")]
318    NonUtf8Path { path: String },
319
320    // --- Preview ---
321    #[error("Failed to create preview window: {reason}")]
322    PreviewWindow { reason: String },
323
324    // --- Lottie ---
325    #[error("Failed to read Lottie file '{path}': {reason}")]
326    LottieRead { path: String, reason: String },
327
328    #[error("Lottie component requires either 'src' or 'data'")]
329    LottieMissingSrc,
330
331    #[error("Failed to read Lottie frame '{path}': {reason}")]
332    LottieFrameRead { path: String, reason: String },
333
334    #[error("Failed to decode Lottie frame '{path}': {reason}")]
335    LottieFrameDecode { path: String, reason: String },
336
337    #[error("Lottie render failed: {reason}")]
338    LottieRender { reason: String },
339
340    // --- Skills ---
341    #[error(
342        "Unknown skill or rule: '{name}'. Run `rustmotion skills list` to see available rules."
343    )]
344    UnknownSkill { name: String },
345
346    // --- IO ---
347    #[error("{0}")]
348    Io(#[from] std::io::Error),
349
350    // --- External library errors ---
351    #[error("Image processing error: {0}")]
352    Image(#[from] image::ImageError),
353
354    #[error("File watcher error: {0}")]
355    Notify(#[from] notify::Error),
356
357    #[error("{0}")]
358    Other(String),
359}
360
361impl From<String> for RustmotionError {
362    fn from(s: String) -> Self {
363        RustmotionError::Other(s)
364    }
365}