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