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