Skip to main content

simple_gal/
json_output.rs

1//! Machine-readable JSON envelopes for every CLI command + the error path.
2//!
3//! Every command, when invoked with `--format json`, emits exactly one JSON
4//! document to stdout (for success) or to stderr (for errors). These types
5//! define the on-the-wire shape of those documents and are the automation
6//! contract: GUIs and shell scripts parse them instead of scraping the
7//! human-readable text output.
8
9use crate::cache::CacheStats;
10use crate::config::ConfigError;
11use crate::generate;
12use crate::scan;
13use serde::Serialize;
14use std::path::{Path, PathBuf};
15
16// ============================================================================
17// Error envelope
18// ============================================================================
19
20/// Classification of a CLI failure. Drives both the JSON `kind` field and
21/// the process exit code so automated callers can branch on failure type
22/// without parsing messages.
23#[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    /// Process exit code for this error kind. 0 is reserved for success;
38    /// 2 is reserved for clap/usage errors (clap emits those directly).
39    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/// Extra context for config-file parse failures so a GUI can highlight
54/// the exact token without re-parsing.
55#[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/// The top-level shape emitted to stderr when any command fails in JSON mode.
67#[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        // Only attach a `config` payload for parse-location-carrying
88        // variants (currently `ConfigError::Toml`). Validation/IO config
89        // errors have no file position, so we leave the field unset
90        // instead of emitting an empty `path` that would confuse clients.
91        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        // Validation / IO config errors carry no file position — skip
135        // the payload entirely rather than emit an empty `path`.
136        _ => 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// ============================================================================
159// Success envelopes
160// ============================================================================
161
162/// Wrapper written to stdout for every successful command in JSON mode.
163/// The `command` tag lets a GUI dispatch on the payload shape.
164#[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// ----- scan -----
189
190#[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// ----- process -----
220
221#[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// ----- generate -----
248
249#[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// ----- build -----
316
317#[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// ----- check -----
326
327#[derive(Debug, Serialize)]
328pub struct CheckPayload<'a> {
329    pub valid: bool,
330    pub source: &'a Path,
331    pub counts: Counts,
332}
333
334// ----- gen-config -----
335
336#[derive(Debug, Serialize)]
337pub struct GenConfigPayload {
338    pub toml: String,
339}
340
341// ============================================================================
342// Helpers for writing envelopes
343// ============================================================================
344
345/// Serialize `value` to pretty JSON on stdout, followed by a newline.
346/// Returns the serde error so the caller can route a serialization
347/// failure through the normal error envelope + exit-code path — we never
348/// want to print a truncated document and silently exit 0.
349pub 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
355/// Serialize `value` to pretty JSON on stderr, followed by a newline. Used
356/// for error envelopes so stdout stays clean on failure.
357pub 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}