1use crate::cache::CacheStats;
10use crate::config::ConfigError;
11use crate::generate;
12use crate::scan;
13use serde::Serialize;
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone, Copy, Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ErrorKind {
26 Config,
27 Io,
28 Scan,
29 Process,
30 Generate,
31 Validation,
32 Usage,
33 Internal,
34}
35
36impl ErrorKind {
37 pub fn exit_code(self) -> i32 {
40 match self {
41 ErrorKind::Internal => 1,
42 ErrorKind::Usage => 2,
43 ErrorKind::Config => 3,
44 ErrorKind::Io => 4,
45 ErrorKind::Scan => 5,
46 ErrorKind::Process => 6,
47 ErrorKind::Generate => 7,
48 ErrorKind::Validation => 8,
49 }
50 }
51}
52
53#[derive(Debug, Serialize)]
56pub struct ConfigErrorPayload {
57 pub path: PathBuf,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub line: Option<usize>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub column: Option<usize>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub snippet: Option<String>,
64}
65
66#[derive(Debug, Serialize)]
68pub struct ErrorEnvelope {
69 pub ok: bool,
70 pub kind: ErrorKind,
71 pub message: String,
72 #[serde(skip_serializing_if = "Vec::is_empty")]
73 pub causes: Vec<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub config: Option<ConfigErrorPayload>,
76}
77
78impl ErrorEnvelope {
79 pub fn new(kind: ErrorKind, err: &(dyn std::error::Error + 'static)) -> Self {
80 let message = err.to_string();
81 let mut causes = Vec::new();
82 let mut src = err.source();
83 while let Some(cause) = src {
84 causes.push(cause.to_string());
85 src = cause.source();
86 }
87 let config = find_config_error(err).and_then(config_error_payload);
92 Self {
93 ok: false,
94 kind,
95 message,
96 causes,
97 config,
98 }
99 }
100}
101
102fn find_config_error<'a>(err: &'a (dyn std::error::Error + 'static)) -> Option<&'a ConfigError> {
103 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
104 while let Some(e) = current {
105 if let Some(cfg) = e.downcast_ref::<ConfigError>() {
106 return Some(cfg);
107 }
108 current = e.source();
109 }
110 None
111}
112
113fn config_error_payload(cfg: &ConfigError) -> Option<ConfigErrorPayload> {
114 match cfg {
115 ConfigError::Toml {
116 path,
117 source,
118 source_text,
119 } => {
120 let (line, column) = source
121 .span()
122 .map(|span| offset_to_line_col(source_text, span.start))
123 .unwrap_or((None, None));
124 let snippet = source
125 .span()
126 .and_then(|span| extract_snippet(source_text, span.start));
127 Some(ConfigErrorPayload {
128 path: path.clone(),
129 line,
130 column,
131 snippet,
132 })
133 }
134 _ => None,
137 }
138}
139
140fn offset_to_line_col(text: &str, offset: usize) -> (Option<usize>, Option<usize>) {
141 let offset = offset.min(text.len());
142 let prefix = &text[..offset];
143 let line = prefix.bytes().filter(|b| *b == b'\n').count() + 1;
144 let col = prefix.rfind('\n').map(|i| offset - i - 1).unwrap_or(offset) + 1;
145 (Some(line), Some(col))
146}
147
148fn extract_snippet(text: &str, offset: usize) -> Option<String> {
149 let offset = offset.min(text.len());
150 let start = text[..offset].rfind('\n').map(|i| i + 1).unwrap_or(0);
151 let end = text[offset..]
152 .find('\n')
153 .map(|i| offset + i)
154 .unwrap_or(text.len());
155 Some(text[start..end].to_string())
156}
157
158#[derive(Debug, Serialize)]
165pub struct OkEnvelope<T: Serialize> {
166 pub ok: bool,
167 pub command: &'static str,
168 pub data: T,
169}
170
171impl<T: Serialize> OkEnvelope<T> {
172 pub fn new(command: &'static str, data: T) -> Self {
173 Self {
174 ok: true,
175 command,
176 data,
177 }
178 }
179}
180
181#[derive(Debug, Serialize)]
182pub struct Counts {
183 pub albums: usize,
184 pub images: usize,
185 pub pages: usize,
186}
187
188#[derive(Debug, Serialize)]
191pub struct ScanPayload<'a> {
192 pub source: &'a Path,
193 pub counts: Counts,
194 pub manifest: &'a scan::Manifest,
195 #[serde(skip_serializing_if = "Option::is_none")]
196 pub saved_manifest_path: Option<PathBuf>,
197}
198
199impl<'a> ScanPayload<'a> {
200 pub fn new(
201 manifest: &'a scan::Manifest,
202 source: &'a Path,
203 saved_manifest_path: Option<PathBuf>,
204 ) -> Self {
205 let images = manifest.albums.iter().map(|a| a.images.len()).sum();
206 Self {
207 source,
208 counts: Counts {
209 albums: manifest.albums.len(),
210 images,
211 pages: manifest.pages.len(),
212 },
213 manifest,
214 saved_manifest_path,
215 }
216 }
217}
218
219#[derive(Debug, Serialize)]
222pub struct CacheStatsPayload {
223 pub cached: u32,
224 pub copied: u32,
225 pub encoded: u32,
226 pub total: u32,
227}
228
229impl From<&CacheStats> for CacheStatsPayload {
230 fn from(s: &CacheStats) -> Self {
231 Self {
232 cached: s.hits,
233 copied: s.copies,
234 encoded: s.misses,
235 total: s.total(),
236 }
237 }
238}
239
240#[derive(Debug, Serialize)]
241pub struct ProcessPayload {
242 pub processed_dir: PathBuf,
243 pub manifest_path: PathBuf,
244 pub cache: CacheStatsPayload,
245}
246
247#[derive(Debug, Serialize)]
250pub struct GeneratePayload<'a> {
251 pub output: &'a Path,
252 pub counts: GenerateCounts,
253 pub albums: Vec<GeneratedAlbum>,
254 pub pages: Vec<GeneratedPage>,
255}
256
257#[derive(Debug, Serialize)]
258pub struct GenerateCounts {
259 pub albums: usize,
260 pub image_pages: usize,
261 pub pages: usize,
262}
263
264#[derive(Debug, Serialize)]
265pub struct GeneratedAlbum {
266 pub title: String,
267 pub path: String,
268 pub index_html: String,
269 pub image_count: usize,
270}
271
272#[derive(Debug, Serialize)]
273pub struct GeneratedPage {
274 pub title: String,
275 pub slug: String,
276 pub is_link: bool,
277}
278
279impl<'a> GeneratePayload<'a> {
280 pub fn new(manifest: &'a generate::Manifest, output: &'a Path) -> Self {
281 let image_pages = manifest.albums.iter().map(|a| a.images.len()).sum();
282 let pages_count = manifest.pages.iter().filter(|p| !p.is_link).count();
283 let albums = manifest
284 .albums
285 .iter()
286 .map(|a| GeneratedAlbum {
287 title: a.title.clone(),
288 path: a.path.clone(),
289 index_html: format!("{}/index.html", a.path),
290 image_count: a.images.len(),
291 })
292 .collect();
293 let pages = manifest
294 .pages
295 .iter()
296 .map(|p| GeneratedPage {
297 title: p.title.clone(),
298 slug: p.slug.clone(),
299 is_link: p.is_link,
300 })
301 .collect();
302 Self {
303 output,
304 counts: GenerateCounts {
305 albums: manifest.albums.len(),
306 image_pages,
307 pages: pages_count,
308 },
309 albums,
310 pages,
311 }
312 }
313}
314
315#[derive(Debug, Serialize)]
318pub struct BuildPayload<'a> {
319 pub source: &'a Path,
320 pub output: &'a Path,
321 pub counts: GenerateCounts,
322 pub cache: CacheStatsPayload,
323}
324
325#[derive(Debug, Serialize)]
328pub struct CheckPayload<'a> {
329 pub valid: bool,
330 pub source: &'a Path,
331 pub counts: Counts,
332}
333
334#[derive(Debug, Serialize)]
337pub struct GenConfigPayload {
338 pub toml: String,
339}
340
341pub fn emit_stdout<T: Serialize>(value: &T) -> Result<(), serde_json::Error> {
350 let s = serde_json::to_string_pretty(value)?;
351 println!("{s}");
352 Ok(())
353}
354
355pub fn emit_stderr<T: Serialize>(value: &T) -> Result<(), serde_json::Error> {
358 let s = serde_json::to_string_pretty(value)?;
359 eprintln!("{s}");
360 Ok(())
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn exit_codes_are_distinct() {
369 let kinds = [
370 ErrorKind::Internal,
371 ErrorKind::Usage,
372 ErrorKind::Config,
373 ErrorKind::Io,
374 ErrorKind::Scan,
375 ErrorKind::Process,
376 ErrorKind::Generate,
377 ErrorKind::Validation,
378 ];
379 let codes: Vec<i32> = kinds.iter().map(|k| k.exit_code()).collect();
380 let mut sorted = codes.clone();
381 sorted.sort_unstable();
382 sorted.dedup();
383 assert_eq!(sorted.len(), kinds.len(), "exit codes must be unique");
384 assert!(!codes.contains(&0), "0 is reserved for success");
385 }
386
387 #[test]
388 fn error_envelope_collects_causes() {
389 use std::io;
390 let err = io::Error::other("outer");
391 let env = ErrorEnvelope::new(ErrorKind::Io, &err);
392 assert!(!env.ok);
393 assert_eq!(env.message, "outer");
394 }
395
396 #[test]
397 fn offset_to_line_col_first_line() {
398 let (line, col) = offset_to_line_col("hello\nworld", 3);
399 assert_eq!(line, Some(1));
400 assert_eq!(col, Some(4));
401 }
402
403 #[test]
404 fn offset_to_line_col_second_line() {
405 let (line, col) = offset_to_line_col("hello\nworld", 8);
406 assert_eq!(line, Some(2));
407 assert_eq!(col, Some(3));
408 }
409}