Skip to main content

simple_gal/
scan.rs

1//! Filesystem scanning and manifest generation.
2//!
3//! Stage 1 of the Simple Gal build pipeline. Scans a directory tree to discover
4//! albums and images, producing a structured manifest that subsequent stages consume.
5//!
6//! ## Directory Structure
7//!
8//! Simple Gal expects a specific directory layout:
9//!
10//! ```text
11//! content/                         # Content root
12//! ├── config.toml                  # Site configuration (optional)
13//! ├── 040-about.md                 # Page (numbered = appears in nav)
14//! ├── 050-github.md                # External link page (URL-only content)
15//! ├── 010-Landscapes/              # Album (numbered = appears in nav)
16//! │   ├── description.txt                 # Album description (optional)
17//! │   ├── 001-dawn.jpg             # Preview image (lowest number)
18//! │   ├── 002-sunset.jpg
19//! │   └── 010-mountains.jpg
20//! ├── 020-Travel/                  # Container directory (has subdirs)
21//! │   ├── 010-Japan/               # Nested album
22//! │   │   ├── 001-tokyo.jpg
23//! │   │   └── 002-kyoto.jpg
24//! │   └── 020-Italy/
25//! │       └── 001-rome.jpg
26//! ├── 030-Minimal/                 # Another album
27//! │   └── 001-simple.jpg
28//! └── wip-drafts/                  # Unnumbered = hidden from nav
29//!     └── 001-draft.jpg
30//! ```
31//!
32//! ## Naming Conventions
33//!
34//! - **Numbered directories** (`NNN-name`): Appear in navigation, sorted by number
35//! - **Unnumbered directories**: Albums exist but are hidden from navigation
36//! - **Numbered images** (`NNN-name.ext`): Sorted by number within album
37//! - **Thumb images** (`NNN-thumb.ext` or `NNN-thumb-Title.ext`): Designated album thumbnail
38//! - **Image #1**: Fallback album preview/thumbnail when no thumb image exists
39//!
40//! ## Output
41//!
42//! Produces a [`Manifest`] containing:
43//! - Navigation tree (numbered directories only) — container directories carry an
44//!   optional `description` field read from `description.md`/`description.txt`
45//! - All albums with their images
46//! - Pages from markdown files (content pages and external links)
47//! - Site configuration
48//!
49//! ## Validation
50//!
51//! The scanner enforces these rules:
52//! - No mixed content (directories cannot contain both images and subdirectories)
53//! - No duplicate image numbers within an album
54//! - Every album must have at least one image
55
56use crate::config::{self, SiteConfig};
57use crate::metadata;
58use crate::naming::parse_entry_name;
59use crate::types::{NavItem, Page};
60use serde::Serialize;
61use std::collections::BTreeMap;
62use std::fs;
63use std::path::{Path, PathBuf};
64use thiserror::Error;
65
66#[derive(Error, Debug)]
67pub enum ScanError {
68    #[error("IO error: {0}")]
69    Io(#[from] std::io::Error),
70    #[error("Config error: {0}")]
71    Config(#[from] config::ConfigError),
72    #[error("Directory contains both images and subdirectories: {0}")]
73    MixedContent(PathBuf),
74    #[error("Duplicate image number {0} in {1}")]
75    DuplicateNumber(u32, PathBuf),
76    #[error("Multiple thumb-designated images in {0}")]
77    DuplicateThumb(PathBuf),
78}
79
80/// Manifest output from the scan stage
81#[derive(Debug, Serialize)]
82pub struct Manifest {
83    pub navigation: Vec<NavItem>,
84    pub albums: Vec<Album>,
85    #[serde(skip_serializing_if = "Vec::is_empty")]
86    pub pages: Vec<Page>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub description: Option<String>,
89    pub config: SiteConfig,
90}
91
92/// Album with its images and resolved configuration.
93#[derive(Debug, Serialize)]
94pub struct Album {
95    pub path: String,
96    pub title: String,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub description: Option<String>,
99    pub preview_image: String,
100    pub images: Vec<Image>,
101    pub in_nav: bool,
102    /// Resolved config for this album (stock → root → group → gallery chain).
103    pub config: SiteConfig,
104    /// Supporting files found in the album directory (e.g. config.toml, description.md).
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub support_files: Vec<String>,
107}
108
109/// Image metadata
110///
111/// Image filenames follow `(<seq>-)?<title>.<ext>` format:
112/// - `001-Museum.jpeg` → number=1, title=Some("Museum")
113/// - `001.jpeg` → number=1, title=None
114/// - `001-.jpeg` → number=1, title=None
115/// - `Museum.jpg` → unnumbered, title=Some("Museum")
116///
117/// The sequence number controls sort order; the title (if present) is
118/// displayed in the breadcrumb on the image detail page.
119#[derive(Debug, Serialize)]
120pub struct Image {
121    pub number: u32,
122    pub source_path: String,
123    pub filename: String,
124    /// URL-safe name from filename, dashes preserved (e.g., "L1020411" from "015-L1020411.jpg")
125    pub slug: String,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub title: Option<String>,
128    /// Image description from sidecar `.txt` file (e.g., `001-photo.txt` for `001-photo.jpg`)
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub description: Option<String>,
131}
132
133pub fn scan(root: &Path) -> Result<Manifest, ScanError> {
134    let mut albums = Vec::new();
135    let mut nav_items = Vec::new();
136
137    // Build the config chain: stock defaults → root config.toml
138    let base = SiteConfig::default();
139    let root_partial = config::load_partial_config(root)?;
140    let root_config = match root_partial {
141        Some(partial) => base.merge(partial),
142        None => base,
143    };
144
145    scan_directory(
146        root,
147        root,
148        &mut albums,
149        &mut nav_items,
150        &root_config,
151        &root_config.assets_dir,
152    )?;
153
154    // Strip number prefixes from output paths (used for URLs and output dirs).
155    // Sorting has already happened with original paths, so this is safe.
156    for album in &mut albums {
157        album.path = slug_path(&album.path);
158    }
159    slugify_nav_paths(&mut nav_items);
160
161    let description = read_description(root, &root_config.site_description_file)?;
162    let pages = parse_pages(root, &root_config.site_description_file)?;
163
164    // Root-level resolved config for CSS generation
165    let config = root_config;
166
167    Ok(Manifest {
168        navigation: nav_items,
169        albums,
170        pages,
171        description,
172        config,
173    })
174}
175
176/// Convert a relative path to a slug path by stripping number prefixes from each component.
177/// `"020-Travel/010-Japan"` → `"travel/japan"`
178fn slug_path(rel_path: &str) -> String {
179    rel_path
180        .split('/')
181        .map(|component| {
182            let parsed = parse_entry_name(component);
183            if parsed.name.is_empty() {
184                component.to_string()
185            } else {
186                parsed.name
187            }
188        })
189        .collect::<Vec<_>>()
190        .join("/")
191}
192
193/// Recursively strip number prefixes from all NavItem paths.
194fn slugify_nav_paths(items: &mut [NavItem]) {
195    for item in items.iter_mut() {
196        item.path = slug_path(&item.path);
197        slugify_nav_paths(&mut item.children);
198    }
199}
200
201/// Parse all markdown files in the root directory into pages.
202///
203/// Each `.md` file becomes a page. Numbered files (`NNN-name.md`) appear in
204/// navigation sorted by number; unnumbered files are generated but hidden.
205/// If a file's only content is a URL, it becomes an external link in the nav.
206/// The `site_description_stem` file (e.g. `site.md`) is excluded — it is
207/// rendered on the index page, not as a standalone page.
208fn parse_pages(root: &Path, site_description_stem: &str) -> Result<Vec<Page>, ScanError> {
209    let exclude_filename = format!("{}.md", site_description_stem);
210    let mut md_files: Vec<PathBuf> = fs::read_dir(root)?
211        .filter_map(|e| e.ok())
212        .map(|e| e.path())
213        .filter(|p| {
214            p.is_file()
215                && p.extension()
216                    .map(|e| e.eq_ignore_ascii_case("md"))
217                    .unwrap_or(false)
218                && p.file_name()
219                    .map(|n| n.to_string_lossy() != exclude_filename)
220                    .unwrap_or(true)
221        })
222        .collect();
223
224    md_files.sort();
225
226    let mut pages = Vec::new();
227    for md_path in &md_files {
228        let stem = md_path
229            .file_stem()
230            .map(|s| s.to_string_lossy().to_string())
231            .unwrap_or_default();
232
233        let parsed = parse_entry_name(&stem);
234        let in_nav = parsed.number.is_some();
235        let sort_key = parsed.number.unwrap_or(u32::MAX);
236        let link_title = parsed.display_title;
237        let slug = parsed.name;
238
239        let content = fs::read_to_string(md_path)?;
240        let trimmed = content.trim();
241
242        // A page whose only content is a URL becomes an external link
243        let is_link = !trimmed.contains('\n')
244            && (trimmed.starts_with("http://") || trimmed.starts_with("https://"));
245
246        let title = if is_link {
247            link_title.clone()
248        } else {
249            content
250                .lines()
251                .find(|line| line.starts_with("# "))
252                .map(|line| line.trim_start_matches("# ").trim().to_string())
253                .unwrap_or_else(|| link_title.clone())
254        };
255
256        pages.push(Page {
257            title,
258            link_title,
259            slug,
260            body: content,
261            in_nav,
262            sort_key,
263            is_link,
264        });
265    }
266
267    pages.sort_by_key(|p| p.sort_key);
268    Ok(pages)
269}
270
271fn scan_directory(
272    path: &Path,
273    root: &Path,
274    albums: &mut Vec<Album>,
275    nav_items: &mut Vec<NavItem>,
276    inherited_config: &SiteConfig,
277    assets_dir: &str,
278) -> Result<(), ScanError> {
279    let entries = collect_entries(path, if path == root { Some(assets_dir) } else { None })?;
280
281    let images = entries.iter().filter(|e| is_image(e)).collect::<Vec<_>>();
282
283    let subdirs = entries.iter().filter(|e| e.is_dir()).collect::<Vec<_>>();
284
285    // Check for mixed content
286    if !images.is_empty() && !subdirs.is_empty() {
287        return Err(ScanError::MixedContent(path.to_path_buf()));
288    }
289
290    // Merge any local config.toml onto the inherited config (skip root — already handled)
291    let effective_config = if path != root {
292        match config::load_partial_config(path)? {
293            Some(partial) => inherited_config.clone().merge(partial),
294            None => inherited_config.clone(),
295        }
296    } else {
297        inherited_config.clone()
298    };
299
300    if !images.is_empty() {
301        // This is an album
302        effective_config.validate()?;
303        let album = build_album(path, root, &images, effective_config)?;
304        let in_nav = album.in_nav;
305        let title = album.title.clone();
306        let album_path = album.path.clone();
307
308        let source_dir_name = path.file_name().unwrap().to_string_lossy().to_string();
309        albums.push(album);
310
311        // Add to nav if numbered
312        if in_nav {
313            nav_items.push(NavItem {
314                title,
315                path: album_path,
316                source_dir: source_dir_name,
317                description: None,
318                children: vec![],
319            });
320        }
321    } else if !subdirs.is_empty() {
322        // This is a container directory
323        let mut child_nav = Vec::new();
324
325        // Sort subdirs by their number prefix
326        let mut sorted_subdirs = subdirs.clone();
327        sorted_subdirs.sort_by_key(|d| {
328            let name = d.file_name().unwrap().to_string_lossy().to_string();
329            (parse_entry_name(&name).number.unwrap_or(u32::MAX), name)
330        });
331
332        for subdir in sorted_subdirs {
333            scan_directory(
334                subdir,
335                root,
336                albums,
337                &mut child_nav,
338                &effective_config,
339                assets_dir,
340            )?;
341        }
342
343        // If this directory is numbered, add it to nav with children
344        if path != root {
345            let dir_name = path.file_name().unwrap().to_string_lossy();
346            let parsed = parse_entry_name(&dir_name);
347            if parsed.number.is_some() {
348                let rel_path = path.strip_prefix(root).unwrap();
349                let description = read_album_description(path)?;
350                nav_items.push(NavItem {
351                    title: parsed.display_title,
352                    path: rel_path.to_string_lossy().to_string(),
353                    source_dir: dir_name.to_string(),
354                    description,
355                    children: child_nav,
356                });
357            } else {
358                // Unnumbered container - its children still get added at this level
359                nav_items.extend(child_nav);
360            }
361        } else {
362            // Root directory - just extend nav_items with children
363            nav_items.extend(child_nav);
364        }
365    }
366
367    // Sort nav_items by their original directory number
368    nav_items.sort_by_key(|item| {
369        let dir_name = item.path.split('/').next_back().unwrap_or("");
370        parse_entry_name(dir_name).number.unwrap_or(u32::MAX)
371    });
372
373    Ok(())
374}
375
376fn collect_entries(path: &Path, assets_dir: Option<&str>) -> Result<Vec<PathBuf>, ScanError> {
377    let mut entries: Vec<PathBuf> = fs::read_dir(path)?
378        .filter_map(|e| e.ok())
379        .map(|e| e.path())
380        .filter(|p| {
381            let name = p.file_name().unwrap().to_string_lossy();
382            // Skip hidden files, description files, config.toml, and build artifacts
383            !name.starts_with('.')
384                && name != "description.txt"
385                && name != "description.md"
386                && name != "config.toml"
387                && name != "processed"
388                && name != "dist"
389                && name != "manifest.json"
390                && assets_dir.is_none_or(|ad| *name != *ad)
391        })
392        .collect();
393
394    entries.sort();
395    Ok(entries)
396}
397
398fn is_image(path: &Path) -> bool {
399    if !path.is_file() {
400        return false;
401    }
402    let ext = path
403        .extension()
404        .map(|e| e.to_string_lossy().to_lowercase())
405        .unwrap_or_default();
406    crate::imaging::supported_input_extensions().contains(&ext.as_str())
407}
408
409/// Read a description from `<stem>.md` or `<stem>.txt` in the given directory.
410///
411/// - `.md` takes priority and is rendered as markdown HTML.
412/// - `.txt` is converted to HTML with smart paragraph handling and URL linkification.
413/// - Returns `None` if neither file exists or contents are empty.
414fn read_description(dir: &Path, stem: &str) -> Result<Option<String>, ScanError> {
415    let md_path = dir.join(format!("{}.md", stem));
416    if md_path.exists() {
417        let content = fs::read_to_string(&md_path)?.trim().to_string();
418        if content.is_empty() {
419            return Ok(None);
420        }
421        let parser = pulldown_cmark::Parser::new(&content);
422        let mut html = String::new();
423        pulldown_cmark::html::push_html(&mut html, parser);
424        return Ok(Some(html));
425    }
426
427    let txt_path = dir.join(format!("{}.txt", stem));
428    if txt_path.exists() {
429        let content = fs::read_to_string(&txt_path)?.trim().to_string();
430        if content.is_empty() {
431            return Ok(None);
432        }
433        return Ok(Some(plain_text_to_html(&content)));
434    }
435
436    Ok(None)
437}
438
439/// Read an album description from `description.md` or `description.txt`.
440fn read_album_description(album_dir: &Path) -> Result<Option<String>, ScanError> {
441    read_description(album_dir, "description")
442}
443
444/// Convert plain text to HTML with smart paragraph detection and URL linkification.
445///
446/// - Double newlines (`\n\n`) split text into `<p>` elements.
447/// - URLs starting with `http://` or `https://` are wrapped in `<a>` tags.
448fn plain_text_to_html(text: &str) -> String {
449    let paragraphs: Vec<&str> = text.split("\n\n").collect();
450    paragraphs
451        .iter()
452        .map(|p| {
453            let escaped = linkify_urls(&html_escape(p.trim()));
454            format!("<p>{}</p>", escaped)
455        })
456        .collect::<Vec<_>>()
457        .join("\n")
458}
459
460/// Escape HTML special characters.
461fn html_escape(text: &str) -> String {
462    text.replace('&', "&amp;")
463        .replace('<', "&lt;")
464        .replace('>', "&gt;")
465        .replace('"', "&quot;")
466}
467
468/// Find URLs in text and wrap them in anchor tags.
469fn linkify_urls(text: &str) -> String {
470    let mut result = String::with_capacity(text.len());
471    let mut remaining = text;
472
473    while let Some(start) = remaining
474        .find("https://")
475        .or_else(|| remaining.find("http://"))
476    {
477        result.push_str(&remaining[..start]);
478        let url_text = &remaining[start..];
479        let end = url_text
480            .find(|c: char| c.is_whitespace() || c == '<' || c == '>' || c == '"')
481            .unwrap_or(url_text.len());
482        let url = &url_text[..end];
483        result.push_str(&format!(r#"<a href="{url}">{url}</a>"#));
484        remaining = &url_text[end..];
485    }
486    result.push_str(remaining);
487    result
488}
489
490fn build_album(
491    path: &Path,
492    root: &Path,
493    images: &[&PathBuf],
494    config: SiteConfig,
495) -> Result<Album, ScanError> {
496    let rel_path = path.strip_prefix(root).unwrap();
497    let dir_name = path.file_name().unwrap().to_string_lossy();
498
499    let parsed_dir = parse_entry_name(&dir_name);
500    let in_nav = parsed_dir.number.is_some();
501    let title = if in_nav {
502        parsed_dir.display_title
503    } else {
504        dir_name.to_string()
505    };
506
507    // Parse image names and check for duplicates.
508    // Store ParsedName alongside each image to avoid double-parsing.
509    let mut numbered_images: BTreeMap<u32, (&PathBuf, crate::naming::ParsedName)> = BTreeMap::new();
510    let mut unnumbered_counter = 0u32;
511    for img in images {
512        let filename = img.file_name().unwrap().to_string_lossy();
513        let stem = Path::new(&*filename).file_stem().unwrap().to_string_lossy();
514        let parsed = parse_entry_name(&stem);
515        if let Some(num) = parsed.number {
516            if numbered_images.contains_key(&num) {
517                return Err(ScanError::DuplicateNumber(num, path.to_path_buf()));
518            }
519            numbered_images.insert(num, (img, parsed));
520        } else {
521            // Images without numbers get sorted to the end, preserving filename order
522            let high_num = 1_000_000 + unnumbered_counter;
523            unnumbered_counter += 1;
524            numbered_images.insert(high_num, (img, parsed));
525        }
526    }
527
528    // Detect thumb-designated images (name starts with "thumb", case-insensitive)
529    let thumb_keys: Vec<u32> = numbered_images
530        .iter()
531        .filter(|(_, (_, parsed))| {
532            let lower = parsed.name.to_ascii_lowercase();
533            lower == "thumb" || lower.starts_with("thumb-")
534        })
535        .map(|(&key, _)| key)
536        .collect();
537
538    if thumb_keys.len() > 1 {
539        return Err(ScanError::DuplicateThumb(path.to_path_buf()));
540    }
541
542    let thumb_key = thumb_keys.first().copied();
543
544    // Find preview image: thumb > #1 > first by sort order
545    let preview_image = if let Some(key) = thumb_key {
546        numbered_images.get(&key).map(|(p, _)| *p).unwrap()
547    } else {
548        numbered_images
549            .iter()
550            .find(|&(&num, _)| num == 1)
551            .map(|(_, (p, _))| *p)
552            .or_else(|| numbered_images.values().next().map(|(p, _)| *p))
553            // Safe: build_album is only called with non-empty images
554            .unwrap()
555    };
556
557    let preview_rel = preview_image.strip_prefix(root).unwrap();
558
559    // Build image list (thumb-designated images stay in the gallery, they're just also used as preview)
560    let images: Vec<Image> = numbered_images
561        .iter()
562        .map(|(&num, (img_path, parsed))| {
563            let filename = img_path.file_name().unwrap().to_string_lossy().to_string();
564
565            let title = if parsed.display_title.is_empty() {
566                None
567            } else {
568                Some(parsed.display_title.clone())
569            };
570            let slug = parsed.name.clone();
571
572            let source = img_path.strip_prefix(root).unwrap();
573            let description = metadata::read_sidecar(img_path);
574            Image {
575                number: num,
576                source_path: source.to_string_lossy().to_string(),
577                filename,
578                slug,
579                title,
580                description,
581            }
582        })
583        .collect();
584
585    // Read description: description.md takes priority over description.txt
586    let description = read_album_description(path)?;
587
588    // Detect supporting files
589    let mut support_files = Vec::new();
590    if path.join("config.toml").exists() {
591        support_files.push("config.toml".to_string());
592    }
593    if path.join("description.md").exists() {
594        support_files.push("description.md".to_string());
595    } else if path.join("description.txt").exists() {
596        support_files.push("description.txt".to_string());
597    }
598
599    Ok(Album {
600        path: rel_path.to_string_lossy().to_string(),
601        title,
602        description,
603        preview_image: preview_rel.to_string_lossy().to_string(),
604        images,
605        in_nav,
606        config,
607        support_files,
608    })
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use crate::test_helpers::*;
615    use std::fs;
616    use tempfile::TempDir;
617
618    #[test]
619    fn scan_finds_all_albums() {
620        let tmp = setup_fixtures();
621        let manifest = scan(tmp.path()).unwrap();
622
623        assert_eq!(
624            album_titles(&manifest),
625            vec!["Landscapes", "Japan", "Italy", "Minimal", "wip-drafts"]
626        );
627    }
628
629    #[test]
630    fn numbered_albums_appear_in_nav() {
631        let tmp = setup_fixtures();
632        let manifest = scan(tmp.path()).unwrap();
633
634        assert_eq!(
635            nav_titles(&manifest),
636            vec!["Landscapes", "Travel", "Minimal"]
637        );
638    }
639
640    #[test]
641    fn unnumbered_albums_hidden_from_nav() {
642        let tmp = setup_fixtures();
643        let manifest = scan(tmp.path()).unwrap();
644
645        assert!(!find_album(&manifest, "wip-drafts").in_nav);
646    }
647
648    #[test]
649    fn fixture_full_nav_shape() {
650        let tmp = setup_fixtures();
651        let manifest = scan(tmp.path()).unwrap();
652
653        assert_nav_shape(
654            &manifest,
655            &[
656                ("Landscapes", &[]),
657                ("Travel", &["Japan", "Italy"]),
658                ("Minimal", &[]),
659            ],
660        );
661    }
662
663    #[test]
664    fn images_sorted_by_number() {
665        let tmp = setup_fixtures();
666        let manifest = scan(tmp.path()).unwrap();
667
668        let numbers: Vec<u32> = find_album(&manifest, "Landscapes")
669            .images
670            .iter()
671            .map(|i| i.number)
672            .collect();
673        assert_eq!(numbers, vec![1, 2, 5, 10]);
674    }
675
676    #[test]
677    fn image_title_extracted_in_scan() {
678        let tmp = TempDir::new().unwrap();
679
680        let album = tmp.path().join("010-Test");
681        fs::create_dir_all(&album).unwrap();
682        fs::write(album.join("001-Dawn.jpg"), "fake image").unwrap();
683        fs::write(album.join("002.jpg"), "fake image").unwrap();
684        fs::write(album.join("003-My-Museum.jpg"), "fake image").unwrap();
685
686        let manifest = scan(tmp.path()).unwrap();
687        let images = &manifest.albums[0].images;
688
689        assert_eq!(images[0].title.as_deref(), Some("Dawn"));
690        assert_eq!(images[1].title, None);
691        assert_eq!(images[2].title.as_deref(), Some("My Museum"));
692    }
693
694    #[test]
695    fn description_read_from_description_txt() {
696        let tmp = setup_fixtures();
697        let manifest = scan(tmp.path()).unwrap();
698
699        let desc = find_album(&manifest, "Landscapes")
700            .description
701            .as_ref()
702            .unwrap();
703        assert!(desc.contains("<p>"));
704        assert!(desc.contains("landscape"));
705
706        assert!(find_album(&manifest, "Minimal").description.is_none());
707    }
708
709    #[test]
710    fn description_md_takes_priority_over_txt() {
711        let tmp = TempDir::new().unwrap();
712        let album = tmp.path().join("010-Test");
713        fs::create_dir_all(&album).unwrap();
714        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
715        fs::write(album.join("description.txt"), "Text version").unwrap();
716        fs::write(album.join("description.md"), "**Markdown** version").unwrap();
717
718        let manifest = scan(tmp.path()).unwrap();
719        let desc = manifest.albums[0].description.as_ref().unwrap();
720        assert!(desc.contains("<strong>Markdown</strong>"));
721        assert!(!desc.contains("Text version"));
722    }
723
724    #[test]
725    fn description_txt_converts_paragraphs() {
726        let tmp = TempDir::new().unwrap();
727        let album = tmp.path().join("010-Test");
728        fs::create_dir_all(&album).unwrap();
729        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
730        fs::write(
731            album.join("description.txt"),
732            "First paragraph.\n\nSecond paragraph.",
733        )
734        .unwrap();
735
736        let manifest = scan(tmp.path()).unwrap();
737        let desc = manifest.albums[0].description.as_ref().unwrap();
738        assert!(desc.contains("<p>First paragraph.</p>"));
739        assert!(desc.contains("<p>Second paragraph.</p>"));
740    }
741
742    #[test]
743    fn description_txt_linkifies_urls() {
744        let tmp = TempDir::new().unwrap();
745        let album = tmp.path().join("010-Test");
746        fs::create_dir_all(&album).unwrap();
747        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
748        fs::write(
749            album.join("description.txt"),
750            "Visit https://example.com for more.",
751        )
752        .unwrap();
753
754        let manifest = scan(tmp.path()).unwrap();
755        let desc = manifest.albums[0].description.as_ref().unwrap();
756        assert!(desc.contains(r#"<a href="https://example.com">https://example.com</a>"#));
757    }
758
759    #[test]
760    fn description_md_renders_markdown() {
761        let tmp = TempDir::new().unwrap();
762        let album = tmp.path().join("010-Test");
763        fs::create_dir_all(&album).unwrap();
764        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
765        fs::write(
766            album.join("description.md"),
767            "# Title\n\nSome *italic* text.",
768        )
769        .unwrap();
770
771        let manifest = scan(tmp.path()).unwrap();
772        let desc = manifest.albums[0].description.as_ref().unwrap();
773        assert!(desc.contains("<h1>Title</h1>"));
774        assert!(desc.contains("<em>italic</em>"));
775    }
776
777    #[test]
778    fn description_empty_file_returns_none() {
779        let tmp = TempDir::new().unwrap();
780        let album = tmp.path().join("010-Test");
781        fs::create_dir_all(&album).unwrap();
782        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
783        fs::write(album.join("description.txt"), "   \n  ").unwrap();
784
785        let manifest = scan(tmp.path()).unwrap();
786        assert!(manifest.albums[0].description.is_none());
787    }
788
789    #[test]
790    fn description_txt_escapes_html() {
791        let tmp = TempDir::new().unwrap();
792        let album = tmp.path().join("010-Test");
793        fs::create_dir_all(&album).unwrap();
794        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
795        fs::write(
796            album.join("description.txt"),
797            "<script>alert('xss')</script>",
798        )
799        .unwrap();
800
801        let manifest = scan(tmp.path()).unwrap();
802        let desc = manifest.albums[0].description.as_ref().unwrap();
803        assert!(!desc.contains("<script>"));
804        assert!(desc.contains("&lt;script&gt;"));
805    }
806
807    #[test]
808    fn preview_image_is_thumb() {
809        let tmp = setup_fixtures();
810        let manifest = scan(tmp.path()).unwrap();
811
812        assert!(
813            find_album(&manifest, "Landscapes")
814                .preview_image
815                .contains("005-thumb")
816        );
817    }
818
819    #[test]
820    fn mixed_content_is_error() {
821        let tmp = TempDir::new().unwrap();
822
823        // Create a directory with both images and subdirs
824        let mixed = tmp.path().join("010-Mixed");
825        fs::create_dir_all(&mixed).unwrap();
826        fs::create_dir_all(mixed.join("subdir")).unwrap();
827
828        // Create a placeholder image in mixed dir (scan only checks extension)
829        fs::write(mixed.join("001-photo.jpg"), "fake image").unwrap();
830
831        let result = scan(tmp.path());
832        assert!(matches!(result, Err(ScanError::MixedContent(_))));
833    }
834
835    #[test]
836    fn duplicate_number_is_error() {
837        let tmp = TempDir::new().unwrap();
838
839        let album = tmp.path().join("010-Album");
840        fs::create_dir_all(&album).unwrap();
841
842        // Create two images with the same number (scan only checks extension)
843        fs::write(album.join("001-first.jpg"), "fake image").unwrap();
844        fs::write(album.join("001-second.jpg"), "fake image").unwrap();
845
846        let result = scan(tmp.path());
847        assert!(matches!(result, Err(ScanError::DuplicateNumber(1, _))));
848    }
849
850    // =========================================================================
851    // Page tests
852    // =========================================================================
853
854    #[test]
855    fn pages_parsed_from_fixtures() {
856        let tmp = setup_fixtures();
857        let manifest = scan(tmp.path()).unwrap();
858
859        assert!(manifest.pages.len() >= 2);
860
861        let about = find_page(&manifest, "about");
862        assert_eq!(about.title, "About This Gallery");
863        assert_eq!(about.link_title, "about");
864        assert!(about.body.contains("Simple Gal"));
865        assert!(about.in_nav);
866        assert!(!about.is_link);
867    }
868
869    #[test]
870    fn page_link_title_from_filename() {
871        let tmp = TempDir::new().unwrap();
872
873        let md_path = tmp.path().join("010-who-am-i.md");
874        fs::write(&md_path, "# My Title\n\nSome content.").unwrap();
875
876        let album = tmp.path().join("010-Test");
877        fs::create_dir_all(&album).unwrap();
878        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
879
880        let manifest = scan(tmp.path()).unwrap();
881
882        let page = manifest.pages.first().unwrap();
883        assert_eq!(page.link_title, "who am i");
884        assert_eq!(page.title, "My Title");
885        assert_eq!(page.slug, "who-am-i");
886        assert!(page.in_nav);
887    }
888
889    #[test]
890    fn page_title_fallback_to_link_title() {
891        let tmp = TempDir::new().unwrap();
892
893        let md_path = tmp.path().join("010-about-me.md");
894        fs::write(&md_path, "Just some content without a heading.").unwrap();
895
896        let album = tmp.path().join("010-Test");
897        fs::create_dir_all(&album).unwrap();
898        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
899
900        let manifest = scan(tmp.path()).unwrap();
901
902        let page = manifest.pages.first().unwrap();
903        assert_eq!(page.title, "about me");
904        assert_eq!(page.link_title, "about me");
905    }
906
907    #[test]
908    fn no_pages_when_no_markdown() {
909        let tmp = TempDir::new().unwrap();
910
911        let album = tmp.path().join("010-Test");
912        fs::create_dir_all(&album).unwrap();
913        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
914
915        let manifest = scan(tmp.path()).unwrap();
916        assert!(manifest.pages.is_empty());
917    }
918
919    #[test]
920    fn unnumbered_page_hidden_from_nav() {
921        let tmp = TempDir::new().unwrap();
922
923        fs::write(tmp.path().join("notes.md"), "# Notes\n\nSome notes.").unwrap();
924
925        let album = tmp.path().join("010-Test");
926        fs::create_dir_all(&album).unwrap();
927        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
928
929        let manifest = scan(tmp.path()).unwrap();
930
931        let page = manifest.pages.first().unwrap();
932        assert!(!page.in_nav);
933        assert_eq!(page.slug, "notes");
934    }
935
936    #[test]
937    fn link_page_detected() {
938        let tmp = TempDir::new().unwrap();
939
940        fs::write(
941            tmp.path().join("050-github.md"),
942            "https://github.com/example\n",
943        )
944        .unwrap();
945
946        let album = tmp.path().join("010-Test");
947        fs::create_dir_all(&album).unwrap();
948        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
949
950        let manifest = scan(tmp.path()).unwrap();
951
952        let page = manifest.pages.first().unwrap();
953        assert!(page.is_link);
954        assert!(page.in_nav);
955        assert_eq!(page.link_title, "github");
956        assert_eq!(page.slug, "github");
957    }
958
959    #[test]
960    fn multiline_content_not_detected_as_link() {
961        let tmp = TempDir::new().unwrap();
962
963        fs::write(
964            tmp.path().join("010-page.md"),
965            "https://example.com\nsome other content",
966        )
967        .unwrap();
968
969        let album = tmp.path().join("010-Test");
970        fs::create_dir_all(&album).unwrap();
971        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
972
973        let manifest = scan(tmp.path()).unwrap();
974
975        let page = manifest.pages.first().unwrap();
976        assert!(!page.is_link);
977    }
978
979    #[test]
980    fn multiple_pages_sorted_by_number() {
981        let tmp = TempDir::new().unwrap();
982
983        fs::write(tmp.path().join("020-second.md"), "# Second").unwrap();
984        fs::write(tmp.path().join("010-first.md"), "# First").unwrap();
985        fs::write(tmp.path().join("030-third.md"), "# Third").unwrap();
986
987        let album = tmp.path().join("010-Test");
988        fs::create_dir_all(&album).unwrap();
989        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
990
991        let manifest = scan(tmp.path()).unwrap();
992
993        let titles: Vec<&str> = manifest.pages.iter().map(|p| p.title.as_str()).collect();
994        assert_eq!(titles, vec!["First", "Second", "Third"]);
995    }
996
997    #[test]
998    fn link_page_in_fixtures() {
999        let tmp = setup_fixtures();
1000        let manifest = scan(tmp.path()).unwrap();
1001
1002        let github = find_page(&manifest, "github");
1003        assert!(github.is_link);
1004        assert!(github.in_nav);
1005        assert!(github.body.trim().starts_with("https://"));
1006    }
1007
1008    // =========================================================================
1009    // Config integration tests
1010    // =========================================================================
1011
1012    #[test]
1013    fn config_loaded_from_fixtures() {
1014        let tmp = setup_fixtures();
1015        let manifest = scan(tmp.path()).unwrap();
1016
1017        // Root config.toml overrides ALL defaults — verify a sample from each section
1018        assert_eq!(manifest.config.thumbnails.aspect_ratio, [3, 4]);
1019        assert_eq!(manifest.config.images.quality, 85);
1020        assert_eq!(manifest.config.images.sizes, vec![600, 1200, 1800]);
1021        assert_eq!(manifest.config.theme.thumbnail_gap, "0.75rem");
1022        assert_eq!(manifest.config.theme.mat_x.size, "4vw");
1023        assert_eq!(manifest.config.theme.mat_y.min, "1.5rem");
1024        assert_eq!(manifest.config.colors.light.background, "#fafafa");
1025        assert_eq!(manifest.config.colors.dark.text_muted, "#888888");
1026        assert_eq!(manifest.config.font.font, "Playfair Display");
1027        assert_eq!(manifest.config.font.weight, "400");
1028    }
1029
1030    #[test]
1031    fn default_config_when_no_toml() {
1032        let tmp = TempDir::new().unwrap();
1033
1034        let album = tmp.path().join("010-Test");
1035        fs::create_dir_all(&album).unwrap();
1036        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1037
1038        let manifest = scan(tmp.path()).unwrap();
1039
1040        // Should have default config values
1041        assert_eq!(manifest.config.colors.light.background, "#ffffff");
1042        assert_eq!(manifest.config.colors.dark.background, "#000000");
1043    }
1044
1045    // =========================================================================
1046    // Album path and structure tests
1047    // =========================================================================
1048
1049    #[test]
1050    fn album_paths_are_relative() {
1051        let tmp = setup_fixtures();
1052        let manifest = scan(tmp.path()).unwrap();
1053
1054        for album in &manifest.albums {
1055            // Paths should not start with / or contain absolute paths
1056            assert!(!album.path.starts_with('/'));
1057            assert!(!album.path.contains(tmp.path().to_str().unwrap()));
1058        }
1059    }
1060
1061    #[test]
1062    fn nested_album_path_includes_parent() {
1063        let tmp = setup_fixtures();
1064        let manifest = scan(tmp.path()).unwrap();
1065
1066        let japan = find_album(&manifest, "Japan");
1067        assert!(japan.path.contains("travel"));
1068        assert!(japan.path.contains("japan"));
1069        assert!(!japan.path.contains("020-"));
1070        assert!(!japan.path.contains("010-"));
1071    }
1072
1073    #[test]
1074    fn image_source_paths_are_relative() {
1075        let tmp = setup_fixtures();
1076        let manifest = scan(tmp.path()).unwrap();
1077
1078        for album in &manifest.albums {
1079            for image in &album.images {
1080                assert!(!image.source_path.starts_with('/'));
1081            }
1082        }
1083    }
1084
1085    // =========================================================================
1086    // plain_text_to_html and linkify_urls unit tests
1087    // =========================================================================
1088
1089    #[test]
1090    fn plain_text_single_paragraph() {
1091        assert_eq!(plain_text_to_html("Hello world"), "<p>Hello world</p>");
1092    }
1093
1094    #[test]
1095    fn plain_text_multiple_paragraphs() {
1096        assert_eq!(
1097            plain_text_to_html("First.\n\nSecond."),
1098            "<p>First.</p>\n<p>Second.</p>"
1099        );
1100    }
1101
1102    #[test]
1103    fn linkify_urls_https() {
1104        assert_eq!(
1105            linkify_urls("Visit https://example.com today"),
1106            r#"Visit <a href="https://example.com">https://example.com</a> today"#
1107        );
1108    }
1109
1110    #[test]
1111    fn linkify_urls_http() {
1112        assert_eq!(
1113            linkify_urls("See http://example.com here"),
1114            r#"See <a href="http://example.com">http://example.com</a> here"#
1115        );
1116    }
1117
1118    #[test]
1119    fn linkify_urls_no_urls() {
1120        assert_eq!(linkify_urls("No links here"), "No links here");
1121    }
1122
1123    #[test]
1124    fn linkify_urls_at_end_of_text() {
1125        assert_eq!(
1126            linkify_urls("Check https://example.com"),
1127            r#"Check <a href="https://example.com">https://example.com</a>"#
1128        );
1129    }
1130
1131    // =========================================================================
1132    // Per-gallery config chain tests
1133    // =========================================================================
1134
1135    #[test]
1136    fn album_gets_default_config_when_no_configs() {
1137        let tmp = TempDir::new().unwrap();
1138        let album = tmp.path().join("010-Test");
1139        fs::create_dir_all(&album).unwrap();
1140        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1141
1142        let manifest = scan(tmp.path()).unwrap();
1143        assert_eq!(manifest.albums[0].config.images.quality, 90);
1144        assert_eq!(manifest.albums[0].config.thumbnails.aspect_ratio, [4, 5]);
1145    }
1146
1147    #[test]
1148    fn album_inherits_root_config() {
1149        let tmp = TempDir::new().unwrap();
1150        fs::write(tmp.path().join("config.toml"), "[images]\nquality = 85\n").unwrap();
1151
1152        let album = tmp.path().join("010-Test");
1153        fs::create_dir_all(&album).unwrap();
1154        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1155
1156        let manifest = scan(tmp.path()).unwrap();
1157        assert_eq!(manifest.albums[0].config.images.quality, 85);
1158        // Other defaults preserved
1159        assert_eq!(
1160            manifest.albums[0].config.images.sizes,
1161            vec![800, 1400, 2080]
1162        );
1163    }
1164
1165    #[test]
1166    fn album_config_overrides_root() {
1167        let tmp = TempDir::new().unwrap();
1168        fs::write(tmp.path().join("config.toml"), "[images]\nquality = 85\n").unwrap();
1169
1170        let album = tmp.path().join("010-Test");
1171        fs::create_dir_all(&album).unwrap();
1172        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1173        fs::write(album.join("config.toml"), "[images]\nquality = 70\n").unwrap();
1174
1175        let manifest = scan(tmp.path()).unwrap();
1176        assert_eq!(manifest.albums[0].config.images.quality, 70);
1177        // Root config still at 85
1178        assert_eq!(manifest.config.images.quality, 85);
1179    }
1180
1181    #[test]
1182    fn nested_config_chain_three_levels() {
1183        let tmp = TempDir::new().unwrap();
1184
1185        // Root config: quality = 85
1186        fs::write(tmp.path().join("config.toml"), "[images]\nquality = 85\n").unwrap();
1187
1188        // Group: Travel with aspect_ratio override
1189        let travel = tmp.path().join("020-Travel");
1190        fs::create_dir_all(&travel).unwrap();
1191        fs::write(
1192            travel.join("config.toml"),
1193            "[thumbnails]\naspect_ratio = [1, 1]\n",
1194        )
1195        .unwrap();
1196
1197        // Gallery: Japan with quality override
1198        let japan = travel.join("010-Japan");
1199        fs::create_dir_all(&japan).unwrap();
1200        fs::write(japan.join("001-tokyo.jpg"), "fake image").unwrap();
1201        fs::write(japan.join("config.toml"), "[images]\nquality = 70\n").unwrap();
1202
1203        // Gallery: Italy with no config
1204        let italy = travel.join("020-Italy");
1205        fs::create_dir_all(&italy).unwrap();
1206        fs::write(italy.join("001-rome.jpg"), "fake image").unwrap();
1207
1208        let manifest = scan(tmp.path()).unwrap();
1209
1210        let japan_album = manifest.albums.iter().find(|a| a.title == "Japan").unwrap();
1211        // Japan: quality from its own config (70), aspect from group (1:1), sizes from stock
1212        assert_eq!(japan_album.config.images.quality, 70);
1213        assert_eq!(japan_album.config.thumbnails.aspect_ratio, [1, 1]);
1214        assert_eq!(japan_album.config.images.sizes, vec![800, 1400, 2080]);
1215
1216        let italy_album = manifest.albums.iter().find(|a| a.title == "Italy").unwrap();
1217        // Italy: quality from root (85), aspect from group (1:1)
1218        assert_eq!(italy_album.config.images.quality, 85);
1219        assert_eq!(italy_album.config.thumbnails.aspect_ratio, [1, 1]);
1220    }
1221
1222    #[test]
1223    fn fixture_per_gallery_config_overrides_root() {
1224        let tmp = setup_fixtures();
1225        let manifest = scan(tmp.path()).unwrap();
1226
1227        let landscapes = find_album(&manifest, "Landscapes");
1228        // Landscapes has its own config.toml: quality=75, aspect_ratio=[1,1]
1229        assert_eq!(landscapes.config.images.quality, 75);
1230        assert_eq!(landscapes.config.thumbnails.aspect_ratio, [1, 1]);
1231        // Other values inherited from root config
1232        assert_eq!(landscapes.config.images.sizes, vec![600, 1200, 1800]);
1233        assert_eq!(landscapes.config.colors.light.background, "#fafafa");
1234    }
1235
1236    #[test]
1237    fn fixture_album_without_config_inherits_root() {
1238        let tmp = setup_fixtures();
1239        let manifest = scan(tmp.path()).unwrap();
1240
1241        let minimal = find_album(&manifest, "Minimal");
1242        assert_eq!(minimal.config.images.quality, 85);
1243        assert_eq!(minimal.config.thumbnails.aspect_ratio, [3, 4]);
1244    }
1245
1246    #[test]
1247    fn fixture_config_chain_all_sections() {
1248        let tmp = setup_fixtures();
1249        let manifest = scan(tmp.path()).unwrap();
1250
1251        // Landscapes overrides images.quality and thumbnails.aspect_ratio;
1252        // everything else should come from root config.
1253        let ls = find_album(&manifest, "Landscapes");
1254
1255        // From Landscapes/config.toml
1256        assert_eq!(ls.config.images.quality, 75);
1257        assert_eq!(ls.config.thumbnails.aspect_ratio, [1, 1]);
1258
1259        // Inherited from root config — theme
1260        assert_eq!(ls.config.theme.thumbnail_gap, "0.75rem");
1261        assert_eq!(ls.config.theme.grid_padding, "1.5rem");
1262        assert_eq!(ls.config.theme.mat_x.size, "4vw");
1263        assert_eq!(ls.config.theme.mat_x.min, "0.5rem");
1264        assert_eq!(ls.config.theme.mat_x.max, "3rem");
1265        assert_eq!(ls.config.theme.mat_y.size, "5vw");
1266        assert_eq!(ls.config.theme.mat_y.min, "1.5rem");
1267        assert_eq!(ls.config.theme.mat_y.max, "4rem");
1268
1269        // Inherited from root config — colors
1270        assert_eq!(ls.config.colors.light.background, "#fafafa");
1271        assert_eq!(ls.config.colors.light.text_muted, "#777777");
1272        assert_eq!(ls.config.colors.light.border, "#d0d0d0");
1273        assert_eq!(ls.config.colors.light.link, "#444444");
1274        assert_eq!(ls.config.colors.light.link_hover, "#111111");
1275        assert_eq!(ls.config.colors.dark.background, "#111111");
1276        assert_eq!(ls.config.colors.dark.text, "#eeeeee");
1277        assert_eq!(ls.config.colors.dark.link, "#bbbbbb");
1278
1279        // Inherited from root config — font
1280        assert_eq!(ls.config.font.font, "Playfair Display");
1281        assert_eq!(ls.config.font.weight, "400");
1282        assert_eq!(ls.config.font.font_type, crate::config::FontType::Serif);
1283
1284        // Inherited from root config — image sizes (not overridden by gallery)
1285        assert_eq!(ls.config.images.sizes, vec![600, 1200, 1800]);
1286    }
1287
1288    #[test]
1289    fn fixture_image_sidecar_read() {
1290        let tmp = setup_fixtures();
1291        let manifest = scan(tmp.path()).unwrap();
1292
1293        // Landscapes: dawn has sidecar; dusk, thumb, and night do not
1294        assert_eq!(
1295            image_descriptions(find_album(&manifest, "Landscapes")),
1296            vec![
1297                Some("First light breaking over the mountain ridge."),
1298                None,
1299                None,
1300                None,
1301            ]
1302        );
1303
1304        // Japan: tokyo has a sidecar
1305        let tokyo = find_image(find_album(&manifest, "Japan"), "tokyo");
1306        assert_eq!(
1307            tokyo.description.as_deref(),
1308            Some("Shibuya crossing at dusk, long exposure.")
1309        );
1310    }
1311
1312    #[test]
1313    fn fixture_image_titles() {
1314        let tmp = setup_fixtures();
1315        let manifest = scan(tmp.path()).unwrap();
1316
1317        assert_eq!(
1318            image_titles(find_album(&manifest, "Landscapes")),
1319            vec![Some("dawn"), Some("dusk"), Some("thumb"), Some("night")]
1320        );
1321    }
1322
1323    #[test]
1324    fn fixture_description_md_overrides_txt() {
1325        let tmp = setup_fixtures();
1326        let manifest = scan(tmp.path()).unwrap();
1327
1328        let desc = find_album(&manifest, "Japan").description.as_ref().unwrap();
1329        assert!(desc.contains("<strong>Tokyo</strong>"));
1330        assert!(!desc.contains("Street photography"));
1331    }
1332
1333    // =========================================================================
1334    // Wider input variants
1335    // =========================================================================
1336
1337    #[test]
1338    fn http_link_page_detected() {
1339        let tmp = TempDir::new().unwrap();
1340        fs::write(tmp.path().join("010-link.md"), "http://example.com\n").unwrap();
1341
1342        let album = tmp.path().join("010-Test");
1343        fs::create_dir_all(&album).unwrap();
1344        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1345
1346        let manifest = scan(tmp.path()).unwrap();
1347        let page = manifest.pages.first().unwrap();
1348        assert!(page.is_link);
1349    }
1350
1351    #[test]
1352    fn preview_image_when_first_is_not_001() {
1353        let tmp = TempDir::new().unwrap();
1354        let album = tmp.path().join("010-Test");
1355        fs::create_dir_all(&album).unwrap();
1356        fs::write(album.join("005-first.jpg"), "fake image").unwrap();
1357        fs::write(album.join("010-second.jpg"), "fake image").unwrap();
1358
1359        let manifest = scan(tmp.path()).unwrap();
1360        assert!(manifest.albums[0].preview_image.contains("005-first"));
1361    }
1362
1363    #[test]
1364    fn description_md_preserves_inline_html() {
1365        let tmp = TempDir::new().unwrap();
1366        let album = tmp.path().join("010-Test");
1367        fs::create_dir_all(&album).unwrap();
1368        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1369        fs::write(
1370            album.join("description.md"),
1371            "Text with <em>emphasis</em> and a [link](https://example.com).",
1372        )
1373        .unwrap();
1374
1375        let manifest = scan(tmp.path()).unwrap();
1376        let desc = manifest.albums[0].description.as_ref().unwrap();
1377        // Markdown renders inline HTML and markdown syntax
1378        assert!(desc.contains("<em>emphasis</em>"));
1379        assert!(desc.contains(r#"<a href="https://example.com">link</a>"#));
1380    }
1381
1382    #[test]
1383    fn album_config_unknown_key_rejected() {
1384        let tmp = TempDir::new().unwrap();
1385        let album = tmp.path().join("010-Test");
1386        fs::create_dir_all(&album).unwrap();
1387        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1388        fs::write(album.join("config.toml"), "[images]\nqualty = 90\n").unwrap();
1389
1390        let result = scan(tmp.path());
1391        assert!(result.is_err());
1392    }
1393
1394    // =========================================================================
1395    // Assets directory tests
1396    // =========================================================================
1397
1398    #[test]
1399    fn assets_dir_skipped_during_scan() {
1400        let tmp = TempDir::new().unwrap();
1401
1402        // Create a real album
1403        let album = tmp.path().join("010-Test");
1404        fs::create_dir_all(&album).unwrap();
1405        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1406
1407        // Create an assets directory with files that look like images
1408        let assets = tmp.path().join("assets");
1409        fs::create_dir_all(assets.join("fonts")).unwrap();
1410        fs::write(assets.join("favicon.ico"), "icon data").unwrap();
1411        fs::write(assets.join("001-should-not-scan.jpg"), "fake image").unwrap();
1412
1413        let manifest = scan(tmp.path()).unwrap();
1414
1415        // Should only find the real album, not treat assets as an album
1416        assert_eq!(manifest.albums.len(), 1);
1417        assert_eq!(manifest.albums[0].title, "Test");
1418    }
1419
1420    #[test]
1421    fn custom_assets_dir_skipped_during_scan() {
1422        let tmp = TempDir::new().unwrap();
1423
1424        // Configure a custom assets dir
1425        fs::write(
1426            tmp.path().join("config.toml"),
1427            r#"assets_dir = "site-assets""#,
1428        )
1429        .unwrap();
1430
1431        // Create a real album
1432        let album = tmp.path().join("010-Test");
1433        fs::create_dir_all(&album).unwrap();
1434        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1435
1436        // Create the custom assets directory
1437        let assets = tmp.path().join("site-assets");
1438        fs::create_dir_all(&assets).unwrap();
1439        fs::write(assets.join("001-nope.jpg"), "fake image").unwrap();
1440
1441        // Also create a default "assets" dir — should NOT be skipped since config says "site-assets"
1442        let default_assets = tmp.path().join("assets");
1443        fs::create_dir_all(&default_assets).unwrap();
1444        fs::write(default_assets.join("001-also.jpg"), "fake image").unwrap();
1445
1446        let manifest = scan(tmp.path()).unwrap();
1447
1448        // "assets" dir should be scanned as an album (not skipped) since config overrides
1449        // "site-assets" dir should be skipped
1450        let album_titles: Vec<&str> = manifest.albums.iter().map(|a| a.title.as_str()).collect();
1451        assert!(album_titles.contains(&"Test"));
1452        assert!(album_titles.contains(&"assets"));
1453        assert!(!album_titles.iter().any(|t| *t == "site-assets"));
1454    }
1455
1456    // =========================================================================
1457    // Site description tests
1458    // =========================================================================
1459
1460    #[test]
1461    fn site_description_read_from_md() {
1462        let tmp = TempDir::new().unwrap();
1463        let album = tmp.path().join("010-Test");
1464        fs::create_dir_all(&album).unwrap();
1465        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1466        fs::write(tmp.path().join("site.md"), "**Welcome** to the gallery.").unwrap();
1467
1468        let manifest = scan(tmp.path()).unwrap();
1469        let desc = manifest.description.as_ref().unwrap();
1470        assert!(desc.contains("<strong>Welcome</strong>"));
1471    }
1472
1473    #[test]
1474    fn site_description_read_from_txt() {
1475        let tmp = TempDir::new().unwrap();
1476        let album = tmp.path().join("010-Test");
1477        fs::create_dir_all(&album).unwrap();
1478        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1479        fs::write(tmp.path().join("site.txt"), "A plain text description.").unwrap();
1480
1481        let manifest = scan(tmp.path()).unwrap();
1482        let desc = manifest.description.as_ref().unwrap();
1483        assert!(desc.contains("<p>A plain text description.</p>"));
1484    }
1485
1486    #[test]
1487    fn site_description_md_takes_priority_over_txt() {
1488        let tmp = TempDir::new().unwrap();
1489        let album = tmp.path().join("010-Test");
1490        fs::create_dir_all(&album).unwrap();
1491        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1492        fs::write(tmp.path().join("site.md"), "Markdown version").unwrap();
1493        fs::write(tmp.path().join("site.txt"), "Text version").unwrap();
1494
1495        let manifest = scan(tmp.path()).unwrap();
1496        let desc = manifest.description.as_ref().unwrap();
1497        assert!(desc.contains("Markdown version"));
1498        assert!(!desc.contains("Text version"));
1499    }
1500
1501    #[test]
1502    fn site_description_empty_returns_none() {
1503        let tmp = TempDir::new().unwrap();
1504        let album = tmp.path().join("010-Test");
1505        fs::create_dir_all(&album).unwrap();
1506        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1507        fs::write(tmp.path().join("site.md"), "  \n  ").unwrap();
1508
1509        let manifest = scan(tmp.path()).unwrap();
1510        assert!(manifest.description.is_none());
1511    }
1512
1513    #[test]
1514    fn site_description_none_when_no_file() {
1515        let tmp = TempDir::new().unwrap();
1516        let album = tmp.path().join("010-Test");
1517        fs::create_dir_all(&album).unwrap();
1518        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1519
1520        let manifest = scan(tmp.path()).unwrap();
1521        assert!(manifest.description.is_none());
1522    }
1523
1524    #[test]
1525    fn site_description_excluded_from_pages() {
1526        let tmp = TempDir::new().unwrap();
1527        let album = tmp.path().join("010-Test");
1528        fs::create_dir_all(&album).unwrap();
1529        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1530        fs::write(tmp.path().join("site.md"), "# Site Description\n\nContent.").unwrap();
1531        fs::write(tmp.path().join("010-about.md"), "# About\n\nAbout page.").unwrap();
1532
1533        let manifest = scan(tmp.path()).unwrap();
1534
1535        // site.md should be in description, not in pages
1536        assert!(manifest.description.is_some());
1537        assert_eq!(manifest.pages.len(), 1);
1538        assert_eq!(manifest.pages[0].slug, "about");
1539    }
1540
1541    #[test]
1542    fn site_description_custom_file_name() {
1543        let tmp = TempDir::new().unwrap();
1544        fs::write(
1545            tmp.path().join("config.toml"),
1546            r#"site_description_file = "intro""#,
1547        )
1548        .unwrap();
1549        let album = tmp.path().join("010-Test");
1550        fs::create_dir_all(&album).unwrap();
1551        fs::write(album.join("001-test.jpg"), "fake image").unwrap();
1552        fs::write(tmp.path().join("intro.md"), "Custom intro text.").unwrap();
1553
1554        let manifest = scan(tmp.path()).unwrap();
1555        let desc = manifest.description.as_ref().unwrap();
1556        assert!(desc.contains("Custom intro text."));
1557    }
1558
1559    #[test]
1560    fn fixture_site_description_loaded() {
1561        let tmp = setup_fixtures();
1562        let manifest = scan(tmp.path()).unwrap();
1563
1564        let desc = manifest.description.as_ref().unwrap();
1565        assert!(desc.contains("fine art photography"));
1566    }
1567
1568    #[test]
1569    fn fixture_site_md_not_in_pages() {
1570        let tmp = setup_fixtures();
1571        let manifest = scan(tmp.path()).unwrap();
1572
1573        // site.md should not appear as a page
1574        assert!(manifest.pages.iter().all(|p| p.slug != "site"));
1575    }
1576
1577    // =========================================================================
1578    // Thumb image tests
1579    // =========================================================================
1580
1581    #[test]
1582    fn thumb_image_overrides_preview() {
1583        let tmp = TempDir::new().unwrap();
1584        let album = tmp.path().join("010-Test");
1585        fs::create_dir_all(&album).unwrap();
1586        fs::write(album.join("001-first.jpg"), "fake image").unwrap();
1587        fs::write(album.join("005-thumb.jpg"), "fake image").unwrap();
1588        fs::write(album.join("010-last.jpg"), "fake image").unwrap();
1589
1590        let manifest = scan(tmp.path()).unwrap();
1591        assert!(manifest.albums[0].preview_image.contains("005-thumb"));
1592    }
1593
1594    #[test]
1595    fn thumb_image_included_in_images() {
1596        let tmp = TempDir::new().unwrap();
1597        let album = tmp.path().join("010-Test");
1598        fs::create_dir_all(&album).unwrap();
1599        fs::write(album.join("001-first.jpg"), "fake image").unwrap();
1600        fs::write(album.join("003-thumb.jpg"), "fake image").unwrap();
1601        fs::write(album.join("005-last.jpg"), "fake image").unwrap();
1602
1603        let manifest = scan(tmp.path()).unwrap();
1604        // Thumb is used as preview
1605        assert!(manifest.albums[0].preview_image.contains("003-thumb"));
1606        // And still included in the image list
1607        assert_eq!(manifest.albums[0].images.len(), 3);
1608        assert!(manifest.albums[0].images.iter().any(|i| i.number == 3));
1609    }
1610
1611    #[test]
1612    fn thumb_image_with_title_included_in_images() {
1613        let tmp = TempDir::new().unwrap();
1614        let album = tmp.path().join("010-Test");
1615        fs::create_dir_all(&album).unwrap();
1616        fs::write(album.join("001-first.jpg"), "fake image").unwrap();
1617        fs::write(album.join("003-thumb-The-Sunset.jpg"), "fake image").unwrap();
1618
1619        let manifest = scan(tmp.path()).unwrap();
1620        // Preview uses the thumb image
1621        assert!(
1622            manifest.albums[0]
1623                .preview_image
1624                .contains("003-thumb-The-Sunset")
1625        );
1626        // Thumb is also in the image list
1627        assert_eq!(manifest.albums[0].images.len(), 2);
1628        assert!(manifest.albums[0].images.iter().any(|i| i.number == 3));
1629    }
1630
1631    #[test]
1632    fn duplicate_thumb_is_error() {
1633        let tmp = TempDir::new().unwrap();
1634        let album = tmp.path().join("010-Test");
1635        fs::create_dir_all(&album).unwrap();
1636        fs::write(album.join("001-thumb.jpg"), "fake image").unwrap();
1637        fs::write(album.join("002-thumb-Other.jpg"), "fake image").unwrap();
1638
1639        let result = scan(tmp.path());
1640        assert!(matches!(result, Err(ScanError::DuplicateThumb(_))));
1641    }
1642
1643    #[test]
1644    fn no_thumb_falls_back_to_first() {
1645        let tmp = TempDir::new().unwrap();
1646        let album = tmp.path().join("010-Test");
1647        fs::create_dir_all(&album).unwrap();
1648        fs::write(album.join("005-first.jpg"), "fake image").unwrap();
1649        fs::write(album.join("010-second.jpg"), "fake image").unwrap();
1650
1651        let manifest = scan(tmp.path()).unwrap();
1652        // No thumb, no image #1 → falls back to first by sort order (005)
1653        assert!(manifest.albums[0].preview_image.contains("005-first"));
1654    }
1655}