Skip to main content

simple_gal/
output.rs

1//! CLI output formatting for all pipeline stages.
2//!
3//! # Information-First Display
4//!
5//! Output is **information-centric, not file-centric**. The primary display
6//! for every entity (album, image, page) is its semantic identity — title and
7//! positional index — with filesystem paths shown as secondary context via
8//! indented `Source:` lines. This makes the output readable as a content
9//! inventory while still letting users trace data back to specific files.
10//!
11//! # Entity Display Contract
12//!
13//! Every entity follows a consistent two-level pattern across all stages:
14//!
15//! 1. **Header line**: positional index + title (+ optional detail like photo count)
16//! 2. **Context lines**: indented `Source:`, `Description:`, variant status, etc.
17//!
18//! Shared helpers ([`entity_header`], [`image_line`]) enforce this pattern so
19//! scan, process, and generate output look consistent for the same entities.
20//!
21//! # Output Format
22//!
23//! ## Scan
24//!
25//! ```text
26//! Albums
27//! 001 Landscapes (5 photos)
28//!     Source: 010-Landscapes/
29//!     001 dawn
30//!         Source: 001-dawn.jpg
31//!         Description: 001-dawn.txt
32//!     002 mountains
33//!         Source: 010-mountains.jpg
34//!
35//! Pages
36//! 001 About
37//!     Source: about.md
38//!
39//! Config
40//!     config.toml
41//!     assets/
42//! ```
43//!
44//! ## Process
45//!
46//! ```text
47//! Landscapes (5 photos)
48//!     001 dawn
49//!         Source: 001-dawn.jpg
50//!         800px: cached
51//!         1400px: encoded
52//!         thumbnail: cached
53//! ```
54//!
55//! ## Generate
56//!
57//! ```text
58//! Home → index.html
59//! 001 Landscapes → Landscapes/index.html
60//!     001 dawn → Landscapes/1-dawn/index.html
61//!     002 mountains → Landscapes/2-mountains/index.html
62//!
63//! Pages
64//! 001 About → about.html
65//!
66//! Generated 2 albums, 4 image pages, 1 page
67//! ```
68//!
69//! # Architecture
70//!
71//! Each stage has a `format_*` function (returns `Vec<String>`) for testability
72//! and a `print_*` wrapper that writes to stdout. Format functions are pure —
73//! no I/O, no side effects.
74
75use crate::types::NavItem;
76use std::path::Path;
77
78// ============================================================================
79// Shared entity display helpers
80// ============================================================================
81
82/// Format a 1-based positional index as 3-digit zero-padded.
83fn format_index(pos: usize) -> String {
84    format!("{:0>3}", pos)
85}
86
87/// Return indentation string: 4 spaces per depth level.
88fn indent(depth: usize) -> String {
89    "    ".repeat(depth)
90}
91
92/// Format an entity header: positional index + title, with optional detail.
93///
94/// Used for albums (with photo count) and containers (without).
95///
96/// ```text
97/// 001 Landscapes (5 photos)
98/// 001 Travel
99/// ```
100fn entity_header(index: usize, title: &str, count: Option<usize>) -> String {
101    match count {
102        Some(n) => format!("{} {} ({} photos)", format_index(index), title, n),
103        None => format!("{} {}", format_index(index), title),
104    }
105}
106
107/// Format an image line: titled images show title, untitled show filename in parens.
108///
109/// ```text
110/// 001 The Sunset        // titled
111/// 001 (010.avif)        // untitled — filename IS the identity
112/// ```
113fn image_line(index: usize, title: Option<&str>, filename: &str) -> String {
114    match title {
115        Some(t) if !t.is_empty() => format!("{} {}", format_index(index), t),
116        _ => format!("{} ({})", format_index(index), filename),
117    }
118}
119
120/// Strip HTML tags from a string (simple angle-bracket stripping).
121fn strip_html_tags(html: &str) -> String {
122    let mut result = String::with_capacity(html.len());
123    let mut in_tag = false;
124    for c in html.chars() {
125        match c {
126            '<' => in_tag = true,
127            '>' => in_tag = false,
128            _ if !in_tag => result.push(c),
129            _ => {}
130        }
131    }
132    result
133}
134
135/// Truncate text to `max` characters, appending `...` if truncated.
136fn truncate_desc(text: &str, max: usize) -> String {
137    if text.len() <= max {
138        text.to_string()
139    } else {
140        format!("{}...", &text[..max])
141    }
142}
143
144// ============================================================================
145// Tree walker
146// ============================================================================
147
148/// A flattened node from walking the NavItem tree.
149struct TreeNode {
150    depth: usize,
151    position: usize,
152    path: String,
153    is_container: bool,
154    source_dir: String,
155}
156
157/// Walk the navigation tree, assigning positional indices per sibling level.
158/// Returns a flat list of nodes with depth and position for formatting.
159fn walk_nav_tree(nav: &[NavItem]) -> Vec<TreeNode> {
160    let mut nodes = Vec::new();
161    walk_nav_tree_recursive(nav, 0, &mut nodes);
162    nodes
163}
164
165fn walk_nav_tree_recursive(items: &[NavItem], depth: usize, nodes: &mut Vec<TreeNode>) {
166    for (i, item) in items.iter().enumerate() {
167        let is_container = !item.children.is_empty();
168        nodes.push(TreeNode {
169            depth,
170            position: i + 1,
171            path: item.path.clone(),
172            is_container,
173            source_dir: item.source_dir.clone(),
174        });
175        if is_container {
176            walk_nav_tree_recursive(&item.children, depth + 1, nodes);
177        }
178    }
179}
180
181// ============================================================================
182// Stage 1: Scan output
183// ============================================================================
184
185/// Format scan stage output showing discovered gallery structure.
186///
187/// Information-first: each entity leads with its positional index and title.
188/// Source paths and description files are shown as indented context lines.
189pub fn format_scan_output(manifest: &crate::scan::Manifest, source_root: &Path) -> Vec<String> {
190    let mut lines = Vec::new();
191
192    // Albums section
193    lines.push("Albums".to_string());
194
195    let tree_nodes = walk_nav_tree(&manifest.navigation);
196    let mut shown_paths = std::collections::HashSet::new();
197
198    for node in &tree_nodes {
199        let base_indent = indent(node.depth);
200
201        if node.is_container {
202            let header = entity_header(
203                node.position,
204                node.path.split('/').next_back().unwrap_or(&node.path),
205                None,
206            );
207            lines.push(format!("{}{}", base_indent, header));
208            lines.push(format!("{}    Source: {}/", base_indent, node.source_dir));
209        } else if let Some(album) = manifest.albums.iter().find(|a| a.path == node.path) {
210            shown_paths.insert(&album.path);
211            let photo_count = album.images.len();
212            let header = entity_header(node.position, &album.title, Some(photo_count));
213            lines.push(format!("{}{}", base_indent, header));
214            lines.push(format!("{}    Source: {}/", base_indent, node.source_dir));
215
216            // Album description (truncated preview)
217            if let Some(ref desc) = album.description {
218                let plain = strip_html_tags(desc);
219                let truncated = truncate_desc(plain.trim(), 60);
220                if !truncated.is_empty() {
221                    lines.push(format!("{}    {}", base_indent, truncated));
222                }
223            }
224
225            // Images
226            for (i, img) in album.images.iter().enumerate() {
227                let img_indent = format!("{}    ", base_indent);
228                let img_header = image_line(i + 1, img.title.as_deref(), &img.filename);
229                lines.push(format!("{}{}", img_indent, img_header));
230
231                // Source (always shown for titled images; implicit for untitled)
232                if img.title.is_some() {
233                    lines.push(format!("{}    Source: {}", img_indent, img.filename));
234                }
235
236                // Description sidecar
237                let sidecar_path = source_root.join(&img.source_path).with_extension("txt");
238                if sidecar_path.exists() {
239                    let sidecar_name = sidecar_path.file_name().unwrap().to_string_lossy();
240                    lines.push(format!("{}    Description: {}", img_indent, sidecar_name));
241                }
242            }
243        }
244    }
245
246    // Un-navigated albums (hidden from nav, no number prefix)
247    for album in &manifest.albums {
248        if !shown_paths.contains(&album.path) {
249            let dir_name = album.path.split('/').next_back().unwrap_or(&album.path);
250            let photo_count = album.images.len();
251            lines.push(format!("    {} ({} photos)", dir_name, photo_count));
252            if let Some(ref desc) = album.description {
253                let plain = strip_html_tags(desc);
254                let truncated = truncate_desc(plain.trim(), 60);
255                if !truncated.is_empty() {
256                    lines.push(format!("        {}", truncated));
257                }
258            }
259        }
260    }
261
262    // Pages section
263    if !manifest.pages.is_empty() {
264        lines.push(String::new());
265        lines.push("Pages".to_string());
266        for (i, page) in manifest.pages.iter().enumerate() {
267            let link_marker = if page.is_link { " (link)" } else { "" };
268            lines.push(format!(
269                "    {} {}{}",
270                format_index(i + 1),
271                page.title,
272                link_marker
273            ));
274            lines.push(format!("        Source: {}.md", page.slug));
275        }
276    }
277
278    // Config section
279    lines.push(String::new());
280    lines.push("Config".to_string());
281    let config_path = source_root.join("config.toml");
282    if config_path.exists() {
283        lines.push("    config.toml".to_string());
284    }
285    let assets_path = source_root.join(&manifest.config.assets_dir);
286    if assets_path.is_dir() {
287        lines.push(format!("    {}/", manifest.config.assets_dir));
288    }
289
290    lines
291}
292
293/// Print scan output to stdout.
294pub fn print_scan_output(manifest: &crate::scan::Manifest, source_root: &Path) {
295    for line in format_scan_output(manifest, source_root) {
296        println!("{}", line);
297    }
298}
299
300// ============================================================================
301// Stage 2: Process output
302// ============================================================================
303
304/// Format a single process progress event as display lines.
305///
306/// Information-first: each image leads with its positional index and title.
307/// Source path and per-variant cache status are shown as indented context.
308pub fn format_process_event(event: &crate::process::ProcessEvent) -> Vec<String> {
309    use crate::process::{ProcessEvent, VariantStatus};
310    match event {
311        ProcessEvent::AlbumStarted { title, image_count } => {
312            vec![format!("{} ({} photos)", title, image_count)]
313        }
314        ProcessEvent::ImageProcessed {
315            index,
316            title,
317            source_path,
318            variants,
319        } => {
320            let mut lines = Vec::new();
321            let filename = Path::new(source_path)
322                .file_name()
323                .map(|f| f.to_string_lossy().into_owned())
324                .unwrap_or_else(|| source_path.clone());
325
326            lines.push(format!(
327                "    {}",
328                image_line(*index, title.as_deref(), &filename)
329            ));
330            lines.push(format!("        Source: {}", source_path));
331
332            for variant in variants {
333                let status_str = match &variant.status {
334                    VariantStatus::Cached => "cached",
335                    VariantStatus::Copied => "copied",
336                    VariantStatus::Encoded => "encoded",
337                };
338                lines.push(format!("        {}: {}", variant.label, status_str));
339            }
340            lines
341        }
342        ProcessEvent::CachePruned { removed } => {
343            vec![format!("    Pruned {} stale cache entries", removed)]
344        }
345    }
346}
347
348// ============================================================================
349// Stage 3: Generate output
350// ============================================================================
351
352/// Format generate stage output showing generated HTML files.
353///
354/// Information-first: each entity leads with its positional index and title,
355/// followed by `→` and the output path.
356pub fn format_generate_output(manifest: &crate::generate::Manifest) -> Vec<String> {
357    let mut lines = Vec::new();
358    let mut total_image_pages = 0;
359
360    // Home page
361    lines.push("Home \u{2192} index.html".to_string());
362
363    let tree_nodes = walk_nav_tree(&manifest.navigation);
364    let mut shown_paths = std::collections::HashSet::new();
365
366    for node in &tree_nodes {
367        let base_indent = indent(node.depth);
368
369        if node.is_container {
370            let header = entity_header(
371                node.position,
372                node.path.split('/').next_back().unwrap_or(&node.path),
373                None,
374            );
375            lines.push(format!("{}{}", base_indent, header));
376        } else if let Some(album) = manifest.albums.iter().find(|a| a.path == node.path) {
377            shown_paths.insert(&album.path);
378            let header = entity_header(node.position, &album.title, None);
379            lines.push(format!(
380                "{}{} \u{2192} {}/index.html",
381                base_indent, header, album.path
382            ));
383
384            for (idx, image) in album.images.iter().enumerate() {
385                let page_url = crate::generate::image_page_url(
386                    idx + 1,
387                    album.images.len(),
388                    image.title.as_deref(),
389                );
390                let display = match &image.title {
391                    Some(t) if !t.is_empty() => format!("{} {}", format_index(idx + 1), t),
392                    _ => format_index(idx + 1),
393                };
394                lines.push(format!(
395                    "{}    {} \u{2192} {}/{}index.html",
396                    base_indent, display, album.path, page_url
397                ));
398                total_image_pages += 1;
399            }
400        }
401    }
402
403    // Un-navigated albums
404    for album in &manifest.albums {
405        if !shown_paths.contains(&album.path) {
406            lines.push(format!(
407                "    {} \u{2192} {}/index.html",
408                album.title, album.path
409            ));
410
411            for (idx, image) in album.images.iter().enumerate() {
412                let page_url = crate::generate::image_page_url(
413                    idx + 1,
414                    album.images.len(),
415                    image.title.as_deref(),
416                );
417                let display = match &image.title {
418                    Some(t) if !t.is_empty() => format!("{} {}", format_index(idx + 1), t),
419                    _ => format_index(idx + 1),
420                };
421                lines.push(format!(
422                    "        {} \u{2192} {}/{}index.html",
423                    display, album.path, page_url
424                ));
425                total_image_pages += 1;
426            }
427        }
428    }
429
430    // Pages section
431    let page_count = manifest.pages.iter().filter(|p| !p.is_link).count();
432    if !manifest.pages.is_empty() {
433        lines.push(String::new());
434        lines.push("Pages".to_string());
435        for (i, page) in manifest.pages.iter().enumerate() {
436            if page.is_link {
437                lines.push(format!(
438                    "    {} {} \u{2192} (external link)",
439                    format_index(i + 1),
440                    page.title
441                ));
442            } else {
443                lines.push(format!(
444                    "    {} {} \u{2192} {}.html",
445                    format_index(i + 1),
446                    page.title,
447                    page.slug
448                ));
449            }
450        }
451    }
452
453    lines.push(format!(
454        "Generated {} albums, {} image pages, {} pages",
455        manifest.albums.len(),
456        total_image_pages,
457        page_count
458    ));
459
460    lines
461}
462
463/// Print generate output to stdout.
464pub fn print_generate_output(manifest: &crate::generate::Manifest) {
465    for line in format_generate_output(manifest) {
466        println!("{}", line);
467    }
468}
469
470// ============================================================================
471// Tests
472// ============================================================================
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    // =========================================================================
479    // Helper tests
480    // =========================================================================
481
482    #[test]
483    fn strip_html_tags_removes_tags() {
484        assert_eq!(strip_html_tags("<p>Hello <b>world</b></p>"), "Hello world");
485    }
486
487    #[test]
488    fn strip_html_tags_no_tags() {
489        assert_eq!(strip_html_tags("plain text"), "plain text");
490    }
491
492    #[test]
493    fn strip_html_tags_empty() {
494        assert_eq!(strip_html_tags(""), "");
495    }
496
497    #[test]
498    fn strip_html_tags_nested() {
499        assert_eq!(
500            strip_html_tags("<div><p>Some <em>text</em></p></div>"),
501            "Some text"
502        );
503    }
504
505    #[test]
506    fn truncate_desc_short() {
507        assert_eq!(truncate_desc("Short text", 40), "Short text");
508    }
509
510    #[test]
511    fn truncate_desc_exact() {
512        let text = "a".repeat(40);
513        assert_eq!(truncate_desc(&text, 40), text);
514    }
515
516    #[test]
517    fn truncate_desc_long() {
518        let text = "a".repeat(50);
519        let expected = format!("{}...", "a".repeat(40));
520        assert_eq!(truncate_desc(&text, 40), expected);
521    }
522
523    #[test]
524    fn truncate_desc_empty() {
525        assert_eq!(truncate_desc("", 40), "");
526    }
527
528    #[test]
529    fn format_index_single_digit() {
530        assert_eq!(format_index(1), "001");
531    }
532
533    #[test]
534    fn format_index_double_digit() {
535        assert_eq!(format_index(42), "042");
536    }
537
538    #[test]
539    fn format_index_triple_digit() {
540        assert_eq!(format_index(100), "100");
541    }
542
543    // =========================================================================
544    // Entity display helper tests
545    // =========================================================================
546
547    #[test]
548    fn entity_header_with_count() {
549        assert_eq!(
550            entity_header(1, "Landscapes", Some(5)),
551            "001 Landscapes (5 photos)"
552        );
553    }
554
555    #[test]
556    fn entity_header_without_count() {
557        assert_eq!(entity_header(2, "Travel", None), "002 Travel");
558    }
559
560    #[test]
561    fn image_line_with_title() {
562        assert_eq!(
563            image_line(1, Some("The Sunset"), "010-The-Sunset.avif"),
564            "001 The Sunset"
565        );
566    }
567
568    #[test]
569    fn image_line_without_title() {
570        assert_eq!(image_line(1, None, "010.avif"), "001 (010.avif)");
571    }
572
573    #[test]
574    fn image_line_with_empty_title() {
575        assert_eq!(image_line(1, Some(""), "010.avif"), "001 (010.avif)");
576    }
577
578    // =========================================================================
579    // Tree walker tests
580    // =========================================================================
581
582    #[test]
583    fn walk_nav_tree_empty() {
584        let nodes = walk_nav_tree(&[]);
585        assert!(nodes.is_empty());
586    }
587
588    #[test]
589    fn walk_nav_tree_flat() {
590        let nav = vec![
591            NavItem {
592                title: "A".to_string(),
593                path: "a".to_string(),
594                source_dir: "010-A".to_string(),
595                description: None,
596                children: vec![],
597            },
598            NavItem {
599                title: "B".to_string(),
600                path: "b".to_string(),
601                source_dir: "020-B".to_string(),
602                description: None,
603                children: vec![],
604            },
605        ];
606        let nodes = walk_nav_tree(&nav);
607        assert_eq!(nodes.len(), 2);
608        assert_eq!(nodes[0].position, 1);
609        assert_eq!(nodes[0].depth, 0);
610        assert_eq!(nodes[0].path, "a");
611        assert!(!nodes[0].is_container);
612        assert_eq!(nodes[1].position, 2);
613        assert_eq!(nodes[1].depth, 0);
614    }
615
616    #[test]
617    fn walk_nav_tree_nested() {
618        let nav = vec![NavItem {
619            title: "Parent".to_string(),
620            path: "parent".to_string(),
621            source_dir: "010-Parent".to_string(),
622            description: None,
623            children: vec![
624                NavItem {
625                    title: "Child A".to_string(),
626                    path: "parent/child-a".to_string(),
627                    source_dir: "010-Child-A".to_string(),
628                    description: None,
629                    children: vec![],
630                },
631                NavItem {
632                    title: "Child B".to_string(),
633                    path: "parent/child-b".to_string(),
634                    source_dir: "020-Child-B".to_string(),
635                    description: None,
636                    children: vec![],
637                },
638            ],
639        }];
640        let nodes = walk_nav_tree(&nav);
641        assert_eq!(nodes.len(), 3);
642        // Parent
643        assert_eq!(nodes[0].position, 1);
644        assert_eq!(nodes[0].depth, 0);
645        assert!(nodes[0].is_container);
646        // Child A
647        assert_eq!(nodes[1].position, 1);
648        assert_eq!(nodes[1].depth, 1);
649        assert!(!nodes[1].is_container);
650        // Child B
651        assert_eq!(nodes[2].position, 2);
652        assert_eq!(nodes[2].depth, 1);
653    }
654
655    #[test]
656    fn indent_zero() {
657        assert_eq!(indent(0), "");
658    }
659
660    #[test]
661    fn indent_one() {
662        assert_eq!(indent(1), "    ");
663    }
664
665    #[test]
666    fn indent_two() {
667        assert_eq!(indent(2), "        ");
668    }
669
670    // =========================================================================
671    // Process event formatting tests
672    // =========================================================================
673
674    #[test]
675    fn format_process_album_started() {
676        use crate::process::ProcessEvent;
677        let event = ProcessEvent::AlbumStarted {
678            title: "Landscapes".to_string(),
679            image_count: 5,
680        };
681        let lines = format_process_event(&event);
682        assert_eq!(lines, vec!["Landscapes (5 photos)"]);
683    }
684
685    #[test]
686    fn format_process_image_with_title() {
687        use crate::process::{ProcessEvent, VariantInfo, VariantStatus};
688        let event = ProcessEvent::ImageProcessed {
689            index: 1,
690            title: Some("The Sunset".to_string()),
691            source_path: "010-Landscapes/001-sunset.jpg".to_string(),
692            variants: vec![
693                VariantInfo {
694                    label: "800px".to_string(),
695                    status: VariantStatus::Cached,
696                },
697                VariantInfo {
698                    label: "1400px".to_string(),
699                    status: VariantStatus::Encoded,
700                },
701                VariantInfo {
702                    label: "thumbnail".to_string(),
703                    status: VariantStatus::Copied,
704                },
705            ],
706        };
707        let lines = format_process_event(&event);
708        assert_eq!(lines[0], "    001 The Sunset");
709        assert_eq!(lines[1], "        Source: 010-Landscapes/001-sunset.jpg");
710        assert_eq!(lines[2], "        800px: cached");
711        assert_eq!(lines[3], "        1400px: encoded");
712        assert_eq!(lines[4], "        thumbnail: copied");
713    }
714
715    #[test]
716    fn format_process_image_without_title() {
717        use crate::process::{ProcessEvent, VariantInfo, VariantStatus};
718        let event = ProcessEvent::ImageProcessed {
719            index: 3,
720            title: None,
721            source_path: "002-NY/38.avif".to_string(),
722            variants: vec![VariantInfo {
723                label: "800px".to_string(),
724                status: VariantStatus::Cached,
725            }],
726        };
727        let lines = format_process_event(&event);
728        assert_eq!(lines[0], "    003 (38.avif)");
729        assert_eq!(lines[1], "        Source: 002-NY/38.avif");
730    }
731}