1use 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#[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#[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 pub config: SiteConfig,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub support_files: Vec<String>,
106}
107
108#[derive(Debug, Serialize)]
119pub struct Image {
120 pub number: u32,
121 pub source_path: String,
122 pub filename: String,
123 pub slug: String,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub title: Option<String>,
127 #[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 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 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 let config = root_config;
165
166 Ok(Manifest {
167 navigation: nav_items,
168 albums,
169 pages,
170 description,
171 config,
172 })
173}
174
175fn 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
192fn 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
200fn 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 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 if !images.is_empty() && !subdirs.is_empty() {
286 return Err(ScanError::MixedContent(path.to_path_buf()));
287 }
288
289 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 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 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 let mut child_nav = Vec::new();
322
323 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 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 nav_items.extend(child_nav);
356 }
357 } else {
358 nav_items.extend(child_nav);
360 }
361 }
362
363 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 !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
405fn 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
435fn read_album_description(album_dir: &Path) -> Result<Option<String>, ScanError> {
437 read_description(album_dir, "description")
438}
439
440fn 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
456fn html_escape(text: &str) -> String {
458 text.replace('&', "&")
459 .replace('<', "<")
460 .replace('>', ">")
461 .replace('"', """)
462}
463
464fn 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 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 let high_num = 1_000_000 + unnumbered_counter;
519 unnumbered_counter += 1;
520 numbered_images.insert(high_num, (img, parsed));
521 }
522 }
523
524 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 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 .unwrap()
551 };
552
553 let preview_rel = preview_image.strip_prefix(root).unwrap();
554
555 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 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 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 let description = read_album_description(path)?;
595
596 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("<script>"));
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 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 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 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 #[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 #[test]
1021 fn config_loaded_from_fixtures() {
1022 let tmp = setup_fixtures();
1023 let manifest = scan(tmp.path()).unwrap();
1024
1025 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 assert_eq!(manifest.config.colors.light.background, "#ffffff");
1050 assert_eq!(manifest.config.colors.dark.background, "#000000");
1051 }
1052
1053 #[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 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 #[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 #[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 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 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 fs::write(tmp.path().join("config.toml"), "[images]\nquality = 85\n").unwrap();
1195
1196 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 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 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 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 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 assert_eq!(landscapes.config.images.quality, 75);
1238 assert_eq!(landscapes.config.thumbnails.aspect_ratio, [1, 1]);
1239 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 let ls = find_album(&manifest, "Landscapes");
1262
1263 assert_eq!(ls.config.images.quality, 75);
1265 assert_eq!(ls.config.thumbnails.aspect_ratio, [1, 1]);
1266
1267 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 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 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 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 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 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 #[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 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 #[test]
1407 fn assets_dir_skipped_during_scan() {
1408 let tmp = TempDir::new().unwrap();
1409
1410 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 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 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 fs::write(
1434 tmp.path().join("config.toml"),
1435 r#"assets_dir = "site-assets""#,
1436 )
1437 .unwrap();
1438
1439 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 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 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 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 #[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 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 assert!(manifest.pages.iter().all(|p| p.slug != "site"));
1583 }
1584
1585 #[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 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 assert!(manifest.albums[0].preview_image.contains("005-first"));
1667 }
1668}