1use crate::cache::CacheStats;
15use crate::config::ConfigError;
16use crate::generate;
17use crate::scan;
18use serde::Serialize;
19use std::path::{Path, PathBuf};
20
21#[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 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#[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#[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 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 _ => 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#[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#[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#[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#[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#[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#[derive(Debug, Serialize)]
333pub struct CheckPayload<'a> {
334 pub valid: bool,
335 pub source: &'a Path,
336 pub counts: Counts,
337}
338
339#[derive(Debug, Serialize)]
347#[serde(tag = "action", rename_all = "snake_case")]
348pub enum ConfigOpPayload {
349 Gen { toml: String },
351 GenWritten { path: PathBuf },
353 Schema { schema: serde_json::Value },
355 SchemaWritten { path: PathBuf },
357 Get {
359 key: String,
360 value: String,
361 #[serde(skip_serializing_if = "Vec::is_empty")]
362 doc: Vec<String>,
363 },
364 Set { key: String, value: String },
366 Unset { key: String },
368 List { entries: Vec<ConfigListEntry> },
370}
371
372#[derive(Debug, Serialize)]
374pub struct ConfigListEntry {
375 pub key: String,
376 pub value: String,
377}
378
379impl ConfigOpPayload {
380 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: 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
421pub 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
435pub 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
443pub 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#[derive(Serialize)]
466struct NdjsonProgress<'a> {
467 r#type: &'static str,
468 #[serde(flatten)]
469 event: &'a crate::process::ProcessEvent,
470}
471
472#[derive(Serialize)]
474struct NdjsonResult<'a, T: Serialize> {
475 r#type: &'static str,
476 #[serde(flatten)]
477 envelope: &'a T,
478}
479
480pub 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
492pub 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
505const SCAN_WEIGHT: f64 = 2.0;
522const PROCESS_WEIGHT: f64 = 90.0;
523#[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#[derive(Debug, Serialize)]
536pub struct ProgressEvent {
537 pub r#type: &'static str,
538 pub stage: Stage,
539 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
547pub 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 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 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 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 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 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
639pub 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 #[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); 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 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); 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 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); 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 let mut tracker = ProgressTracker::new(2, 4); tracker.on_image_processed(2); tracker.on_image_processed(2); let ev = tracker.generate_started();
750 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}