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// ----- config -----
335
336/// JSON envelope for any `simple-gal config <action>` invocation.
337///
338/// Mirrors clapfig's [`ConfigResult`][clapfig::ConfigResult] but flattens
339/// each variant into a tagged `action` so consumers can branch on a single
340/// field without parsing free-form text.
341#[derive(Debug, Serialize)]
342#[serde(tag = "action", rename_all = "snake_case")]
343pub enum ConfigOpPayload {
344    /// `config gen` (printed to stdout).
345    Gen { toml: String },
346    /// `config gen --output PATH` (written to a file).
347    GenWritten { path: PathBuf },
348    /// `config schema` (printed to stdout).
349    Schema { schema: serde_json::Value },
350    /// `config schema --output PATH` (written to a file).
351    SchemaWritten { path: PathBuf },
352    /// `config get KEY`.
353    Get {
354        key: String,
355        value: String,
356        #[serde(skip_serializing_if = "Vec::is_empty")]
357        doc: Vec<String>,
358    },
359    /// `config set KEY VALUE`.
360    Set { key: String, value: String },
361    /// `config unset KEY`.
362    Unset { key: String },
363    /// `config` / `config list` — flat key/value listing.
364    List { entries: Vec<ConfigListEntry> },
365}
366
367/// One row of a `config list` listing.
368#[derive(Debug, Serialize)]
369pub struct ConfigListEntry {
370    pub key: String,
371    pub value: String,
372}
373
374impl ConfigOpPayload {
375    /// Convert clapfig's `ConfigResult` into the wire envelope.
376    ///
377    /// For `Schema`, the JSON string clapfig produced is re-parsed into a
378    /// `serde_json::Value` so the schema lands as structured JSON inside
379    /// the envelope (rather than as a string-of-JSON that consumers would
380    /// have to double-parse).
381    pub fn from_result(result: &clapfig::ConfigResult) -> Self {
382        use clapfig::ConfigResult as R;
383        match result {
384            R::Template(t) => ConfigOpPayload::Gen { toml: t.clone() },
385            R::TemplateWritten { path } => ConfigOpPayload::GenWritten { path: path.clone() },
386            R::Schema(s) => ConfigOpPayload::Schema {
387                // Schema strings are produced by serde_json::to_string_pretty
388                // upstream, so re-parsing is infallible in practice.
389                schema: serde_json::from_str(s)
390                    .unwrap_or_else(|_| serde_json::Value::String(s.clone())),
391            },
392            R::SchemaWritten { path } => ConfigOpPayload::SchemaWritten { path: path.clone() },
393            R::KeyValue { key, value, doc } => ConfigOpPayload::Get {
394                key: key.clone(),
395                value: value.clone(),
396                doc: doc.clone(),
397            },
398            R::ValueSet { key, value } => ConfigOpPayload::Set {
399                key: key.clone(),
400                value: value.clone(),
401            },
402            R::ValueUnset { key } => ConfigOpPayload::Unset { key: key.clone() },
403            R::Listing { entries } => ConfigOpPayload::List {
404                entries: entries
405                    .iter()
406                    .map(|(k, v)| ConfigListEntry {
407                        key: k.clone(),
408                        value: v.clone(),
409                    })
410                    .collect(),
411            },
412        }
413    }
414}
415
416// ============================================================================
417// Helpers for writing envelopes
418// ============================================================================
419
420/// Serialize `value` to pretty JSON on stdout, followed by a newline.
421/// Returns the serde error so the caller can route a serialization
422/// failure through the normal error envelope + exit-code path — we never
423/// want to print a truncated document and silently exit 0.
424pub fn emit_stdout<T: Serialize>(value: &T) -> Result<(), serde_json::Error> {
425    let s = serde_json::to_string_pretty(value)?;
426    println!("{s}");
427    Ok(())
428}
429
430/// Serialize `value` to pretty JSON on stderr, followed by a newline. Used
431/// for error envelopes so stdout stays clean on failure.
432pub fn emit_stderr<T: Serialize>(value: &T) -> Result<(), serde_json::Error> {
433    let s = serde_json::to_string_pretty(value)?;
434    eprintln!("{s}");
435    Ok(())
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn exit_codes_are_distinct() {
444        let kinds = [
445            ErrorKind::Internal,
446            ErrorKind::Usage,
447            ErrorKind::Config,
448            ErrorKind::Io,
449            ErrorKind::Scan,
450            ErrorKind::Process,
451            ErrorKind::Generate,
452            ErrorKind::Validation,
453        ];
454        let codes: Vec<i32> = kinds.iter().map(|k| k.exit_code()).collect();
455        let mut sorted = codes.clone();
456        sorted.sort_unstable();
457        sorted.dedup();
458        assert_eq!(sorted.len(), kinds.len(), "exit codes must be unique");
459        assert!(!codes.contains(&0), "0 is reserved for success");
460    }
461
462    #[test]
463    fn error_envelope_collects_causes() {
464        use std::io;
465        let err = io::Error::other("outer");
466        let env = ErrorEnvelope::new(ErrorKind::Io, &err);
467        assert!(!env.ok);
468        assert_eq!(env.message, "outer");
469    }
470
471    #[test]
472    fn offset_to_line_col_first_line() {
473        let (line, col) = offset_to_line_col("hello\nworld", 3);
474        assert_eq!(line, Some(1));
475        assert_eq!(col, Some(4));
476    }
477
478    #[test]
479    fn offset_to_line_col_second_line() {
480        let (line, col) = offset_to_line_col("hello\nworld", 8);
481        assert_eq!(line, Some(2));
482        assert_eq!(col, Some(3));
483    }
484}