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//!
9//! `--format ndjson` extends this with streaming: progress events are emitted
10//! as compact JSON lines (one per event, tagged `"type": "progress"`) as they
11//! happen, followed by a final `"type": "result"` line with the same envelope
12//! shape. Consumers read line-by-line and branch on the `type` field.
13
14use crate::cache::CacheStats;
15use crate::config::ConfigError;
16use crate::generate;
17use crate::scan;
18use serde::Serialize;
19use std::path::{Path, PathBuf};
20
21// ============================================================================
22// Error envelope
23// ============================================================================
24
25/// Classification of a CLI failure. Drives both the JSON `kind` field and
26/// the process exit code so automated callers can branch on failure type
27/// without parsing messages.
28#[derive(Debug, Clone, Copy, Serialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ErrorKind {
31    Config,
32    Io,
33    Scan,
34    Process,
35    Generate,
36    Validation,
37    Usage,
38    Internal,
39}
40
41impl ErrorKind {
42    /// Process exit code for this error kind. 0 is reserved for success;
43    /// 2 is reserved for clap/usage errors (clap emits those directly).
44    pub fn exit_code(self) -> i32 {
45        match self {
46            ErrorKind::Internal => 1,
47            ErrorKind::Usage => 2,
48            ErrorKind::Config => 3,
49            ErrorKind::Io => 4,
50            ErrorKind::Scan => 5,
51            ErrorKind::Process => 6,
52            ErrorKind::Generate => 7,
53            ErrorKind::Validation => 8,
54        }
55    }
56}
57
58/// Extra context for config-file parse failures so a GUI can highlight
59/// the exact token without re-parsing.
60#[derive(Debug, Serialize)]
61pub struct ConfigErrorPayload {
62    pub path: PathBuf,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub line: Option<usize>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub column: Option<usize>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub snippet: Option<String>,
69}
70
71/// The top-level shape emitted to stderr when any command fails in JSON mode.
72#[derive(Debug, Serialize)]
73pub struct ErrorEnvelope {
74    pub ok: bool,
75    pub kind: ErrorKind,
76    pub message: String,
77    #[serde(skip_serializing_if = "Vec::is_empty")]
78    pub causes: Vec<String>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub config: Option<ConfigErrorPayload>,
81}
82
83impl ErrorEnvelope {
84    pub fn new(kind: ErrorKind, err: &(dyn std::error::Error + 'static)) -> Self {
85        let message = err.to_string();
86        let mut causes = Vec::new();
87        let mut src = err.source();
88        while let Some(cause) = src {
89            causes.push(cause.to_string());
90            src = cause.source();
91        }
92        // Only attach a `config` payload for parse-location-carrying
93        // variants (currently `ConfigError::Toml`). Validation/IO config
94        // errors have no file position, so we leave the field unset
95        // instead of emitting an empty `path` that would confuse clients.
96        let config = find_config_error(err).and_then(config_error_payload);
97        Self {
98            ok: false,
99            kind,
100            message,
101            causes,
102            config,
103        }
104    }
105}
106
107fn find_config_error<'a>(err: &'a (dyn std::error::Error + 'static)) -> Option<&'a ConfigError> {
108    let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
109    while let Some(e) = current {
110        if let Some(cfg) = e.downcast_ref::<ConfigError>() {
111            return Some(cfg);
112        }
113        current = e.source();
114    }
115    None
116}
117
118fn config_error_payload(cfg: &ConfigError) -> Option<ConfigErrorPayload> {
119    match cfg {
120        ConfigError::Toml {
121            path,
122            source,
123            source_text,
124        } => {
125            let (line, column) = source
126                .span()
127                .map(|span| offset_to_line_col(source_text, span.start))
128                .unwrap_or((None, None));
129            let snippet = source
130                .span()
131                .and_then(|span| extract_snippet(source_text, span.start));
132            Some(ConfigErrorPayload {
133                path: path.clone(),
134                line,
135                column,
136                snippet,
137            })
138        }
139        // Validation / IO config errors carry no file position — skip
140        // the payload entirely rather than emit an empty `path`.
141        _ => None,
142    }
143}
144
145fn offset_to_line_col(text: &str, offset: usize) -> (Option<usize>, Option<usize>) {
146    let offset = offset.min(text.len());
147    let prefix = &text[..offset];
148    let line = prefix.bytes().filter(|b| *b == b'\n').count() + 1;
149    let col = prefix.rfind('\n').map(|i| offset - i - 1).unwrap_or(offset) + 1;
150    (Some(line), Some(col))
151}
152
153fn extract_snippet(text: &str, offset: usize) -> Option<String> {
154    let offset = offset.min(text.len());
155    let start = text[..offset].rfind('\n').map(|i| i + 1).unwrap_or(0);
156    let end = text[offset..]
157        .find('\n')
158        .map(|i| offset + i)
159        .unwrap_or(text.len());
160    Some(text[start..end].to_string())
161}
162
163// ============================================================================
164// Success envelopes
165// ============================================================================
166
167/// Wrapper written to stdout for every successful command in JSON mode.
168/// The `command` tag lets a GUI dispatch on the payload shape.
169#[derive(Debug, Serialize)]
170pub struct OkEnvelope<T: Serialize> {
171    pub ok: bool,
172    pub command: &'static str,
173    pub data: T,
174}
175
176impl<T: Serialize> OkEnvelope<T> {
177    pub fn new(command: &'static str, data: T) -> Self {
178        Self {
179            ok: true,
180            command,
181            data,
182        }
183    }
184}
185
186#[derive(Debug, Serialize)]
187pub struct Counts {
188    pub albums: usize,
189    pub images: usize,
190    pub pages: usize,
191}
192
193// ----- scan -----
194
195#[derive(Debug, Serialize)]
196pub struct ScanPayload<'a> {
197    pub source: &'a Path,
198    pub counts: Counts,
199    pub manifest: &'a scan::Manifest,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub saved_manifest_path: Option<PathBuf>,
202}
203
204impl<'a> ScanPayload<'a> {
205    pub fn new(
206        manifest: &'a scan::Manifest,
207        source: &'a Path,
208        saved_manifest_path: Option<PathBuf>,
209    ) -> Self {
210        let images = manifest.albums.iter().map(|a| a.images.len()).sum();
211        Self {
212            source,
213            counts: Counts {
214                albums: manifest.albums.len(),
215                images,
216                pages: manifest.pages.len(),
217            },
218            manifest,
219            saved_manifest_path,
220        }
221    }
222}
223
224// ----- process -----
225
226#[derive(Debug, Serialize)]
227pub struct CacheStatsPayload {
228    pub cached: u32,
229    pub copied: u32,
230    pub encoded: u32,
231    pub total: u32,
232}
233
234impl From<&CacheStats> for CacheStatsPayload {
235    fn from(s: &CacheStats) -> Self {
236        Self {
237            cached: s.hits,
238            copied: s.copies,
239            encoded: s.misses,
240            total: s.total(),
241        }
242    }
243}
244
245#[derive(Debug, Serialize)]
246pub struct ProcessPayload {
247    pub processed_dir: PathBuf,
248    pub manifest_path: PathBuf,
249    pub cache: CacheStatsPayload,
250}
251
252// ----- generate -----
253
254#[derive(Debug, Serialize)]
255pub struct GeneratePayload<'a> {
256    pub output: &'a Path,
257    pub counts: GenerateCounts,
258    pub albums: Vec<GeneratedAlbum>,
259    pub pages: Vec<GeneratedPage>,
260}
261
262#[derive(Debug, Serialize)]
263pub struct GenerateCounts {
264    pub albums: usize,
265    pub image_pages: usize,
266    pub pages: usize,
267}
268
269#[derive(Debug, Serialize)]
270pub struct GeneratedAlbum {
271    pub title: String,
272    pub path: String,
273    pub index_html: String,
274    pub image_count: usize,
275}
276
277#[derive(Debug, Serialize)]
278pub struct GeneratedPage {
279    pub title: String,
280    pub slug: String,
281    pub is_link: bool,
282}
283
284impl<'a> GeneratePayload<'a> {
285    pub fn new(manifest: &'a generate::Manifest, output: &'a Path) -> Self {
286        let image_pages = manifest.albums.iter().map(|a| a.images.len()).sum();
287        let pages_count = manifest.pages.iter().filter(|p| !p.is_link).count();
288        let albums = manifest
289            .albums
290            .iter()
291            .map(|a| GeneratedAlbum {
292                title: a.title.clone(),
293                path: a.path.clone(),
294                index_html: format!("{}/index.html", a.path),
295                image_count: a.images.len(),
296            })
297            .collect();
298        let pages = manifest
299            .pages
300            .iter()
301            .map(|p| GeneratedPage {
302                title: p.title.clone(),
303                slug: p.slug.clone(),
304                is_link: p.is_link,
305            })
306            .collect();
307        Self {
308            output,
309            counts: GenerateCounts {
310                albums: manifest.albums.len(),
311                image_pages,
312                pages: pages_count,
313            },
314            albums,
315            pages,
316        }
317    }
318}
319
320// ----- build -----
321
322#[derive(Debug, Serialize)]
323pub struct BuildPayload<'a> {
324    pub source: &'a Path,
325    pub output: &'a Path,
326    pub counts: GenerateCounts,
327    pub cache: CacheStatsPayload,
328}
329
330// ----- check -----
331
332#[derive(Debug, Serialize)]
333pub struct CheckPayload<'a> {
334    pub valid: bool,
335    pub source: &'a Path,
336    pub counts: Counts,
337}
338
339// ----- config -----
340
341/// JSON envelope for any `simple-gal config <action>` invocation.
342///
343/// Mirrors clapfig's [`ConfigResult`][clapfig::ConfigResult] but flattens
344/// each variant into a tagged `action` so consumers can branch on a single
345/// field without parsing free-form text.
346#[derive(Debug, Serialize)]
347#[serde(tag = "action", rename_all = "snake_case")]
348pub enum ConfigOpPayload {
349    /// `config gen` (printed to stdout).
350    Gen { toml: String },
351    /// `config gen --output PATH` (written to a file).
352    GenWritten { path: PathBuf },
353    /// `config schema` (printed to stdout).
354    Schema { schema: serde_json::Value },
355    /// `config schema --output PATH` (written to a file).
356    SchemaWritten { path: PathBuf },
357    /// `config get KEY`.
358    Get {
359        key: String,
360        value: String,
361        #[serde(skip_serializing_if = "Vec::is_empty")]
362        doc: Vec<String>,
363    },
364    /// `config set KEY VALUE`.
365    Set { key: String, value: String },
366    /// `config unset KEY`.
367    Unset { key: String },
368    /// `config` / `config list` — flat key/value listing.
369    List { entries: Vec<ConfigListEntry> },
370}
371
372/// One row of a `config list` listing.
373#[derive(Debug, Serialize)]
374pub struct ConfigListEntry {
375    pub key: String,
376    pub value: String,
377}
378
379impl ConfigOpPayload {
380    /// Convert clapfig's `ConfigResult` into the wire envelope.
381    ///
382    /// For `Schema`, the JSON string clapfig produced is re-parsed into a
383    /// `serde_json::Value` so the schema lands as structured JSON inside
384    /// the envelope (rather than as a string-of-JSON that consumers would
385    /// have to double-parse).
386    pub fn from_result(result: &clapfig::ConfigResult) -> Self {
387        use clapfig::ConfigResult as R;
388        match result {
389            R::Template(t) => ConfigOpPayload::Gen { toml: t.clone() },
390            R::TemplateWritten { path } => ConfigOpPayload::GenWritten { path: path.clone() },
391            R::Schema(s) => ConfigOpPayload::Schema {
392                // Schema strings are produced by serde_json::to_string_pretty
393                // upstream, so re-parsing is infallible in practice.
394                schema: serde_json::from_str(s)
395                    .unwrap_or_else(|_| serde_json::Value::String(s.clone())),
396            },
397            R::SchemaWritten { path } => ConfigOpPayload::SchemaWritten { path: path.clone() },
398            R::KeyValue { key, value, doc } => ConfigOpPayload::Get {
399                key: key.clone(),
400                value: value.clone(),
401                doc: doc.clone(),
402            },
403            R::ValueSet { key, value } => ConfigOpPayload::Set {
404                key: key.clone(),
405                value: value.clone(),
406            },
407            R::ValueUnset { key } => ConfigOpPayload::Unset { key: key.clone() },
408            R::Listing { entries } => ConfigOpPayload::List {
409                entries: entries
410                    .iter()
411                    .map(|(k, v)| ConfigListEntry {
412                        key: k.clone(),
413                        value: v.clone(),
414                    })
415                    .collect(),
416            },
417        }
418    }
419}
420
421// ============================================================================
422// Helpers for writing envelopes
423// ============================================================================
424
425/// Serialize `value` to pretty JSON on stdout, followed by a newline.
426/// Returns the serde error so the caller can route a serialization
427/// failure through the normal error envelope + exit-code path — we never
428/// want to print a truncated document and silently exit 0.
429pub fn emit_stdout<T: Serialize>(value: &T) -> Result<(), serde_json::Error> {
430    let s = serde_json::to_string_pretty(value)?;
431    println!("{s}");
432    Ok(())
433}
434
435/// Serialize `value` to pretty JSON on stderr, followed by a newline. Used
436/// for error envelopes so stdout stays clean on failure.
437pub fn emit_stderr<T: Serialize>(value: &T) -> Result<(), serde_json::Error> {
438    let s = serde_json::to_string_pretty(value)?;
439    eprintln!("{s}");
440    Ok(())
441}
442
443/// Serialize `value` to compact JSON on stderr, followed by a newline. Used
444/// for error envelopes in NDJSON mode.
445pub fn emit_stderr_compact<T: Serialize>(value: &T) -> Result<(), serde_json::Error> {
446    let s = serde_json::to_string(value)?;
447    eprintln!("{s}");
448    Ok(())
449}
450
451// ============================================================================
452// NDJSON (newline-delimited JSON) streaming helpers
453// ============================================================================
454//
455// In `--format ndjson` mode, each line on stdout is a self-contained JSON
456// object. Progress events stream as they happen (one per line), and the
457// final line is the result envelope. Every line carries a `"type"` field
458// so consumers can branch without parsing the full shape:
459//
460//   {"type":"progress","event":"album_started","title":"Landscapes","image_count":5}
461//   {"type":"progress","event":"image_processed","index":1, ...}
462//   {"type":"result","ok":true,"command":"build","data":{...}}
463
464/// Wrapper that tags a progress event with `"type": "progress"` for NDJSON.
465#[derive(Serialize)]
466struct NdjsonProgress<'a> {
467    r#type: &'static str,
468    #[serde(flatten)]
469    event: &'a crate::process::ProcessEvent,
470}
471
472/// Wrapper that tags a result envelope with `"type": "result"` for NDJSON.
473#[derive(Serialize)]
474struct NdjsonResult<'a, T: Serialize> {
475    r#type: &'static str,
476    #[serde(flatten)]
477    envelope: &'a T,
478}
479
480/// Emit a `ProcessEvent` as a single compact JSON line on stdout,
481/// tagged with `"type": "progress"`.
482pub fn emit_ndjson_progress(event: &crate::process::ProcessEvent) -> Result<(), serde_json::Error> {
483    let wrapped = NdjsonProgress {
484        r#type: "progress",
485        event,
486    };
487    let s = serde_json::to_string(&wrapped)?;
488    println!("{s}");
489    Ok(())
490}
491
492/// Emit a result envelope as a single compact JSON line on stdout,
493/// tagged with `"type": "result"`. This is always the last line in
494/// an NDJSON stream.
495pub fn emit_ndjson_result<T: Serialize>(envelope: &T) -> Result<(), serde_json::Error> {
496    let wrapped = NdjsonResult {
497        r#type: "result",
498        envelope,
499    };
500    let s = serde_json::to_string(&wrapped)?;
501    println!("{s}");
502    Ok(())
503}
504
505// ============================================================================
506// Structured progress tracking (`--format progress`)
507// ============================================================================
508//
509// Pre-computes percent-complete, stage, and image/variant counters so GUI
510// consumers can drive a progress bar without interpreting raw events.
511//
512// Weight model (empirically measured):
513//   scan     =  2%  — filesystem walk, near-instant
514//   process  = 90%  — image encoding dominates wall time
515//   generate =  8%  — HTML templating + file writes
516//
517// Within process, the unit of progress is one image variant (a responsive
518// size or thumbnail). `ProgressTracker` estimates total variants from the
519// config upfront, then increments as `ImageProcessed` events arrive.
520
521const SCAN_WEIGHT: f64 = 2.0;
522const PROCESS_WEIGHT: f64 = 90.0;
523// generate weight is implicitly 100 - SCAN_WEIGHT - PROCESS_WEIGHT = 8.0
524
525/// Pipeline stage reported in progress events.
526#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
527#[serde(rename_all = "snake_case")]
528pub enum Stage {
529    Scan,
530    Process,
531    Generate,
532}
533
534/// A single progress line emitted in `--format progress` mode.
535#[derive(Debug, Serialize)]
536pub struct ProgressEvent {
537    pub r#type: &'static str,
538    pub stage: Stage,
539    /// Overall percent complete, 0.0–100.0.
540    pub percent: f64,
541    pub images_done: usize,
542    pub images_total: usize,
543    pub variants_done: usize,
544    pub variants_total: usize,
545}
546
547/// Tracks build progress across the three pipeline stages.
548///
549/// Created after scan completes (when totals are known). Call
550/// [`on_image_processed`] for each `ProcessEvent::ImageProcessed` to
551/// advance the counters.
552pub struct ProgressTracker {
553    pub images_total: usize,
554    pub variants_total: usize,
555    images_done: usize,
556    variants_done: usize,
557}
558
559impl ProgressTracker {
560    /// Create a tracker from the scan results.
561    ///
562    /// `variants_per_image` is the config-based estimate:
563    /// `responsive_sizes.len() + 1 (thumbnail) + full_index_flag`.
564    pub fn new(images_total: usize, variants_per_image: usize) -> Self {
565        Self {
566            images_total,
567            variants_total: images_total * variants_per_image,
568            images_done: 0,
569            variants_done: 0,
570        }
571    }
572
573    /// Create a tracker with pre-computed totals (for per-album-config accuracy).
574    ///
575    /// Use this when albums have different configs so `variants_total` cannot
576    /// be derived from a single `variants_per_image` factor.
577    pub fn with_totals(images_total: usize, variants_total: usize) -> Self {
578        Self {
579            images_total,
580            variants_total,
581            images_done: 0,
582            variants_done: 0,
583        }
584    }
585
586    /// Build the scan-complete progress event (stage boundary).
587    pub fn scan_complete(&self) -> ProgressEvent {
588        ProgressEvent {
589            r#type: "progress",
590            stage: Stage::Scan,
591            percent: SCAN_WEIGHT,
592            images_done: 0,
593            images_total: self.images_total,
594            variants_done: 0,
595            variants_total: self.variants_total,
596        }
597    }
598
599    /// Record a completed image and return the updated progress event.
600    ///
601    /// `variant_count` is the actual number of variants this image produced
602    /// (taken from `ProcessEvent::ImageProcessed.variants.len()`).
603    pub fn on_image_processed(&mut self, variant_count: usize) -> ProgressEvent {
604        self.images_done += 1;
605        self.variants_done += variant_count;
606
607        let fraction = if self.variants_total > 0 {
608            (self.variants_done as f64 / self.variants_total as f64).min(1.0)
609        } else {
610            1.0
611        };
612        let percent = SCAN_WEIGHT + fraction * PROCESS_WEIGHT;
613
614        ProgressEvent {
615            r#type: "progress",
616            stage: Stage::Process,
617            percent,
618            images_done: self.images_done,
619            images_total: self.images_total,
620            variants_done: self.variants_done,
621            variants_total: self.variants_total,
622        }
623    }
624
625    /// Build the generate-started progress event (stage boundary).
626    pub fn generate_started(&self) -> ProgressEvent {
627        ProgressEvent {
628            r#type: "progress",
629            stage: Stage::Generate,
630            percent: SCAN_WEIGHT + PROCESS_WEIGHT,
631            images_done: self.images_done,
632            images_total: self.images_total,
633            variants_done: self.variants_done,
634            variants_total: self.variants_total,
635        }
636    }
637}
638
639/// Emit a progress event as a compact JSON line on stdout.
640pub fn emit_progress(event: &ProgressEvent) -> Result<(), serde_json::Error> {
641    let s = serde_json::to_string(event)?;
642    println!("{s}");
643    Ok(())
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    #[test]
651    fn exit_codes_are_distinct() {
652        let kinds = [
653            ErrorKind::Internal,
654            ErrorKind::Usage,
655            ErrorKind::Config,
656            ErrorKind::Io,
657            ErrorKind::Scan,
658            ErrorKind::Process,
659            ErrorKind::Generate,
660            ErrorKind::Validation,
661        ];
662        let codes: Vec<i32> = kinds.iter().map(|k| k.exit_code()).collect();
663        let mut sorted = codes.clone();
664        sorted.sort_unstable();
665        sorted.dedup();
666        assert_eq!(sorted.len(), kinds.len(), "exit codes must be unique");
667        assert!(!codes.contains(&0), "0 is reserved for success");
668    }
669
670    #[test]
671    fn error_envelope_collects_causes() {
672        use std::io;
673        let err = io::Error::other("outer");
674        let env = ErrorEnvelope::new(ErrorKind::Io, &err);
675        assert!(!env.ok);
676        assert_eq!(env.message, "outer");
677    }
678
679    #[test]
680    fn offset_to_line_col_first_line() {
681        let (line, col) = offset_to_line_col("hello\nworld", 3);
682        assert_eq!(line, Some(1));
683        assert_eq!(col, Some(4));
684    }
685
686    #[test]
687    fn offset_to_line_col_second_line() {
688        let (line, col) = offset_to_line_col("hello\nworld", 8);
689        assert_eq!(line, Some(2));
690        assert_eq!(col, Some(3));
691    }
692
693    // =========================================================================
694    // ProgressTracker tests
695    // =========================================================================
696
697    #[test]
698    fn progress_scan_complete_is_2_percent() {
699        let tracker = ProgressTracker::new(10, 4);
700        let ev = tracker.scan_complete();
701        assert_eq!(ev.stage, Stage::Scan);
702        assert!((ev.percent - 2.0).abs() < f64::EPSILON);
703        assert_eq!(ev.images_total, 10);
704        assert_eq!(ev.variants_total, 40);
705        assert_eq!(ev.images_done, 0);
706        assert_eq!(ev.variants_done, 0);
707    }
708
709    #[test]
710    fn progress_first_image_advances_correctly() {
711        let mut tracker = ProgressTracker::new(10, 4); // 40 variants
712        let ev = tracker.on_image_processed(4);
713        assert_eq!(ev.stage, Stage::Process);
714        assert_eq!(ev.images_done, 1);
715        assert_eq!(ev.variants_done, 4);
716        // 2 + (4/40)*90 = 2 + 9 = 11
717        assert!((ev.percent - 11.0).abs() < f64::EPSILON);
718    }
719
720    #[test]
721    fn progress_all_images_reaches_92_percent() {
722        let mut tracker = ProgressTracker::new(3, 4); // 12 variants
723        tracker.on_image_processed(4);
724        tracker.on_image_processed(4);
725        let ev = tracker.on_image_processed(4);
726        assert_eq!(ev.images_done, 3);
727        assert_eq!(ev.variants_done, 12);
728        // 2 + (12/12)*90 = 92
729        assert!((ev.percent - 92.0).abs() < f64::EPSILON);
730    }
731
732    #[test]
733    fn progress_generate_started_is_92_percent() {
734        let mut tracker = ProgressTracker::new(2, 3); // 6 variants
735        tracker.on_image_processed(3);
736        tracker.on_image_processed(3);
737        let ev = tracker.generate_started();
738        assert_eq!(ev.stage, Stage::Generate);
739        assert!((ev.percent - 92.0).abs() < f64::EPSILON);
740        assert_eq!(ev.images_done, 2);
741    }
742
743    #[test]
744    fn progress_fewer_variants_than_estimated_clamps() {
745        // Images produce fewer variants than the config estimate
746        let mut tracker = ProgressTracker::new(2, 4); // estimate: 8 variants
747        tracker.on_image_processed(2); // actual: 2
748        tracker.on_image_processed(2); // actual: 2, total: 4 out of 8
749        let ev = tracker.generate_started();
750        // variants_done=4 < variants_total=8, so process didn't fill 90%
751        // generate_started clamps to 92% regardless
752        assert!((ev.percent - 92.0).abs() < f64::EPSILON);
753    }
754
755    #[test]
756    fn progress_zero_images() {
757        let tracker = ProgressTracker::new(0, 4);
758        assert_eq!(tracker.variants_total, 0);
759        let ev = tracker.scan_complete();
760        assert!((ev.percent - 2.0).abs() < f64::EPSILON);
761    }
762}