1use crate::types::NavItem;
76use std::path::Path;
77
78fn format_index(pos: usize) -> String {
84 format!("{:0>3}", pos)
85}
86
87fn indent(depth: usize) -> String {
89 " ".repeat(depth)
90}
91
92fn entity_header(index: usize, title: &str, count: Option<usize>) -> String {
101 match count {
102 Some(n) => format!("{} {} ({} photos)", format_index(index), title, n),
103 None => format!("{} {}", format_index(index), title),
104 }
105}
106
107fn image_line(index: usize, title: Option<&str>, filename: &str) -> String {
114 match title {
115 Some(t) if !t.is_empty() => format!("{} {}", format_index(index), t),
116 _ => format!("{} ({})", format_index(index), filename),
117 }
118}
119
120fn strip_html_tags(html: &str) -> String {
122 let mut result = String::with_capacity(html.len());
123 let mut in_tag = false;
124 for c in html.chars() {
125 match c {
126 '<' => in_tag = true,
127 '>' => in_tag = false,
128 _ if !in_tag => result.push(c),
129 _ => {}
130 }
131 }
132 result
133}
134
135fn truncate_desc(text: &str, max: usize) -> String {
137 if text.len() <= max {
138 text.to_string()
139 } else {
140 format!("{}...", &text[..max])
141 }
142}
143
144struct TreeNode {
150 depth: usize,
151 position: usize,
152 path: String,
153 is_container: bool,
154 source_dir: String,
155}
156
157fn walk_nav_tree(nav: &[NavItem]) -> Vec<TreeNode> {
160 let mut nodes = Vec::new();
161 walk_nav_tree_recursive(nav, 0, &mut nodes);
162 nodes
163}
164
165fn walk_nav_tree_recursive(items: &[NavItem], depth: usize, nodes: &mut Vec<TreeNode>) {
166 for (i, item) in items.iter().enumerate() {
167 let is_container = !item.children.is_empty();
168 nodes.push(TreeNode {
169 depth,
170 position: i + 1,
171 path: item.path.clone(),
172 is_container,
173 source_dir: item.source_dir.clone(),
174 });
175 if is_container {
176 walk_nav_tree_recursive(&item.children, depth + 1, nodes);
177 }
178 }
179}
180
181pub fn format_scan_output(manifest: &crate::scan::Manifest, source_root: &Path) -> Vec<String> {
190 let mut lines = Vec::new();
191
192 lines.push("Albums".to_string());
194
195 let tree_nodes = walk_nav_tree(&manifest.navigation);
196 let mut shown_paths = std::collections::HashSet::new();
197
198 for node in &tree_nodes {
199 let base_indent = indent(node.depth);
200
201 if node.is_container {
202 let header = entity_header(
203 node.position,
204 node.path.split('/').next_back().unwrap_or(&node.path),
205 None,
206 );
207 lines.push(format!("{}{}", base_indent, header));
208 lines.push(format!("{} Source: {}/", base_indent, node.source_dir));
209 } else if let Some(album) = manifest.albums.iter().find(|a| a.path == node.path) {
210 shown_paths.insert(&album.path);
211 let photo_count = album.images.len();
212 let header = entity_header(node.position, &album.title, Some(photo_count));
213 lines.push(format!("{}{}", base_indent, header));
214 lines.push(format!("{} Source: {}/", base_indent, node.source_dir));
215
216 if let Some(ref desc) = album.description {
218 let plain = strip_html_tags(desc);
219 let truncated = truncate_desc(plain.trim(), 60);
220 if !truncated.is_empty() {
221 lines.push(format!("{} {}", base_indent, truncated));
222 }
223 }
224
225 for (i, img) in album.images.iter().enumerate() {
227 let img_indent = format!("{} ", base_indent);
228 let img_header = image_line(i + 1, img.title.as_deref(), &img.filename);
229 lines.push(format!("{}{}", img_indent, img_header));
230
231 if img.title.is_some() {
233 lines.push(format!("{} Source: {}", img_indent, img.filename));
234 }
235
236 let sidecar_path = source_root.join(&img.source_path).with_extension("txt");
238 if sidecar_path.exists() {
239 let sidecar_name = sidecar_path.file_name().unwrap().to_string_lossy();
240 lines.push(format!("{} Description: {}", img_indent, sidecar_name));
241 }
242 }
243 }
244 }
245
246 for album in &manifest.albums {
248 if !shown_paths.contains(&album.path) {
249 let dir_name = album.path.split('/').next_back().unwrap_or(&album.path);
250 let photo_count = album.images.len();
251 lines.push(format!(" {} ({} photos)", dir_name, photo_count));
252 if let Some(ref desc) = album.description {
253 let plain = strip_html_tags(desc);
254 let truncated = truncate_desc(plain.trim(), 60);
255 if !truncated.is_empty() {
256 lines.push(format!(" {}", truncated));
257 }
258 }
259 }
260 }
261
262 if !manifest.pages.is_empty() {
264 lines.push(String::new());
265 lines.push("Pages".to_string());
266 for (i, page) in manifest.pages.iter().enumerate() {
267 let link_marker = if page.is_link { " (link)" } else { "" };
268 lines.push(format!(
269 " {} {}{}",
270 format_index(i + 1),
271 page.title,
272 link_marker
273 ));
274 lines.push(format!(" Source: {}.md", page.slug));
275 }
276 }
277
278 lines.push(String::new());
280 lines.push("Config".to_string());
281 let config_path = source_root.join("config.toml");
282 if config_path.exists() {
283 lines.push(" config.toml".to_string());
284 }
285 let assets_path = source_root.join(&manifest.config.assets_dir);
286 if assets_path.is_dir() {
287 lines.push(format!(" {}/", manifest.config.assets_dir));
288 }
289
290 lines
291}
292
293pub fn print_scan_output(manifest: &crate::scan::Manifest, source_root: &Path) {
295 for line in format_scan_output(manifest, source_root) {
296 println!("{}", line);
297 }
298}
299
300pub fn format_process_event(event: &crate::process::ProcessEvent) -> Vec<String> {
309 use crate::process::{ProcessEvent, VariantStatus};
310 match event {
311 ProcessEvent::AlbumStarted { title, image_count } => {
312 vec![format!("{} ({} photos)", title, image_count)]
313 }
314 ProcessEvent::ImageProcessed {
315 index,
316 title,
317 source_path,
318 variants,
319 } => {
320 let mut lines = Vec::new();
321 let filename = Path::new(source_path)
322 .file_name()
323 .map(|f| f.to_string_lossy().into_owned())
324 .unwrap_or_else(|| source_path.clone());
325
326 lines.push(format!(
327 " {}",
328 image_line(*index, title.as_deref(), &filename)
329 ));
330 lines.push(format!(" Source: {}", source_path));
331
332 for variant in variants {
333 let status_str = match &variant.status {
334 VariantStatus::Cached => "cached",
335 VariantStatus::Copied => "copied",
336 VariantStatus::Encoded => "encoded",
337 };
338 lines.push(format!(" {}: {}", variant.label, status_str));
339 }
340 lines
341 }
342 ProcessEvent::CachePruned { removed } => {
343 vec![format!(" Pruned {} stale cache entries", removed)]
344 }
345 }
346}
347
348pub fn format_generate_output(manifest: &crate::generate::Manifest) -> Vec<String> {
357 let mut lines = Vec::new();
358 let mut total_image_pages = 0;
359
360 lines.push("Home \u{2192} index.html".to_string());
362
363 let tree_nodes = walk_nav_tree(&manifest.navigation);
364 let mut shown_paths = std::collections::HashSet::new();
365
366 for node in &tree_nodes {
367 let base_indent = indent(node.depth);
368
369 if node.is_container {
370 let header = entity_header(
371 node.position,
372 node.path.split('/').next_back().unwrap_or(&node.path),
373 None,
374 );
375 lines.push(format!("{}{}", base_indent, header));
376 } else if let Some(album) = manifest.albums.iter().find(|a| a.path == node.path) {
377 shown_paths.insert(&album.path);
378 let header = entity_header(node.position, &album.title, None);
379 lines.push(format!(
380 "{}{} \u{2192} {}/index.html",
381 base_indent, header, album.path
382 ));
383
384 for (idx, image) in album.images.iter().enumerate() {
385 let page_url = crate::generate::image_page_url(
386 idx + 1,
387 album.images.len(),
388 image.title.as_deref(),
389 );
390 let display = match &image.title {
391 Some(t) if !t.is_empty() => format!("{} {}", format_index(idx + 1), t),
392 _ => format_index(idx + 1),
393 };
394 lines.push(format!(
395 "{} {} \u{2192} {}/{}index.html",
396 base_indent, display, album.path, page_url
397 ));
398 total_image_pages += 1;
399 }
400 }
401 }
402
403 for album in &manifest.albums {
405 if !shown_paths.contains(&album.path) {
406 lines.push(format!(
407 " {} \u{2192} {}/index.html",
408 album.title, album.path
409 ));
410
411 for (idx, image) in album.images.iter().enumerate() {
412 let page_url = crate::generate::image_page_url(
413 idx + 1,
414 album.images.len(),
415 image.title.as_deref(),
416 );
417 let display = match &image.title {
418 Some(t) if !t.is_empty() => format!("{} {}", format_index(idx + 1), t),
419 _ => format_index(idx + 1),
420 };
421 lines.push(format!(
422 " {} \u{2192} {}/{}index.html",
423 display, album.path, page_url
424 ));
425 total_image_pages += 1;
426 }
427 }
428 }
429
430 let page_count = manifest.pages.iter().filter(|p| !p.is_link).count();
432 if !manifest.pages.is_empty() {
433 lines.push(String::new());
434 lines.push("Pages".to_string());
435 for (i, page) in manifest.pages.iter().enumerate() {
436 if page.is_link {
437 lines.push(format!(
438 " {} {} \u{2192} (external link)",
439 format_index(i + 1),
440 page.title
441 ));
442 } else {
443 lines.push(format!(
444 " {} {} \u{2192} {}.html",
445 format_index(i + 1),
446 page.title,
447 page.slug
448 ));
449 }
450 }
451 }
452
453 lines.push(format!(
454 "Generated {} albums, {} image pages, {} pages",
455 manifest.albums.len(),
456 total_image_pages,
457 page_count
458 ));
459
460 lines
461}
462
463pub fn print_generate_output(manifest: &crate::generate::Manifest) {
465 for line in format_generate_output(manifest) {
466 println!("{}", line);
467 }
468}
469
470#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
483 fn strip_html_tags_removes_tags() {
484 assert_eq!(strip_html_tags("<p>Hello <b>world</b></p>"), "Hello world");
485 }
486
487 #[test]
488 fn strip_html_tags_no_tags() {
489 assert_eq!(strip_html_tags("plain text"), "plain text");
490 }
491
492 #[test]
493 fn strip_html_tags_empty() {
494 assert_eq!(strip_html_tags(""), "");
495 }
496
497 #[test]
498 fn strip_html_tags_nested() {
499 assert_eq!(
500 strip_html_tags("<div><p>Some <em>text</em></p></div>"),
501 "Some text"
502 );
503 }
504
505 #[test]
506 fn truncate_desc_short() {
507 assert_eq!(truncate_desc("Short text", 40), "Short text");
508 }
509
510 #[test]
511 fn truncate_desc_exact() {
512 let text = "a".repeat(40);
513 assert_eq!(truncate_desc(&text, 40), text);
514 }
515
516 #[test]
517 fn truncate_desc_long() {
518 let text = "a".repeat(50);
519 let expected = format!("{}...", "a".repeat(40));
520 assert_eq!(truncate_desc(&text, 40), expected);
521 }
522
523 #[test]
524 fn truncate_desc_empty() {
525 assert_eq!(truncate_desc("", 40), "");
526 }
527
528 #[test]
529 fn format_index_single_digit() {
530 assert_eq!(format_index(1), "001");
531 }
532
533 #[test]
534 fn format_index_double_digit() {
535 assert_eq!(format_index(42), "042");
536 }
537
538 #[test]
539 fn format_index_triple_digit() {
540 assert_eq!(format_index(100), "100");
541 }
542
543 #[test]
548 fn entity_header_with_count() {
549 assert_eq!(
550 entity_header(1, "Landscapes", Some(5)),
551 "001 Landscapes (5 photos)"
552 );
553 }
554
555 #[test]
556 fn entity_header_without_count() {
557 assert_eq!(entity_header(2, "Travel", None), "002 Travel");
558 }
559
560 #[test]
561 fn image_line_with_title() {
562 assert_eq!(
563 image_line(1, Some("The Sunset"), "010-The-Sunset.avif"),
564 "001 The Sunset"
565 );
566 }
567
568 #[test]
569 fn image_line_without_title() {
570 assert_eq!(image_line(1, None, "010.avif"), "001 (010.avif)");
571 }
572
573 #[test]
574 fn image_line_with_empty_title() {
575 assert_eq!(image_line(1, Some(""), "010.avif"), "001 (010.avif)");
576 }
577
578 #[test]
583 fn walk_nav_tree_empty() {
584 let nodes = walk_nav_tree(&[]);
585 assert!(nodes.is_empty());
586 }
587
588 #[test]
589 fn walk_nav_tree_flat() {
590 let nav = vec![
591 NavItem {
592 title: "A".to_string(),
593 path: "a".to_string(),
594 source_dir: "010-A".to_string(),
595 description: None,
596 children: vec![],
597 },
598 NavItem {
599 title: "B".to_string(),
600 path: "b".to_string(),
601 source_dir: "020-B".to_string(),
602 description: None,
603 children: vec![],
604 },
605 ];
606 let nodes = walk_nav_tree(&nav);
607 assert_eq!(nodes.len(), 2);
608 assert_eq!(nodes[0].position, 1);
609 assert_eq!(nodes[0].depth, 0);
610 assert_eq!(nodes[0].path, "a");
611 assert!(!nodes[0].is_container);
612 assert_eq!(nodes[1].position, 2);
613 assert_eq!(nodes[1].depth, 0);
614 }
615
616 #[test]
617 fn walk_nav_tree_nested() {
618 let nav = vec![NavItem {
619 title: "Parent".to_string(),
620 path: "parent".to_string(),
621 source_dir: "010-Parent".to_string(),
622 description: None,
623 children: vec![
624 NavItem {
625 title: "Child A".to_string(),
626 path: "parent/child-a".to_string(),
627 source_dir: "010-Child-A".to_string(),
628 description: None,
629 children: vec![],
630 },
631 NavItem {
632 title: "Child B".to_string(),
633 path: "parent/child-b".to_string(),
634 source_dir: "020-Child-B".to_string(),
635 description: None,
636 children: vec![],
637 },
638 ],
639 }];
640 let nodes = walk_nav_tree(&nav);
641 assert_eq!(nodes.len(), 3);
642 assert_eq!(nodes[0].position, 1);
644 assert_eq!(nodes[0].depth, 0);
645 assert!(nodes[0].is_container);
646 assert_eq!(nodes[1].position, 1);
648 assert_eq!(nodes[1].depth, 1);
649 assert!(!nodes[1].is_container);
650 assert_eq!(nodes[2].position, 2);
652 assert_eq!(nodes[2].depth, 1);
653 }
654
655 #[test]
656 fn indent_zero() {
657 assert_eq!(indent(0), "");
658 }
659
660 #[test]
661 fn indent_one() {
662 assert_eq!(indent(1), " ");
663 }
664
665 #[test]
666 fn indent_two() {
667 assert_eq!(indent(2), " ");
668 }
669
670 #[test]
675 fn format_process_album_started() {
676 use crate::process::ProcessEvent;
677 let event = ProcessEvent::AlbumStarted {
678 title: "Landscapes".to_string(),
679 image_count: 5,
680 };
681 let lines = format_process_event(&event);
682 assert_eq!(lines, vec!["Landscapes (5 photos)"]);
683 }
684
685 #[test]
686 fn format_process_image_with_title() {
687 use crate::process::{ProcessEvent, VariantInfo, VariantStatus};
688 let event = ProcessEvent::ImageProcessed {
689 index: 1,
690 title: Some("The Sunset".to_string()),
691 source_path: "010-Landscapes/001-sunset.jpg".to_string(),
692 variants: vec![
693 VariantInfo {
694 label: "800px".to_string(),
695 status: VariantStatus::Cached,
696 },
697 VariantInfo {
698 label: "1400px".to_string(),
699 status: VariantStatus::Encoded,
700 },
701 VariantInfo {
702 label: "thumbnail".to_string(),
703 status: VariantStatus::Copied,
704 },
705 ],
706 };
707 let lines = format_process_event(&event);
708 assert_eq!(lines[0], " 001 The Sunset");
709 assert_eq!(lines[1], " Source: 010-Landscapes/001-sunset.jpg");
710 assert_eq!(lines[2], " 800px: cached");
711 assert_eq!(lines[3], " 1400px: encoded");
712 assert_eq!(lines[4], " thumbnail: copied");
713 }
714
715 #[test]
716 fn format_process_image_without_title() {
717 use crate::process::{ProcessEvent, VariantInfo, VariantStatus};
718 let event = ProcessEvent::ImageProcessed {
719 index: 3,
720 title: None,
721 source_path: "002-NY/38.avif".to_string(),
722 variants: vec![VariantInfo {
723 label: "800px".to_string(),
724 status: VariantStatus::Cached,
725 }],
726 };
727 let lines = format_process_event(&event);
728 assert_eq!(lines[0], " 003 (38.avif)");
729 assert_eq!(lines[1], " Source: 002-NY/38.avif");
730 }
731}