1use std::io::Write;
9use std::path::{Path, PathBuf};
10
11use clap::{Parser, Subcommand, ValueEnum};
12use photostax_core::backends::local::LocalRepository;
13use photostax_core::photo_stack::{Metadata, PhotoStack, Rotation, RotationTarget, ScannerProfile};
14use photostax_core::scanner::ScannerConfig;
15use photostax_core::search::{paginate_stacks, PaginationParams, SearchQuery};
16use photostax_core::stack_manager::StackManager;
17
18#[derive(Parser)]
20#[command(name = "photostax-cli")]
21#[command(author, version, about, long_about = None)]
22pub struct Cli {
23 #[command(subcommand)]
24 pub command: Commands,
25}
26
27#[derive(Subcommand)]
28pub enum Commands {
29 #[command(
31 long_about = "Scan a directory for Epson FastFoto photo stacks and display them.\n\n\
32 FastFoto creates files with naming convention:\n \
33 - <name>.jpg/.tif Original front scan\n \
34 - <name>_a.jpg/.tif Enhanced (color-corrected)\n \
35 - <name>_b.jpg/.tif Back of photo\n\n\
36 These are grouped into 'stacks' for unified management."
37 )]
38 Scan {
39 directory: PathBuf,
41
42 #[arg(long, short, value_enum, default_value_t = OutputFormat::Table)]
44 format: OutputFormat,
45
46 #[arg(long)]
48 show_metadata: bool,
49
50 #[arg(long, short = 'm')]
52 metadata: bool,
53
54 #[arg(long, conflicts_with = "jpeg_only")]
56 tiff_only: bool,
57
58 #[arg(long, conflicts_with = "tiff_only")]
60 jpeg_only: bool,
61
62 #[arg(long)]
64 with_back: bool,
65
66 #[arg(long, short)]
68 recursive: bool,
69
70 #[arg(long, default_value_t = 0)]
72 limit: usize,
73
74 #[arg(long, default_value_t = 0)]
76 offset: usize,
77
78 #[arg(long, value_enum, default_value_t = CliScannerProfile::Auto)]
81 profile: CliScannerProfile,
82 },
83
84 #[command(
86 long_about = "Search photo stacks by text query and metadata filters.\n\n\
87 The text query searches across stack IDs and all metadata values.\n\
88 Additional filters can narrow results to specific EXIF or custom tags."
89 )]
90 Search {
91 directory: PathBuf,
93
94 query: String,
96
97 #[arg(long = "exif", value_parser = parse_key_value)]
99 exif_filters: Vec<(String, String)>,
100
101 #[arg(long = "tag", value_parser = parse_key_value)]
103 tag_filters: Vec<(String, String)>,
104
105 #[arg(long)]
107 has_back: bool,
108
109 #[arg(long)]
111 has_enhanced: bool,
112
113 #[arg(long = "id", value_delimiter = ',')]
115 stack_ids: Vec<String>,
116
117 #[arg(long, short, value_enum, default_value_t = OutputFormat::Table)]
119 format: OutputFormat,
120
121 #[arg(long, default_value_t = 0)]
123 limit: usize,
124
125 #[arg(long, default_value_t = 0)]
127 offset: usize,
128 },
129
130 #[command(
132 long_about = "Display comprehensive information about a single photo stack.\n\n\
133 Shows all file paths, file sizes, and complete metadata including\n\
134 EXIF tags, XMP tags, and custom tags from the XMP sidecar file."
135 )]
136 Info {
137 directory: PathBuf,
139
140 stack_id: String,
142
143 #[arg(long, short, value_enum, default_value_t = OutputFormat::Table)]
145 format: OutputFormat,
146 },
147
148 #[command(subcommand)]
150 Metadata(MetadataCommand),
151
152 #[command(long_about = "Export all photo stacks with full metadata as JSON.\n\n\
154 Output can be written to a file or stdout for piping to other tools.")]
155 Export {
156 directory: PathBuf,
158
159 #[arg(long, short)]
161 output: Option<PathBuf>,
162 },
163
164 #[command(
166 long_about = "Rotate image files in a photo stack by the given angle.\n\n\
167 Pixel data is re-encoded on disk (lossy for JPEG). Accepted degree\n\
168 values: 90 (clockwise), -90 (counter-clockwise), 180, -180.\n\n\
169 Use --target to rotate only front or back images."
170 )]
171 Rotate {
172 directory: PathBuf,
174
175 stack_id: String,
177
178 #[arg(long, short, allow_hyphen_values = true)]
180 degrees: i32,
181
182 #[arg(long, short, value_enum, default_value_t = CliRotationTarget::All)]
184 target: CliRotationTarget,
185
186 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
188 format: OutputFormat,
189 },
190}
191
192#[derive(Subcommand)]
193pub enum MetadataCommand {
194 #[command(
196 long_about = "Display all metadata for a photo stack including EXIF, XMP, and custom tags."
197 )]
198 Read {
199 directory: PathBuf,
201
202 stack_id: String,
204
205 #[arg(long, short, value_enum, default_value_t = OutputFormat::Table)]
207 format: OutputFormat,
208 },
209
210 #[command(long_about = "Add or update custom tags for a photo stack.\n\n\
212 Tags are written to an XMP sidecar file (.xmp) alongside the images\n\
213 and do not modify the original image files.")]
214 Write {
215 directory: PathBuf,
217
218 stack_id: String,
220
221 #[arg(long = "tag", required = true, value_parser = parse_key_value)]
223 tags: Vec<(String, String)>,
224 },
225
226 #[command(long_about = "Remove custom tags from a photo stack's XMP sidecar file.")]
228 Delete {
229 directory: PathBuf,
231
232 stack_id: String,
234
235 #[arg(long = "tag", required = true)]
237 tags: Vec<String>,
238 },
239}
240
241#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
242pub enum OutputFormat {
243 Table,
245 Json,
247 Csv,
249}
250
251#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
253pub enum CliRotationTarget {
254 All,
256 Front,
258 Back,
260}
261
262impl From<CliRotationTarget> for RotationTarget {
263 fn from(t: CliRotationTarget) -> Self {
264 match t {
265 CliRotationTarget::All => RotationTarget::All,
266 CliRotationTarget::Front => RotationTarget::Front,
267 CliRotationTarget::Back => RotationTarget::Back,
268 }
269 }
270}
271
272#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
274pub enum CliScannerProfile {
275 Auto,
277 EnhancedAndBack,
279 EnhancedOnly,
281 OriginalOnly,
283}
284
285impl From<CliScannerProfile> for ScannerProfile {
286 fn from(p: CliScannerProfile) -> Self {
287 match p {
288 CliScannerProfile::Auto => ScannerProfile::Auto,
289 CliScannerProfile::EnhancedAndBack => ScannerProfile::EnhancedAndBack,
290 CliScannerProfile::EnhancedOnly => ScannerProfile::EnhancedOnly,
291 CliScannerProfile::OriginalOnly => ScannerProfile::OriginalOnly,
292 }
293 }
294}
295
296pub const EXIT_SUCCESS: i32 = 0;
298pub const EXIT_ERROR: i32 = 1;
299pub const EXIT_NOT_FOUND: i32 = 2;
300
301pub fn parse_key_value(s: &str) -> Result<(String, String), String> {
303 let parts: Vec<&str> = s.splitn(2, '=').collect();
304 if parts.len() != 2 {
305 return Err(format!("Invalid format '{s}', expected KEY=VALUE"));
306 }
307 Ok((parts[0].to_string(), parts[1].to_string()))
308}
309
310async fn stack_to_json(stack: &PhotoStack) -> serde_json::Value {
312 let metadata = match stack.metadata().cached() {
313 Some(m) => serde_json::json!({
314 "exif_tags": m.exif_tags,
315 "xmp_tags": m.xmp_tags,
316 "custom_tags": m.custom_tags,
317 }),
318 None => serde_json::json!({"exif_tags": {}, "xmp_tags": {}, "custom_tags": {}}),
319 };
320 serde_json::json!({
321 "id": stack.id(),
322 "name": stack.name(),
323 "folder": stack.folder(),
324 "location": stack.location(),
325 "original": stack.original().is_present(),
326 "enhanced": stack.enhanced().is_present(),
327 "back": stack.back().is_present(),
328 "image_count": stack.image_count(),
329 "metadata": metadata,
330 })
331}
332
333async fn stacks_to_json(stacks: &[PhotoStack]) -> serde_json::Value {
335 let mut arr = Vec::with_capacity(stacks.len());
336 for s in stacks {
337 arr.push(stack_to_json(s).await);
338 }
339 serde_json::Value::Array(arr)
340}
341
342pub async fn run_cli(cli: &Cli, out: &mut dyn Write, err: &mut dyn Write) -> i32 {
345 match &cli.command {
346 Commands::Scan {
347 directory,
348 format,
349 show_metadata,
350 metadata,
351 tiff_only,
352 jpeg_only,
353 with_back,
354 recursive,
355 limit,
356 offset,
357 profile,
358 } => {
359 cmd_scan(
360 out,
361 err,
362 directory,
363 *format,
364 *show_metadata,
365 *metadata,
366 *tiff_only,
367 *jpeg_only,
368 *with_back,
369 *recursive,
370 *limit,
371 *offset,
372 (*profile).into(),
373 )
374 .await
375 }
376
377 Commands::Search {
378 directory,
379 query,
380 exif_filters,
381 tag_filters,
382 has_back,
383 has_enhanced,
384 stack_ids,
385 format,
386 limit,
387 offset,
388 } => {
389 cmd_search(
390 out,
391 err,
392 directory,
393 query,
394 exif_filters,
395 tag_filters,
396 *has_back,
397 *has_enhanced,
398 stack_ids,
399 *format,
400 *limit,
401 *offset,
402 )
403 .await
404 }
405
406 Commands::Info {
407 directory,
408 stack_id,
409 format,
410 } => cmd_info(out, err, directory, stack_id, *format).await,
411
412 Commands::Metadata(MetadataCommand::Read {
413 directory,
414 stack_id,
415 format,
416 }) => cmd_metadata_read(out, err, directory, stack_id, *format).await,
417
418 Commands::Metadata(MetadataCommand::Write {
419 directory,
420 stack_id,
421 tags,
422 }) => cmd_metadata_write(out, err, directory, stack_id, tags).await,
423
424 Commands::Metadata(MetadataCommand::Delete {
425 directory,
426 stack_id,
427 tags,
428 }) => cmd_metadata_delete(out, err, directory, stack_id, tags).await,
429
430 Commands::Export { directory, output } => {
431 cmd_export(out, err, directory, output.as_deref()).await
432 }
433
434 Commands::Rotate {
435 directory,
436 stack_id,
437 degrees,
438 target,
439 format,
440 } => {
441 cmd_rotate(
442 out,
443 err,
444 directory,
445 stack_id,
446 *degrees,
447 (*target).into(),
448 *format,
449 )
450 .await
451 }
452 }
453}
454
455#[allow(clippy::too_many_arguments)]
457pub async fn cmd_scan(
458 out: &mut dyn Write,
459 err: &mut dyn Write,
460 directory: &PathBuf,
461 format: OutputFormat,
462 show_metadata: bool,
463 metadata: bool,
464 _tiff_only: bool,
465 _jpeg_only: bool,
466 with_back: bool,
467 recursive: bool,
468 limit: usize,
469 offset: usize,
470 profile: ScannerProfile,
471) -> i32 {
472 let config = ScannerConfig {
473 recursive,
474 ..ScannerConfig::default()
475 };
476 let repo = LocalRepository::with_config(directory, config);
477 let mut mgr = match StackManager::single(Box::new(repo), profile) {
478 Ok(m) => m,
479 Err(e) => {
480 let _ = writeln!(err, "Error: {e}");
481 return EXIT_ERROR;
482 }
483 };
484
485 let load_metadata = metadata || show_metadata;
487
488 let mut progress_cb = |p: &photostax_core::photo_stack::ScanProgress| {
489 let phase = match p.phase {
490 photostax_core::photo_stack::ScanPhase::Scanning => "Scanning",
491 photostax_core::photo_stack::ScanPhase::Classifying => "Classifying",
492 photostax_core::photo_stack::ScanPhase::Complete => "Complete",
493 };
494 let _ = write!(std::io::stderr(), "\r{phase}: {}/{}", p.current, p.total);
495 if p.phase == photostax_core::photo_stack::ScanPhase::Complete {
496 let _ = writeln!(std::io::stderr());
497 }
498 };
499
500 if load_metadata {
501 let result = match mgr.query(None, None, None, None) {
502 Ok(r) => r,
503 Err(e) => {
504 let _ = writeln!(err, "Error scanning {}: {e}", directory.display());
505 return EXIT_ERROR;
506 }
507 };
508 for stack in result.all_stacks() {
509 let _ = stack.metadata().read();
510 }
511 } else if let Err(e) = mgr.query(None, None, Some(&mut progress_cb), None) {
512 let _ = writeln!(err, "Error scanning {}: {e}", directory.display());
513 return EXIT_ERROR;
514 }
515 let stacks: Vec<PhotoStack> = mgr
516 .query(None, None, None, None)
517 .expect("cache already populated")
518 .all_stacks()
519 .to_vec();
520 let filtered: Vec<_> = stacks
521 .into_iter()
522 .filter(|s| {
523 if with_back && !s.back().is_present() {
524 return false;
525 }
526 true
527 })
528 .collect();
529
530 if limit > 0 {
532 let paginated = paginate_stacks(&filtered, &PaginationParams { offset, limit });
533 output_stacks(out, &paginated.items, format, show_metadata, directory).await;
534 if format == OutputFormat::Json {
535 let _ = writeln!(
536 out,
537 "{{\"pagination\": {{\"total_count\": {}, \"offset\": {}, \"limit\": {}, \"has_more\": {}}}}}",
538 paginated.total_count, paginated.offset, paginated.limit, paginated.has_more
539 );
540 } else {
541 let _ = writeln!(
542 out,
543 "\nShowing {}-{} of {} stacks{}",
544 offset + 1,
545 (offset + paginated.items.len()).min(paginated.total_count),
546 paginated.total_count,
547 if paginated.has_more {
548 " (more available)"
549 } else {
550 ""
551 }
552 );
553 }
554 } else {
555 output_stacks(out, &filtered, format, show_metadata, directory).await;
556 }
557 EXIT_SUCCESS
558}
559
560#[allow(clippy::too_many_arguments)]
562pub async fn cmd_search(
563 out: &mut dyn Write,
564 err: &mut dyn Write,
565 directory: &PathBuf,
566 query: &str,
567 exif_filters: &[(String, String)],
568 tag_filters: &[(String, String)],
569 has_back: bool,
570 has_enhanced: bool,
571 stack_ids: &[String],
572 format: OutputFormat,
573 limit: usize,
574 offset: usize,
575) -> i32 {
576 let repo = LocalRepository::new(directory);
577 let mut mgr = match StackManager::single(Box::new(repo), ScannerProfile::Auto) {
578 Ok(m) => m,
579 Err(e) => {
580 let _ = writeln!(err, "Error: {e}");
581 return EXIT_ERROR;
582 }
583 };
584 let result = match mgr.query(None, None, None, None) {
585 Ok(r) => r,
586 Err(e) => {
587 let _ = writeln!(err, "Error scanning {}: {e}", directory.display());
588 return EXIT_ERROR;
589 }
590 };
591 for stack in result.all_stacks() {
592 let _ = stack.metadata().read();
593 }
594
595 let mut search = SearchQuery::new().with_text(query);
597
598 for (key, value) in exif_filters {
599 search = search.with_exif_filter(key, value);
600 }
601 for (key, value) in tag_filters {
602 search = search.with_custom_filter(key, value);
603 }
604 if has_back {
605 search = search.with_has_back(true);
606 }
607 if has_enhanced {
608 search = search.with_has_enhanced(true);
609 }
610 if !stack_ids.is_empty() {
611 search = search.with_ids(stack_ids.to_vec());
612 }
613
614 if limit > 0 {
616 let snapshot = match mgr.query(Some(&search), None, None, None) {
617 Ok(snap) => snap,
618 Err(e) => {
619 let _ = writeln!(err, "Error querying: {e}");
620 return EXIT_ERROR;
621 }
622 };
623 let paginated = snapshot.snapshot().get_page(offset, limit);
624 output_stacks(out, &paginated.items, format, false, directory).await;
625 if format == OutputFormat::Json {
626 let _ = writeln!(
627 out,
628 "{{\"pagination\": {{\"total_count\": {}, \"offset\": {}, \"limit\": {}, \"has_more\": {}}}}}",
629 paginated.total_count, paginated.offset, paginated.limit, paginated.has_more
630 );
631 } else {
632 let _ = writeln!(
633 out,
634 "\nShowing {}-{} of {} results{}",
635 offset + 1,
636 (offset + paginated.items.len()).min(paginated.total_count),
637 paginated.total_count,
638 if paginated.has_more {
639 " (more available)"
640 } else {
641 ""
642 }
643 );
644 }
645 } else {
646 let results = match mgr.query(Some(&search), None, None, None) {
647 Ok(snap) => snap,
648 Err(e) => {
649 let _ = writeln!(err, "Error querying: {e}");
650 return EXIT_ERROR;
651 }
652 };
653 output_stacks(out, results.all_stacks(), format, false, directory).await;
654 }
655 EXIT_SUCCESS
656}
657
658async fn resolve_stack(
662 mgr: &mut StackManager,
663 id_or_name: &str,
664) -> Result<PhotoStack, photostax_core::repository::RepositoryError> {
665 if mgr.is_empty() {
666 mgr.query(None, None, None, None)
667 .map_err(|e| photostax_core::repository::RepositoryError::Other(e.to_string()))?;
668 }
669 let id_query = SearchQuery::new().with_ids(vec![id_or_name.to_string()]);
671 if let Ok(result) = mgr.query(Some(&id_query), None, None, None) {
672 if let Some(stack) = result.all_stacks().first() {
673 return Ok(stack.clone());
674 }
675 }
676 let text_query = SearchQuery::new().with_text(id_or_name.to_string());
678 if let Ok(result) = mgr.query(Some(&text_query), None, None, None) {
679 if let Some(stack) = result.all_stacks().first() {
680 return Ok(stack.clone());
681 }
682 }
683 let all = mgr
685 .query(None, None, None, None)
686 .map_err(|e| photostax_core::repository::RepositoryError::Other(e.to_string()))?;
687 for stack in all.all_stacks() {
688 if stack.name() == id_or_name {
689 return Ok(stack.clone());
690 }
691 }
692 Err(photostax_core::repository::RepositoryError::NotFound(
693 id_or_name.to_string(),
694 ))
695}
696
697pub async fn cmd_info(
699 out: &mut dyn Write,
700 err: &mut dyn Write,
701 directory: &PathBuf,
702 stack_id: &str,
703 format: OutputFormat,
704) -> i32 {
705 let repo = LocalRepository::new(directory);
706 let mut mgr = match StackManager::single(Box::new(repo), ScannerProfile::Auto) {
707 Ok(m) => m,
708 Err(e) => {
709 let _ = writeln!(err, "Error: {e}");
710 return EXIT_ERROR;
711 }
712 };
713 let stack = match resolve_stack(&mut mgr, stack_id).await {
714 Ok(s) => s,
715 Err(photostax_core::repository::RepositoryError::NotFound(_)) => {
716 let _ = writeln!(err, "Stack not found: {stack_id}");
717 return EXIT_NOT_FOUND;
718 }
719 Err(e) => {
720 let _ = writeln!(err, "Error: {e}");
721 return EXIT_ERROR;
722 }
723 };
724
725 if let Err(e) = stack.metadata().read() {
726 let _ = writeln!(err, "Error loading metadata: {e}");
727 return EXIT_ERROR;
728 }
729
730 match format {
731 OutputFormat::Json => {
732 let _ = writeln!(
733 out,
734 "{}",
735 serde_json::to_string_pretty(&stack_to_json(&stack).await).unwrap()
736 );
737 }
738 OutputFormat::Csv => {
739 output_info_csv(out, &stack).await;
740 }
741 OutputFormat::Table => {
742 output_info_table(out, &stack).await;
743 }
744 }
745
746 EXIT_SUCCESS
747}
748
749pub async fn cmd_metadata_read(
751 out: &mut dyn Write,
752 err: &mut dyn Write,
753 directory: &PathBuf,
754 stack_id: &str,
755 format: OutputFormat,
756) -> i32 {
757 let repo = LocalRepository::new(directory);
758 let mut mgr = match StackManager::single(Box::new(repo), ScannerProfile::Auto) {
759 Ok(m) => m,
760 Err(e) => {
761 let _ = writeln!(err, "Error: {e}");
762 return EXIT_ERROR;
763 }
764 };
765 let stack = match resolve_stack(&mut mgr, stack_id).await {
766 Ok(s) => s,
767 Err(photostax_core::repository::RepositoryError::NotFound(_)) => {
768 let _ = writeln!(err, "Stack not found: {stack_id}");
769 return EXIT_NOT_FOUND;
770 }
771 Err(e) => {
772 let _ = writeln!(err, "Error: {e}");
773 return EXIT_ERROR;
774 }
775 };
776
777 let metadata = match stack.metadata().read() {
778 Ok(m) => m,
779 Err(e) => {
780 let _ = writeln!(err, "Error loading metadata: {e}");
781 return EXIT_ERROR;
782 }
783 };
784
785 match format {
786 OutputFormat::Json => {
787 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&metadata).unwrap());
788 }
789 OutputFormat::Csv => {
790 output_metadata_csv(out, &metadata);
791 }
792 OutputFormat::Table => {
793 output_metadata_table(out, &metadata);
794 }
795 }
796
797 EXIT_SUCCESS
798}
799
800pub async fn cmd_metadata_write(
802 out: &mut dyn Write,
803 err: &mut dyn Write,
804 directory: &PathBuf,
805 stack_id: &str,
806 tags: &[(String, String)],
807) -> i32 {
808 let repo = LocalRepository::new(directory);
809 let mut mgr = match StackManager::single(Box::new(repo), ScannerProfile::Auto) {
810 Ok(m) => m,
811 Err(e) => {
812 let _ = writeln!(err, "Error: {e}");
813 return EXIT_ERROR;
814 }
815 };
816 let stack = match resolve_stack(&mut mgr, stack_id).await {
817 Ok(s) => s,
818 Err(photostax_core::repository::RepositoryError::NotFound(_)) => {
819 let _ = writeln!(err, "Stack not found: {stack_id}");
820 return EXIT_NOT_FOUND;
821 }
822 Err(e) => {
823 let _ = writeln!(err, "Error: {e}");
824 return EXIT_ERROR;
825 }
826 };
827
828 let mut new_tags = Metadata::default();
829 for (key, value) in tags {
830 new_tags
831 .custom_tags
832 .insert(key.clone(), serde_json::Value::String(value.clone()));
833 }
834
835 if let Err(e) = stack.metadata().write(&new_tags) {
837 let _ = writeln!(err, "Error writing metadata: {e}");
838 return EXIT_ERROR;
839 }
840
841 let _ = writeln!(out, "Wrote {} tag(s) to {stack_id}", tags.len());
842 EXIT_SUCCESS
843}
844
845pub async fn cmd_metadata_delete(
847 out: &mut dyn Write,
848 err: &mut dyn Write,
849 directory: &PathBuf,
850 stack_id: &str,
851 tags: &[String],
852) -> i32 {
853 let repo = LocalRepository::new(directory);
854 let mut mgr = match StackManager::single(Box::new(repo), ScannerProfile::Auto) {
855 Ok(m) => m,
856 Err(e) => {
857 let _ = writeln!(err, "Error: {e}");
858 return EXIT_ERROR;
859 }
860 };
861
862 if let Err(photostax_core::repository::RepositoryError::NotFound(_)) =
864 resolve_stack(&mut mgr, stack_id).await
865 {
866 let _ = writeln!(err, "Stack not found: {stack_id}");
867 return EXIT_NOT_FOUND;
868 }
869
870 for tag in tags {
872 if let Err(e) =
873 photostax_core::metadata::sidecar::remove_custom_tag(directory, stack_id, tag)
874 {
875 let _ = writeln!(err, "Error deleting tag '{tag}': {e}");
876 return EXIT_ERROR;
877 }
878 }
879
880 let _ = writeln!(out, "Deleted {} tag(s) from {stack_id}", tags.len());
881 EXIT_SUCCESS
882}
883
884pub async fn cmd_export(
886 out: &mut dyn Write,
887 err: &mut dyn Write,
888 directory: &PathBuf,
889 output: Option<&Path>,
890) -> i32 {
891 let repo = LocalRepository::new(directory);
892 let mut mgr = match StackManager::single(Box::new(repo), ScannerProfile::Auto) {
893 Ok(m) => m,
894 Err(e) => {
895 let _ = writeln!(err, "Error: {e}");
896 return EXIT_ERROR;
897 }
898 };
899 let result = match mgr.query(None, None, None, None) {
900 Ok(r) => r,
901 Err(e) => {
902 let _ = writeln!(err, "Error scanning {}: {e}", directory.display());
903 return EXIT_ERROR;
904 }
905 };
906 for stack in result.all_stacks() {
907 let _ = stack.metadata().read();
908 }
909 let stacks: Vec<PhotoStack> = mgr
910 .query(None, None, None, None)
911 .expect("cache already populated")
912 .all_stacks()
913 .to_vec();
914
915 let json = serde_json::to_string_pretty(&stacks_to_json(&stacks).await).unwrap();
916
917 match output {
918 Some(path) => {
919 if let Err(e) = std::fs::write(path, &json) {
920 let _ = writeln!(err, "Error writing to {}: {e}", path.display());
921 return EXIT_ERROR;
922 }
923 let _ = writeln!(
924 out,
925 "Exported {} stack(s) to {}",
926 stacks.len(),
927 path.display()
928 );
929 }
930 None => {
931 let _ = writeln!(out, "{json}");
932 }
933 }
934
935 EXIT_SUCCESS
936}
937
938pub async fn cmd_rotate(
940 out: &mut dyn Write,
941 err: &mut dyn Write,
942 directory: &PathBuf,
943 stack_id: &str,
944 degrees: i32,
945 target: RotationTarget,
946 format: OutputFormat,
947) -> i32 {
948 let rotation = match Rotation::from_degrees(degrees) {
949 Some(r) => r,
950 None => {
951 let _ = writeln!(
952 err,
953 "Invalid rotation: {degrees}°. Accepted values: 90, -90, 180, -180"
954 );
955 return EXIT_ERROR;
956 }
957 };
958
959 let repo = LocalRepository::new(directory);
960 let mut mgr = match StackManager::single(Box::new(repo), ScannerProfile::Auto) {
961 Ok(m) => m,
962 Err(e) => {
963 let _ = writeln!(err, "Error: {e}");
964 return EXIT_ERROR;
965 }
966 };
967
968 let stack = match resolve_stack(&mut mgr, stack_id).await {
970 Ok(s) => s,
971 Err(photostax_core::repository::RepositoryError::NotFound(_)) => {
972 let _ = writeln!(err, "Stack not found: {stack_id}");
973 return EXIT_NOT_FOUND;
974 }
975 Err(e) => {
976 let _ = writeln!(err, "Error: {e}");
977 return EXIT_ERROR;
978 }
979 };
980
981 let rotate_front = matches!(target, RotationTarget::All | RotationTarget::Front);
983 let rotate_back = matches!(target, RotationTarget::All | RotationTarget::Back);
984
985 if rotate_front {
986 if stack.original().is_present() {
987 if let Err(e) = stack.original().rotate(rotation) {
988 let _ = writeln!(err, "Error rotating original: {e}");
989 return EXIT_ERROR;
990 }
991 }
992 if stack.enhanced().is_present() {
993 if let Err(e) = stack.enhanced().rotate(rotation) {
994 let _ = writeln!(err, "Error rotating enhanced: {e}");
995 return EXIT_ERROR;
996 }
997 }
998 }
999 if rotate_back && stack.back().is_present() {
1000 if let Err(e) = stack.back().rotate(rotation) {
1001 let _ = writeln!(err, "Error rotating back: {e}");
1002 return EXIT_ERROR;
1003 }
1004 }
1005
1006 let _ = writeln!(
1007 out,
1008 "Rotated {} image(s) in stack '{}' by {}°",
1009 stack.image_count(),
1010 stack.name(),
1011 rotation.as_degrees()
1012 );
1013
1014 if format == OutputFormat::Json {
1015 let _ = writeln!(
1016 out,
1017 "{}",
1018 serde_json::to_string_pretty(&stack_to_json(&stack).await).unwrap()
1019 );
1020 }
1021
1022 EXIT_SUCCESS
1023}
1024
1025pub async fn output_stacks(
1031 out: &mut dyn Write,
1032 stacks: &[PhotoStack],
1033 format: OutputFormat,
1034 show_metadata: bool,
1035 dir: &Path,
1036) {
1037 match format {
1038 OutputFormat::Json => {
1039 let _ = writeln!(
1040 out,
1041 "{}",
1042 serde_json::to_string_pretty(&stacks_to_json(stacks).await).unwrap()
1043 );
1044 }
1045 OutputFormat::Csv => {
1046 output_stacks_csv(out, stacks, show_metadata).await;
1047 }
1048 OutputFormat::Table => {
1049 output_stacks_table(out, stacks, show_metadata, dir).await;
1050 }
1051 }
1052}
1053
1054pub async fn output_stacks_table(
1056 out: &mut dyn Write,
1057 stacks: &[PhotoStack],
1058 show_metadata: bool,
1059 dir: &Path,
1060) {
1061 let _ = writeln!(
1062 out,
1063 "Found {} photo stack(s) in {}",
1064 stacks.len(),
1065 dir.display()
1066 );
1067 let _ = writeln!(out);
1068
1069 if stacks.is_empty() {
1070 return;
1071 }
1072
1073 let max_id = stacks
1075 .iter()
1076 .map(|s| s.name().len())
1077 .max()
1078 .unwrap_or(10)
1079 .max(10);
1080
1081 let _ = writeln!(
1083 out,
1084 "┌─{}─┬─────────┬──────────┬──────┬──────┬────────┐",
1085 "─".repeat(max_id)
1086 );
1087 let _ = writeln!(
1088 out,
1089 "│ {:<max_id$} │ Format │ Original │ Enh. │ Back │ Tags │",
1090 "ID"
1091 );
1092 let _ = writeln!(
1093 out,
1094 "├─{}─┼─────────┼──────────┼──────┼──────┼────────┤",
1095 "─".repeat(max_id)
1096 );
1097
1098 for stack in stacks {
1099 let orig = if stack.original().is_present() {
1100 "✓"
1101 } else {
1102 "-"
1103 };
1104 let enh = if stack.enhanced().is_present() {
1105 "✓"
1106 } else {
1107 "-"
1108 };
1109 let back = if stack.back().is_present() {
1110 "✓"
1111 } else {
1112 "-"
1113 };
1114 let tags = stack
1115 .metadata()
1116 .cached()
1117 .map_or(0, |m| m.exif_tags.len() + m.custom_tags.len());
1118
1119 let _ = writeln!(
1120 out,
1121 "│ {:<max_id$} │ {:<7} │ {:<5} │ {:<3} │ {:<3} │ {:>6} │",
1122 stack.name(),
1123 "-",
1124 orig,
1125 enh,
1126 back,
1127 tags
1128 );
1129
1130 if show_metadata {
1131 if stack.original().is_present() {
1132 let _ = writeln!(out, "│ {:<max_id$} │ │ (original)", "");
1133 }
1134 if stack.enhanced().is_present() {
1135 let _ = writeln!(out, "│ {:<max_id$} │ │ (enhanced)", "");
1136 }
1137 if stack.back().is_present() {
1138 let _ = writeln!(out, "│ {:<max_id$} │ │ (back)", "");
1139 }
1140 }
1141 }
1142
1143 let _ = writeln!(
1144 out,
1145 "└─{}─┴─────────┴──────────┴──────┴──────┴────────┘",
1146 "─".repeat(max_id)
1147 );
1148}
1149
1150pub async fn output_stacks_csv(out: &mut dyn Write, stacks: &[PhotoStack], show_metadata: bool) {
1152 if show_metadata {
1153 let _ = writeln!(
1154 out,
1155 "id,format,original,enhanced,back,exif_tags,custom_tags"
1156 );
1157 } else {
1158 let _ = writeln!(
1159 out,
1160 "id,format,has_original,has_enhanced,has_back,tag_count"
1161 );
1162 }
1163
1164 for stack in stacks {
1165 if show_metadata {
1166 let _ = writeln!(
1167 out,
1168 "{},-,{},{},{},{},{}",
1169 stack.name(),
1170 if stack.original().is_present() {
1171 "present"
1172 } else {
1173 ""
1174 },
1175 if stack.enhanced().is_present() {
1176 "present"
1177 } else {
1178 ""
1179 },
1180 if stack.back().is_present() {
1181 "present"
1182 } else {
1183 ""
1184 },
1185 stack.metadata().cached().map_or(0, |m| m.exif_tags.len()),
1186 stack.metadata().cached().map_or(0, |m| m.custom_tags.len())
1187 );
1188 } else {
1189 let tags = stack
1190 .metadata()
1191 .cached()
1192 .map_or(0, |m| m.exif_tags.len() + m.custom_tags.len());
1193 let _ = writeln!(
1194 out,
1195 "{},-,{},{},{},{}",
1196 stack.name(),
1197 stack.original().is_present(),
1198 stack.enhanced().is_present(),
1199 stack.back().is_present(),
1200 tags
1201 );
1202 }
1203 }
1204}
1205
1206pub async fn output_info_table(out: &mut dyn Write, stack: &PhotoStack) {
1208 let _ = writeln!(
1209 out,
1210 "┌──────────────────────────────────────────────────────────────────┐"
1211 );
1212 let _ = writeln!(out, "│ Stack: {:<57} │", stack.name());
1213 let _ = writeln!(
1214 out,
1215 "├──────────────────────────────────────────────────────────────────┤"
1216 );
1217
1218 let _ = writeln!(
1220 out,
1221 "├──────────────────────────────────────────────────────────────────┤"
1222 );
1223 let _ = writeln!(
1224 out,
1225 "│ Files: │"
1226 );
1227 if stack.original().is_present() {
1228 let size = stack.original().size().unwrap_or(0);
1229 let _ = writeln!(
1230 out,
1231 "│ Original: {:<40} ({:>8}) │",
1232 "(present)",
1233 format_size(size)
1234 );
1235 }
1236 if stack.enhanced().is_present() {
1237 let size = stack.enhanced().size().unwrap_or(0);
1238 let _ = writeln!(
1239 out,
1240 "│ Enhanced: {:<40} ({:>8}) │",
1241 "(present)",
1242 format_size(size)
1243 );
1244 }
1245 if stack.back().is_present() {
1246 let size = stack.back().size().unwrap_or(0);
1247 let _ = writeln!(
1248 out,
1249 "│ Back: {:<40} ({:>8}) │",
1250 "(present)",
1251 format_size(size)
1252 );
1253 }
1254
1255 if let Some(m) = stack.metadata().cached() {
1256 if !m.exif_tags.is_empty() {
1258 let _ = writeln!(
1259 out,
1260 "├──────────────────────────────────────────────────────────────────┤"
1261 );
1262 let _ = writeln!(out, "│ EXIF Tags ({}):", m.exif_tags.len());
1263 let width = 62;
1264 for (key, value) in &m.exif_tags {
1265 let kv = format!("{}: {}", key, value);
1266 let truncated = if kv.len() > width {
1267 format!("{}...", &kv[..width - 3])
1268 } else {
1269 kv
1270 };
1271 let _ = writeln!(out, "│ {:<width$} │", truncated);
1272 }
1273 }
1274
1275 if !m.xmp_tags.is_empty() {
1277 let _ = writeln!(
1278 out,
1279 "├──────────────────────────────────────────────────────────────────┤"
1280 );
1281 let _ = writeln!(out, "│ XMP Tags ({}):", m.xmp_tags.len());
1282 let width = 62;
1283 for (key, value) in &m.xmp_tags {
1284 let kv = format!("{}: {}", key, value);
1285 let truncated = if kv.len() > width {
1286 format!("{}...", &kv[..width - 3])
1287 } else {
1288 kv
1289 };
1290 let _ = writeln!(out, "│ {:<width$} │", truncated);
1291 }
1292 }
1293
1294 if !m.custom_tags.is_empty() {
1296 let _ = writeln!(
1297 out,
1298 "├──────────────────────────────────────────────────────────────────┤"
1299 );
1300 let _ = writeln!(out, "│ Custom Tags ({}):", m.custom_tags.len());
1301 let width = 62;
1302 for (key, value) in &m.custom_tags {
1303 let kv = format!("{}: {}", key, value);
1304 let truncated = if kv.len() > width {
1305 format!("{}...", &kv[..width - 3])
1306 } else {
1307 kv
1308 };
1309 let _ = writeln!(out, "│ {:<width$} │", truncated);
1310 }
1311 }
1312 }
1313
1314 let _ = writeln!(
1315 out,
1316 "└──────────────────────────────────────────────────────────────────┘"
1317 );
1318}
1319
1320pub async fn output_info_csv(out: &mut dyn Write, stack: &PhotoStack) {
1322 let _ = writeln!(out, "type,key,value");
1323 let _ = writeln!(out, "id,,{}", stack.name());
1324
1325 if stack.original().is_present() {
1326 let _ = writeln!(out, "file,original,present");
1327 }
1328 if stack.enhanced().is_present() {
1329 let _ = writeln!(out, "file,enhanced,present");
1330 }
1331 if stack.back().is_present() {
1332 let _ = writeln!(out, "file,back,present");
1333 }
1334
1335 if let Some(m) = stack.metadata().cached() {
1336 for (key, value) in &m.exif_tags {
1337 let _ = writeln!(out, "exif,{},{}", key, escape_csv(value));
1338 }
1339 for (key, value) in &m.xmp_tags {
1340 let _ = writeln!(out, "xmp,{},{}", key, escape_csv(value));
1341 }
1342 for (key, value) in &m.custom_tags {
1343 let _ = writeln!(out, "custom,{},{}", key, escape_csv(&value.to_string()));
1344 }
1345 }
1346}
1347
1348pub fn output_metadata_table(out: &mut dyn Write, metadata: &Metadata) {
1350 let _ = writeln!(
1351 out,
1352 "┌──────────────────────────────────────────────────────────────────┐"
1353 );
1354 let _ = writeln!(
1355 out,
1356 "│ Metadata │"
1357 );
1358 let _ = writeln!(
1359 out,
1360 "├──────────────────────────────────────────────────────────────────┤"
1361 );
1362
1363 let width = 62;
1364
1365 if !metadata.exif_tags.is_empty() {
1366 let _ = writeln!(out, "│ EXIF Tags ({}):", metadata.exif_tags.len());
1367 for (key, value) in &metadata.exif_tags {
1368 let kv = format!("{}: {}", key, value);
1369 let truncated = if kv.len() > width {
1370 format!("{}...", &kv[..width - 3])
1371 } else {
1372 kv
1373 };
1374 let _ = writeln!(out, "│ {:<width$} │", truncated);
1375 }
1376 } else {
1377 let _ = writeln!(
1378 out,
1379 "│ EXIF Tags: (none) │"
1380 );
1381 }
1382
1383 let _ = writeln!(
1384 out,
1385 "├──────────────────────────────────────────────────────────────────┤"
1386 );
1387
1388 if !metadata.xmp_tags.is_empty() {
1389 let _ = writeln!(out, "│ XMP Tags ({}):", metadata.xmp_tags.len());
1390 for (key, value) in &metadata.xmp_tags {
1391 let kv = format!("{}: {}", key, value);
1392 let truncated = if kv.len() > width {
1393 format!("{}...", &kv[..width - 3])
1394 } else {
1395 kv
1396 };
1397 let _ = writeln!(out, "│ {:<width$} │", truncated);
1398 }
1399 } else {
1400 let _ = writeln!(
1401 out,
1402 "│ XMP Tags: (none) │"
1403 );
1404 }
1405
1406 let _ = writeln!(
1407 out,
1408 "├──────────────────────────────────────────────────────────────────┤"
1409 );
1410
1411 if !metadata.custom_tags.is_empty() {
1412 let _ = writeln!(out, "│ Custom Tags ({}):", metadata.custom_tags.len());
1413 for (key, value) in &metadata.custom_tags {
1414 let kv = format!("{}: {}", key, value);
1415 let truncated = if kv.len() > width {
1416 format!("{}...", &kv[..width - 3])
1417 } else {
1418 kv
1419 };
1420 let _ = writeln!(out, "│ {:<width$} │", truncated);
1421 }
1422 } else {
1423 let _ = writeln!(
1424 out,
1425 "│ Custom Tags: (none) │"
1426 );
1427 }
1428
1429 let _ = writeln!(
1430 out,
1431 "└──────────────────────────────────────────────────────────────────┘"
1432 );
1433}
1434
1435pub fn output_metadata_csv(out: &mut dyn Write, metadata: &Metadata) {
1437 let _ = writeln!(out, "type,key,value");
1438
1439 for (key, value) in &metadata.exif_tags {
1440 let _ = writeln!(out, "exif,{},{}", key, escape_csv(value));
1441 }
1442 for (key, value) in &metadata.xmp_tags {
1443 let _ = writeln!(out, "xmp,{},{}", key, escape_csv(value));
1444 }
1445 for (key, value) in &metadata.custom_tags {
1446 let _ = writeln!(out, "custom,{},{}", key, escape_csv(&value.to_string()));
1447 }
1448}
1449
1450pub fn format_size(bytes: u64) -> String {
1452 const KB: u64 = 1024;
1453 const MB: u64 = KB * 1024;
1454 const GB: u64 = MB * 1024;
1455
1456 if bytes >= GB {
1457 format!("{:.1} GB", bytes as f64 / GB as f64)
1458 } else if bytes >= MB {
1459 format!("{:.1} MB", bytes as f64 / MB as f64)
1460 } else if bytes >= KB {
1461 format!("{:.1} KB", bytes as f64 / KB as f64)
1462 } else {
1463 format!("{} B", bytes)
1464 }
1465}
1466
1467pub fn escape_csv(s: &str) -> String {
1469 if s.contains(',') || s.contains('"') || s.contains('\n') {
1470 format!("\"{}\"", s.replace('"', "\"\""))
1471 } else {
1472 s.to_string()
1473 }
1474}
1475
1476#[cfg(test)]
1477mod tests {
1478 use super::*;
1479 use photostax_core::backends::local_handles::LocalImageHandle;
1480 use photostax_core::image_handle::ImageRef;
1481 use photostax_core::metadata_handle::{MetadataHandle, MetadataRef};
1482 use std::collections::HashMap;
1483 use std::path::PathBuf;
1484 use std::sync::Arc;
1485
1486 struct InlineMetadataHandle {
1488 data: Metadata,
1489 }
1490 impl MetadataHandle for InlineMetadataHandle {
1491 fn load(&self) -> Result<Metadata, photostax_core::repository::RepositoryError> {
1492 Ok(self.data.clone())
1493 }
1494 fn write(&self, _: &Metadata) -> Result<(), photostax_core::repository::RepositoryError> {
1495 Ok(())
1496 }
1497 fn is_valid(&self) -> bool {
1498 true
1499 }
1500 }
1501
1502 fn make_image_ref(path: &str) -> ImageRef {
1503 ImageRef::new(Arc::new(LocalImageHandle::new(path, 0)))
1504 }
1505
1506 fn make_metadata_ref(metadata: Metadata) -> MetadataRef {
1507 let handle = Arc::new(InlineMetadataHandle { data: metadata });
1508 let mut mr = MetadataRef::new(handle);
1509 let _ = mr.read(); mr
1511 }
1512
1513 fn testdata_path() -> PathBuf {
1514 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1515 .parent()
1516 .unwrap()
1517 .join("core")
1518 .join("tests")
1519 .join("testdata")
1520 }
1521
1522 fn stable_tempdir() -> tempfile::TempDir {
1525 let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1526 .parent()
1527 .unwrap()
1528 .join("target")
1529 .join("test-tmp");
1530 std::fs::create_dir_all(&base).unwrap();
1531 tempfile::Builder::new()
1532 .prefix("photostax-")
1533 .tempdir_in(&base)
1534 .unwrap()
1535 }
1536
1537 fn copy_testdata_to_tempdir() -> tempfile::TempDir {
1539 let dir = stable_tempdir();
1540 for entry in std::fs::read_dir(testdata_path()).unwrap() {
1541 let entry = entry.unwrap();
1542 if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
1543 match std::fs::copy(entry.path(), dir.path().join(entry.file_name())) {
1546 Ok(_) => {}
1547 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1548 Err(e) => panic!("failed to copy testdata file: {e}"),
1549 }
1550 }
1551 }
1552 dir
1553 }
1554
1555 fn make_stack(id: &str) -> PhotoStack {
1556 let stack = PhotoStack::new(id);
1557 stack.set_original(make_image_ref(&format!("/photos/{id}.jpg")));
1558 stack.set_enhanced(make_image_ref(&format!("/photos/{id}_a.jpg")));
1559 stack.set_back(make_image_ref(&format!("/photos/{id}_b.jpg")));
1560 stack
1561 }
1562
1563 async fn make_stack_with_metadata(id: &str) -> PhotoStack {
1564 let mut exif_tags = HashMap::new();
1565 exif_tags.insert("Make".to_string(), "EPSON".to_string());
1566 exif_tags.insert("Model".to_string(), "FastFoto FF-680W".to_string());
1567
1568 let mut xmp_tags = HashMap::new();
1569 xmp_tags.insert("Creator".to_string(), "Test User".to_string());
1570
1571 let mut custom_tags = HashMap::new();
1572 custom_tags.insert(
1573 "album".to_string(),
1574 serde_json::Value::String("Family".to_string()),
1575 );
1576
1577 let stack = PhotoStack::new(id);
1578 stack.set_original(make_image_ref(&format!("/photos/{id}.jpg")));
1579 stack.set_enhanced(make_image_ref(&format!("/photos/{id}_a.jpg")));
1580 stack.set_metadata(make_metadata_ref(Metadata {
1581 exif_tags,
1582 xmp_tags,
1583 custom_tags,
1584 }));
1585 stack
1586 }
1587
1588 fn make_tiff_stack(id: &str) -> PhotoStack {
1589 let stack = PhotoStack::new(id);
1590 stack.set_original(make_image_ref(&format!("/photos/{id}.tif")));
1591 stack
1592 }
1593
1594 fn make_empty_stack(id: &str) -> PhotoStack {
1595 PhotoStack::new(id)
1596 }
1597
1598 #[tokio::test]
1601 async fn test_parse_key_value_valid() {
1602 let (k, v) = parse_key_value("Make=EPSON").unwrap();
1603 assert_eq!(k, "Make");
1604 assert_eq!(v, "EPSON");
1605 }
1606
1607 #[tokio::test]
1608 async fn test_parse_key_value_with_equals_in_value() {
1609 let (k, v) = parse_key_value("expr=a=b").unwrap();
1610 assert_eq!(k, "expr");
1611 assert_eq!(v, "a=b");
1612 }
1613
1614 #[tokio::test]
1615 async fn test_parse_key_value_missing_equals() {
1616 let result = parse_key_value("noequals");
1617 assert!(result.is_err());
1618 assert!(result.unwrap_err().contains("KEY=VALUE"));
1619 }
1620
1621 #[tokio::test]
1622 async fn test_parse_key_value_empty_value() {
1623 let (k, v) = parse_key_value("key=").unwrap();
1624 assert_eq!(k, "key");
1625 assert_eq!(v, "");
1626 }
1627
1628 #[tokio::test]
1629 async fn test_format_size_bytes() {
1630 assert_eq!(format_size(0), "0 B");
1631 assert_eq!(format_size(512), "512 B");
1632 assert_eq!(format_size(1023), "1023 B");
1633 }
1634
1635 #[tokio::test]
1636 async fn test_format_size_kb() {
1637 assert_eq!(format_size(1024), "1.0 KB");
1638 assert_eq!(format_size(1536), "1.5 KB");
1639 }
1640
1641 #[tokio::test]
1642 async fn test_format_size_mb() {
1643 assert_eq!(format_size(1024 * 1024), "1.0 MB");
1644 assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB");
1645 }
1646
1647 #[tokio::test]
1648 async fn test_format_size_gb() {
1649 assert_eq!(format_size(1024 * 1024 * 1024), "1.0 GB");
1650 }
1651
1652 #[tokio::test]
1653 async fn test_escape_csv_plain() {
1654 assert_eq!(escape_csv("hello"), "hello");
1655 }
1656
1657 #[tokio::test]
1658 async fn test_escape_csv_with_comma() {
1659 assert_eq!(escape_csv("hello,world"), "\"hello,world\"");
1660 }
1661
1662 #[tokio::test]
1663 async fn test_escape_csv_with_quotes() {
1664 assert_eq!(escape_csv("say \"hi\""), "\"say \"\"hi\"\"\"");
1665 }
1666
1667 #[tokio::test]
1668 async fn test_escape_csv_with_newline() {
1669 assert_eq!(escape_csv("line1\nline2"), "\"line1\nline2\"");
1670 }
1671
1672 #[tokio::test]
1675 async fn test_output_stacks_json() {
1676 let stacks = vec![make_stack("IMG_0001")];
1677 let mut buf = Vec::new();
1678 output_stacks(
1679 &mut buf,
1680 &stacks,
1681 OutputFormat::Json,
1682 false,
1683 &PathBuf::from("/photos"),
1684 )
1685 .await;
1686 let output = String::from_utf8(buf).unwrap();
1687 assert!(output.contains("IMG_0001"));
1688 assert!(output.contains("original"));
1689 }
1690
1691 #[tokio::test]
1692 async fn test_output_stacks_csv_no_metadata() {
1693 let stacks = vec![make_stack("IMG_0001"), make_tiff_stack("IMG_0002")];
1694 let mut buf = Vec::new();
1695 output_stacks_csv(&mut buf, &stacks, false).await;
1696 let output = String::from_utf8(buf).unwrap();
1697 assert!(output.contains("id,format,has_original"));
1698 assert!(output.contains("IMG_0001,-,true,true,true"));
1700 assert!(output.contains("IMG_0002,-,true,false,false"));
1701 }
1702
1703 #[tokio::test]
1704 async fn test_output_stacks_csv_with_metadata() {
1705 let stacks = vec![make_stack("IMG_0001")];
1706 let mut buf = Vec::new();
1707 output_stacks_csv(&mut buf, &stacks, true).await;
1708 let output = String::from_utf8(buf).unwrap();
1709 assert!(output.contains("id,format,original,enhanced,back"));
1710 }
1711
1712 #[tokio::test]
1713 async fn test_output_stacks_table_empty() {
1714 let stacks: Vec<PhotoStack> = vec![];
1715 let mut buf = Vec::new();
1716 output_stacks_table(&mut buf, &stacks, false, &PathBuf::from("/photos")).await;
1717 let output = String::from_utf8(buf).unwrap();
1718 assert!(output.contains("Found 0 photo stack(s)"));
1719 }
1720
1721 #[tokio::test]
1722 async fn test_output_stacks_table_with_stacks() {
1723 let stacks = vec![make_stack("IMG_0001"), make_empty_stack("IMG_0002")];
1724 let mut buf = Vec::new();
1725 output_stacks_table(&mut buf, &stacks, false, &PathBuf::from("/photos")).await;
1726 let output = String::from_utf8(buf).unwrap();
1727 assert!(output.contains("Found 2 photo stack(s)"));
1728 assert!(output.contains("IMG_0001"));
1729 assert!(output.contains("-"));
1731 }
1732
1733 #[tokio::test]
1734 async fn test_output_stacks_table_with_metadata_paths() {
1735 let stacks = vec![make_stack("IMG_0001")];
1736 let mut buf = Vec::new();
1737 output_stacks_table(&mut buf, &stacks, true, &PathBuf::from("/photos")).await;
1738 let output = String::from_utf8(buf).unwrap();
1739 assert!(output.contains("(original)"));
1741 assert!(output.contains("(enhanced)"));
1742 assert!(output.contains("(back)"));
1743 }
1744
1745 #[tokio::test]
1746 async fn test_output_info_table_jpeg() {
1747 let stack = make_stack_with_metadata("IMG_0001").await;
1748 let mut buf = Vec::new();
1749 output_info_table(&mut buf, &stack).await;
1750 let output = String::from_utf8(buf).unwrap();
1751 assert!(output.contains("Stack: IMG_0001"));
1752 assert!(output.contains("EXIF Tags"));
1754 assert!(output.contains("EPSON"));
1755 assert!(output.contains("XMP Tags"));
1756 assert!(output.contains("Creator"));
1757 assert!(output.contains("Custom Tags"));
1758 assert!(output.contains("album"));
1759 }
1760
1761 #[tokio::test]
1762 async fn test_output_info_table_no_images() {
1763 let stack = make_empty_stack("EMPTY");
1764 let mut buf = Vec::new();
1765 output_info_table(&mut buf, &stack).await;
1766 let output = String::from_utf8(buf).unwrap();
1767 assert!(output.contains("Stack: EMPTY"));
1768 }
1770
1771 #[tokio::test]
1772 async fn test_output_info_csv() {
1773 let stack = make_stack_with_metadata("IMG_0001").await;
1774 let mut buf = Vec::new();
1775 output_info_csv(&mut buf, &stack).await;
1776 let output = String::from_utf8(buf).unwrap();
1777 assert!(output.contains("type,key,value"));
1778 assert!(output.contains("id,,IMG_0001"));
1779 assert!(output.contains("file,original,"));
1780 assert!(output.contains("exif,Make,EPSON"));
1781 assert!(output.contains("xmp,Creator,Test User"));
1782 assert!(output.contains("custom,album,"));
1783 }
1784
1785 #[tokio::test]
1786 async fn test_output_info_csv_no_files() {
1787 let stack = make_empty_stack("EMPTY");
1788 let mut buf = Vec::new();
1789 output_info_csv(&mut buf, &stack).await;
1790 let output = String::from_utf8(buf).unwrap();
1791 assert!(output.contains("id,,EMPTY"));
1792 assert!(!output.contains("file,original"));
1794 }
1795
1796 #[tokio::test]
1797 async fn test_output_metadata_table_empty() {
1798 let metadata = Metadata::default();
1799 let mut buf = Vec::new();
1800 output_metadata_table(&mut buf, &metadata);
1801 let output = String::from_utf8(buf).unwrap();
1802 assert!(output.contains("EXIF Tags: (none)"));
1803 assert!(output.contains("XMP Tags: (none)"));
1804 assert!(output.contains("Custom Tags: (none)"));
1805 }
1806
1807 #[tokio::test]
1808 async fn test_output_metadata_table_with_tags() {
1809 let stack = make_stack_with_metadata("test").await;
1810 let mut buf = Vec::new();
1811 output_metadata_table(&mut buf, &stack.metadata().cached().unwrap());
1812 let output = String::from_utf8(buf).unwrap();
1813 assert!(output.contains("EXIF Tags (2):"));
1814 assert!(output.contains("EPSON"));
1815 assert!(output.contains("XMP Tags (1):"));
1816 assert!(output.contains("Test User"));
1817 assert!(output.contains("Custom Tags (1):"));
1818 }
1819
1820 #[tokio::test]
1821 async fn test_output_metadata_table_truncation() {
1822 let mut exif_tags = HashMap::new();
1823 exif_tags.insert("VeryLongTag".to_string(), "x".repeat(100));
1824 let metadata = Metadata {
1825 exif_tags,
1826 xmp_tags: HashMap::new(),
1827 custom_tags: HashMap::new(),
1828 };
1829 let mut buf = Vec::new();
1830 output_metadata_table(&mut buf, &metadata);
1831 let output = String::from_utf8(buf).unwrap();
1832 assert!(output.contains("..."));
1833 }
1834
1835 #[tokio::test]
1836 async fn test_output_metadata_csv_empty() {
1837 let metadata = Metadata::default();
1838 let mut buf = Vec::new();
1839 output_metadata_csv(&mut buf, &metadata);
1840 let output = String::from_utf8(buf).unwrap();
1841 assert_eq!(output.trim(), "type,key,value");
1842 }
1843
1844 #[tokio::test]
1845 async fn test_output_metadata_csv_with_tags() {
1846 let stack = make_stack_with_metadata("test").await;
1847 let mut buf = Vec::new();
1848 output_metadata_csv(&mut buf, &stack.metadata().cached().unwrap());
1849 let output = String::from_utf8(buf).unwrap();
1850 assert!(output.contains("exif,Make,EPSON"));
1851 assert!(output.contains("xmp,Creator,Test User"));
1852 assert!(output.contains("custom,album,"));
1853 }
1854
1855 #[tokio::test]
1858 async fn test_cmd_scan_testdata() {
1859 let mut out = Vec::new();
1860 let mut err = Vec::new();
1861 let code = cmd_scan(
1862 &mut out,
1863 &mut err,
1864 &testdata_path(),
1865 OutputFormat::Table,
1866 false,
1867 false,
1868 false,
1869 false,
1870 false,
1871 false,
1872 0,
1873 0,
1874 ScannerProfile::EnhancedAndBack,
1875 )
1876 .await;
1877 assert_eq!(code, EXIT_SUCCESS);
1878 let output = String::from_utf8(out).unwrap();
1879 assert!(output.contains("photo stack(s)"));
1880 assert!(output.contains("FamilyPhotos"));
1881 }
1882
1883 #[tokio::test]
1884 async fn test_cmd_scan_json() {
1885 let mut out = Vec::new();
1886 let mut err = Vec::new();
1887 let code = cmd_scan(
1888 &mut out,
1889 &mut err,
1890 &testdata_path(),
1891 OutputFormat::Json,
1892 false,
1893 false,
1894 false,
1895 false,
1896 false,
1897 false,
1898 0,
1899 0,
1900 ScannerProfile::EnhancedAndBack,
1901 )
1902 .await;
1903 assert_eq!(code, EXIT_SUCCESS);
1904 let output = String::from_utf8(out).unwrap();
1905 assert!(output.contains("FamilyPhotos"));
1906 let parsed: serde_json::Value = serde_json::from_str(output.trim()).unwrap();
1908 assert!(parsed.is_array());
1909 }
1910
1911 #[tokio::test]
1912 async fn test_cmd_scan_csv() {
1913 let mut out = Vec::new();
1914 let mut err = Vec::new();
1915 let code = cmd_scan(
1916 &mut out,
1917 &mut err,
1918 &testdata_path(),
1919 OutputFormat::Csv,
1920 false,
1921 false,
1922 false,
1923 false,
1924 false,
1925 false,
1926 0,
1927 0,
1928 ScannerProfile::EnhancedAndBack,
1929 )
1930 .await;
1931 assert_eq!(code, EXIT_SUCCESS);
1932 let output = String::from_utf8(out).unwrap();
1933 assert!(output.contains("id,format"));
1934 }
1935
1936 #[tokio::test]
1937 async fn test_cmd_scan_jpeg_only() {
1938 let mut out = Vec::new();
1939 let mut err = Vec::new();
1940 let code = cmd_scan(
1941 &mut out,
1942 &mut err,
1943 &testdata_path(),
1944 OutputFormat::Csv,
1945 false,
1946 false,
1947 false,
1948 true,
1949 false,
1950 false,
1951 0,
1952 0,
1953 ScannerProfile::EnhancedAndBack,
1954 )
1955 .await;
1956 assert_eq!(code, EXIT_SUCCESS);
1957 let output = String::from_utf8(out).unwrap();
1958 for line in output.lines().skip(1) {
1960 if !line.is_empty() {
1961 assert!(
1962 line.contains("jpeg") || line.contains("true") || line.contains("false"),
1963 "Non-JPEG line found: {line}"
1964 );
1965 }
1966 }
1967 }
1968
1969 #[tokio::test]
1970 async fn test_cmd_scan_tiff_only() {
1971 let mut out = Vec::new();
1972 let mut err = Vec::new();
1973 let code = cmd_scan(
1974 &mut out,
1975 &mut err,
1976 &testdata_path(),
1977 OutputFormat::Csv,
1978 false,
1979 false,
1980 true,
1981 false,
1982 false,
1983 false,
1984 0,
1985 0,
1986 ScannerProfile::EnhancedAndBack,
1987 )
1988 .await;
1989 assert_eq!(code, EXIT_SUCCESS);
1990 }
1991
1992 #[tokio::test]
1993 async fn test_cmd_scan_with_back_filter() {
1994 let mut out = Vec::new();
1995 let mut err = Vec::new();
1996 let code = cmd_scan(
1997 &mut out,
1998 &mut err,
1999 &testdata_path(),
2000 OutputFormat::Csv,
2001 false,
2002 false,
2003 false,
2004 false,
2005 true,
2006 false,
2007 0,
2008 0,
2009 ScannerProfile::EnhancedAndBack,
2010 )
2011 .await;
2012 assert_eq!(code, EXIT_SUCCESS);
2013 }
2014
2015 #[tokio::test]
2016 async fn test_cmd_scan_show_metadata() {
2017 let mut out = Vec::new();
2018 let mut err = Vec::new();
2019 let code = cmd_scan(
2020 &mut out,
2021 &mut err,
2022 &testdata_path(),
2023 OutputFormat::Table,
2024 true,
2025 false,
2026 false,
2027 false,
2028 false,
2029 false,
2030 0,
2031 0,
2032 ScannerProfile::EnhancedAndBack,
2033 )
2034 .await;
2035 assert_eq!(code, EXIT_SUCCESS);
2036 let output = String::from_utf8(out).unwrap();
2037 assert!(
2039 output.contains("(original)")
2040 || output.contains("(enhanced)")
2041 || output.contains("(back)")
2042 || output.contains("present")
2043 );
2044 }
2045
2046 #[tokio::test]
2047 async fn test_cmd_scan_csv_with_metadata() {
2048 let mut out = Vec::new();
2049 let mut err = Vec::new();
2050 let code = cmd_scan(
2051 &mut out,
2052 &mut err,
2053 &testdata_path(),
2054 OutputFormat::Csv,
2055 true,
2056 false,
2057 false,
2058 false,
2059 false,
2060 false,
2061 0,
2062 0,
2063 ScannerProfile::EnhancedAndBack,
2064 )
2065 .await;
2066 assert_eq!(code, EXIT_SUCCESS);
2067 let output = String::from_utf8(out).unwrap();
2068 assert!(output.contains("id,format,original,enhanced,back"));
2069 }
2070
2071 #[tokio::test]
2072 async fn test_cmd_scan_nonexistent_dir() {
2073 let mut out = Vec::new();
2074 let mut err = Vec::new();
2075 let code = cmd_scan(
2076 &mut out,
2077 &mut err,
2078 &PathBuf::from("/nonexistent/dir"),
2079 OutputFormat::Table,
2080 false,
2081 false,
2082 false,
2083 false,
2084 false,
2085 false,
2086 0,
2087 0,
2088 ScannerProfile::EnhancedAndBack,
2089 )
2090 .await;
2091 assert!(code == EXIT_SUCCESS || code == EXIT_ERROR);
2093 }
2094
2095 #[tokio::test]
2096 async fn test_cmd_search_testdata() {
2097 let mut out = Vec::new();
2098 let mut err = Vec::new();
2099 let code = cmd_search(
2100 &mut out,
2101 &mut err,
2102 &testdata_path(),
2103 "FamilyPhotos",
2104 &[],
2105 &[],
2106 false,
2107 false,
2108 &[],
2109 OutputFormat::Table,
2110 0,
2111 0,
2112 )
2113 .await;
2114 assert_eq!(code, EXIT_SUCCESS);
2115 let output = String::from_utf8(out).unwrap();
2116 assert!(output.contains("FamilyPhotos"));
2117 }
2118
2119 #[tokio::test]
2120 async fn test_cmd_search_no_results() {
2121 let mut out = Vec::new();
2122 let mut err = Vec::new();
2123 let code = cmd_search(
2124 &mut out,
2125 &mut err,
2126 &testdata_path(),
2127 "zzz_nonexistent",
2128 &[],
2129 &[],
2130 false,
2131 false,
2132 &[],
2133 OutputFormat::Table,
2134 0,
2135 0,
2136 )
2137 .await;
2138 assert_eq!(code, EXIT_SUCCESS);
2139 let output = String::from_utf8(out).unwrap();
2140 assert!(output.contains("Found 0"));
2141 }
2142
2143 #[tokio::test]
2144 async fn test_cmd_search_with_exif_filter() {
2145 let mut out = Vec::new();
2146 let mut err = Vec::new();
2147 let exif_filters = vec![("Make".to_string(), "EPSON".to_string())];
2148 let code = cmd_search(
2149 &mut out,
2150 &mut err,
2151 &testdata_path(),
2152 "Family",
2153 &exif_filters,
2154 &[],
2155 false,
2156 false,
2157 &[],
2158 OutputFormat::Json,
2159 0,
2160 0,
2161 )
2162 .await;
2163 assert_eq!(code, EXIT_SUCCESS);
2164 }
2165
2166 #[tokio::test]
2167 async fn test_cmd_search_with_has_back() {
2168 let mut out = Vec::new();
2169 let mut err = Vec::new();
2170 let code = cmd_search(
2171 &mut out,
2172 &mut err,
2173 &testdata_path(),
2174 "Family",
2175 &[],
2176 &[],
2177 true,
2178 false,
2179 &[],
2180 OutputFormat::Csv,
2181 0,
2182 0,
2183 )
2184 .await;
2185 assert_eq!(code, EXIT_SUCCESS);
2186 }
2187
2188 #[tokio::test]
2189 async fn test_cmd_search_with_has_enhanced() {
2190 let mut out = Vec::new();
2191 let mut err = Vec::new();
2192 let code = cmd_search(
2193 &mut out,
2194 &mut err,
2195 &testdata_path(),
2196 "Family",
2197 &[],
2198 &[],
2199 false,
2200 true,
2201 &[],
2202 OutputFormat::Table,
2203 0,
2204 0,
2205 )
2206 .await;
2207 assert_eq!(code, EXIT_SUCCESS);
2208 }
2209
2210 #[tokio::test]
2211 async fn test_cmd_search_with_tag_filter() {
2212 let mut out = Vec::new();
2213 let mut err = Vec::new();
2214 let tag_filters = vec![("album".to_string(), "Family".to_string())];
2215 let code = cmd_search(
2216 &mut out,
2217 &mut err,
2218 &testdata_path(),
2219 "Family",
2220 &[],
2221 &tag_filters,
2222 false,
2223 false,
2224 &[],
2225 OutputFormat::Table,
2226 0,
2227 0,
2228 )
2229 .await;
2230 assert_eq!(code, EXIT_SUCCESS);
2231 }
2232
2233 #[tokio::test]
2234 async fn test_cmd_search_with_stack_ids() {
2235 let mut out = Vec::new();
2236 let mut err = Vec::new();
2237 let ids = vec!["FamilyPhotos_0001".to_string()];
2238 let code = cmd_search(
2239 &mut out,
2240 &mut err,
2241 &testdata_path(),
2242 "",
2243 &[],
2244 &[],
2245 false,
2246 false,
2247 &ids,
2248 OutputFormat::Table,
2249 0,
2250 0,
2251 )
2252 .await;
2253 assert_eq!(code, EXIT_SUCCESS);
2254 let output = String::from_utf8(out).unwrap();
2255 assert!(output.contains("FamilyPhotos_0001"));
2256 }
2257
2258 #[tokio::test]
2259 async fn test_cmd_search_with_stack_ids_no_match() {
2260 let mut out = Vec::new();
2261 let mut err = Vec::new();
2262 let ids = vec!["NONEXISTENT_ID".to_string()];
2263 let code = cmd_search(
2264 &mut out,
2265 &mut err,
2266 &testdata_path(),
2267 "",
2268 &[],
2269 &[],
2270 false,
2271 false,
2272 &ids,
2273 OutputFormat::Table,
2274 0,
2275 0,
2276 )
2277 .await;
2278 assert_eq!(code, EXIT_SUCCESS);
2279 let output = String::from_utf8(out).unwrap();
2280 assert!(output.contains("Found 0"));
2281 }
2282
2283 #[tokio::test]
2284 async fn test_cmd_info_happy_path() {
2285 let mut out = Vec::new();
2286 let mut err = Vec::new();
2287 let code = cmd_info(
2288 &mut out,
2289 &mut err,
2290 &testdata_path(),
2291 "FamilyPhotos_0001",
2292 OutputFormat::Table,
2293 )
2294 .await;
2295 assert_eq!(code, EXIT_SUCCESS);
2296 let output = String::from_utf8(out).unwrap();
2297 assert!(output.contains("FamilyPhotos_0001"));
2298 }
2299
2300 #[tokio::test]
2301 async fn test_cmd_info_json() {
2302 let mut out = Vec::new();
2303 let mut err = Vec::new();
2304 let code = cmd_info(
2305 &mut out,
2306 &mut err,
2307 &testdata_path(),
2308 "FamilyPhotos_0001",
2309 OutputFormat::Json,
2310 )
2311 .await;
2312 assert_eq!(code, EXIT_SUCCESS);
2313 let output = String::from_utf8(out).unwrap();
2314 let _parsed: serde_json::Value = serde_json::from_str(output.trim()).unwrap();
2315 }
2316
2317 #[tokio::test]
2318 async fn test_cmd_info_csv() {
2319 let mut out = Vec::new();
2320 let mut err = Vec::new();
2321 let code = cmd_info(
2322 &mut out,
2323 &mut err,
2324 &testdata_path(),
2325 "FamilyPhotos_0001",
2326 OutputFormat::Csv,
2327 )
2328 .await;
2329 assert_eq!(code, EXIT_SUCCESS);
2330 let output = String::from_utf8(out).unwrap();
2331 assert!(output.contains("type,key,value"));
2332 }
2333
2334 #[tokio::test]
2335 async fn test_cmd_info_not_found() {
2336 let mut out = Vec::new();
2337 let mut err = Vec::new();
2338 let code = cmd_info(
2339 &mut out,
2340 &mut err,
2341 &testdata_path(),
2342 "nonexistent_stack",
2343 OutputFormat::Table,
2344 )
2345 .await;
2346 assert_eq!(code, EXIT_NOT_FOUND);
2347 let error_output = String::from_utf8(err).unwrap();
2348 assert!(error_output.contains("not found"));
2349 }
2350
2351 #[tokio::test]
2352 async fn test_cmd_metadata_read_table() {
2353 let mut out = Vec::new();
2354 let mut err = Vec::new();
2355 let code = cmd_metadata_read(
2356 &mut out,
2357 &mut err,
2358 &testdata_path(),
2359 "FamilyPhotos_0001",
2360 OutputFormat::Table,
2361 )
2362 .await;
2363 assert_eq!(code, EXIT_SUCCESS);
2364 let output = String::from_utf8(out).unwrap();
2365 assert!(output.contains("Metadata"));
2366 }
2367
2368 #[tokio::test]
2369 async fn test_cmd_metadata_read_json() {
2370 let mut out = Vec::new();
2371 let mut err = Vec::new();
2372 let code = cmd_metadata_read(
2373 &mut out,
2374 &mut err,
2375 &testdata_path(),
2376 "FamilyPhotos_0001",
2377 OutputFormat::Json,
2378 )
2379 .await;
2380 assert_eq!(code, EXIT_SUCCESS);
2381 let output = String::from_utf8(out).unwrap();
2382 let _parsed: serde_json::Value = serde_json::from_str(output.trim()).unwrap();
2383 }
2384
2385 #[tokio::test]
2386 async fn test_cmd_metadata_read_csv() {
2387 let mut out = Vec::new();
2388 let mut err = Vec::new();
2389 let code = cmd_metadata_read(
2390 &mut out,
2391 &mut err,
2392 &testdata_path(),
2393 "FamilyPhotos_0001",
2394 OutputFormat::Csv,
2395 )
2396 .await;
2397 assert_eq!(code, EXIT_SUCCESS);
2398 }
2399
2400 #[tokio::test]
2401 async fn test_cmd_metadata_read_not_found() {
2402 let mut out = Vec::new();
2403 let mut err = Vec::new();
2404 let code = cmd_metadata_read(
2405 &mut out,
2406 &mut err,
2407 &testdata_path(),
2408 "nonexistent",
2409 OutputFormat::Table,
2410 )
2411 .await;
2412 assert_eq!(code, EXIT_NOT_FOUND);
2413 }
2414
2415 #[tokio::test]
2416 async fn test_cmd_metadata_write_happy_path() {
2417 let dir = copy_testdata_to_tempdir();
2418 let mut out = Vec::new();
2419 let mut err = Vec::new();
2420 let tags = vec![
2421 ("album".to_string(), "Family".to_string()),
2422 ("year".to_string(), "2024".to_string()),
2423 ];
2424 let code = cmd_metadata_write(
2425 &mut out,
2426 &mut err,
2427 &dir.path().to_path_buf(),
2428 "FamilyPhotos_0001",
2429 &tags,
2430 )
2431 .await;
2432 assert_eq!(code, EXIT_SUCCESS);
2433 let output = String::from_utf8(out).unwrap();
2434 assert!(output.contains("Wrote 2 tag(s)"));
2435 }
2436
2437 #[tokio::test]
2438 async fn test_cmd_metadata_write_not_found() {
2439 let dir = copy_testdata_to_tempdir();
2440 let mut out = Vec::new();
2441 let mut err = Vec::new();
2442 let tags = vec![("album".to_string(), "Test".to_string())];
2443 let code = cmd_metadata_write(
2444 &mut out,
2445 &mut err,
2446 &dir.path().to_path_buf(),
2447 "nonexistent",
2448 &tags,
2449 )
2450 .await;
2451 assert_eq!(code, EXIT_NOT_FOUND);
2452 }
2453
2454 #[tokio::test]
2455 async fn test_cmd_metadata_delete_not_found() {
2456 let dir = copy_testdata_to_tempdir();
2457 let mut out = Vec::new();
2458 let mut err = Vec::new();
2459 let tags = vec!["album".to_string()];
2460 let code = cmd_metadata_delete(
2461 &mut out,
2462 &mut err,
2463 &dir.path().to_path_buf(),
2464 "nonexistent",
2465 &tags,
2466 )
2467 .await;
2468 assert_eq!(code, EXIT_NOT_FOUND);
2469 }
2470
2471 #[tokio::test]
2472 async fn test_cmd_metadata_delete_happy_path() {
2473 let dir = copy_testdata_to_tempdir();
2474
2475 let mut out = Vec::new();
2477 let mut err = Vec::new();
2478 let tags = vec![("album".to_string(), "Family".to_string())];
2479 cmd_metadata_write(
2480 &mut out,
2481 &mut err,
2482 &dir.path().to_path_buf(),
2483 "FamilyPhotos_0001",
2484 &tags,
2485 )
2486 .await;
2487
2488 let mut out = Vec::new();
2490 let mut err = Vec::new();
2491 let tags = vec!["album".to_string()];
2492 let code = cmd_metadata_delete(
2493 &mut out,
2494 &mut err,
2495 &dir.path().to_path_buf(),
2496 "FamilyPhotos_0001",
2497 &tags,
2498 )
2499 .await;
2500 assert_eq!(code, EXIT_SUCCESS);
2501 let output = String::from_utf8(out).unwrap();
2502 assert!(output.contains("Deleted 1 tag(s)"));
2503 }
2504
2505 #[tokio::test]
2506 async fn test_cmd_export_stdout() {
2507 let mut out = Vec::new();
2508 let mut err = Vec::new();
2509 let code = cmd_export(&mut out, &mut err, &testdata_path(), None).await;
2510 assert_eq!(code, EXIT_SUCCESS);
2511 let output = String::from_utf8(out).unwrap();
2512 let parsed: serde_json::Value = serde_json::from_str(output.trim()).unwrap();
2513 assert!(parsed.is_array());
2514 }
2515
2516 #[tokio::test]
2517 async fn test_cmd_export_to_file() {
2518 let dir = stable_tempdir();
2519 let output_file = dir.path().join("export.json");
2520 let mut out = Vec::new();
2521 let mut err = Vec::new();
2522 let code = cmd_export(&mut out, &mut err, &testdata_path(), Some(&output_file)).await;
2523 assert_eq!(code, EXIT_SUCCESS);
2524 let output = String::from_utf8(out).unwrap();
2525 assert!(output.contains("Exported"));
2526 assert!(output_file.exists());
2527 let content = std::fs::read_to_string(&output_file).unwrap();
2528 let _: serde_json::Value = serde_json::from_str(&content).unwrap();
2529 }
2530
2531 #[tokio::test]
2532 async fn test_cmd_export_to_invalid_path() {
2533 let mut out = Vec::new();
2534 let mut err = Vec::new();
2535 let code = cmd_export(
2536 &mut out,
2537 &mut err,
2538 &testdata_path(),
2539 Some(Path::new("/nonexistent/dir/out.json")),
2540 )
2541 .await;
2542 assert_eq!(code, EXIT_ERROR);
2543 let error_output = String::from_utf8(err).unwrap();
2544 assert!(error_output.contains("Error writing"));
2545 }
2546
2547 #[tokio::test]
2548 async fn test_cmd_scan_empty_dir() {
2549 let dir = stable_tempdir();
2550 let mut out = Vec::new();
2551 let mut err = Vec::new();
2552 let code = cmd_scan(
2553 &mut out,
2554 &mut err,
2555 &dir.path().to_path_buf(),
2556 OutputFormat::Table,
2557 false,
2558 false,
2559 false,
2560 false,
2561 false,
2562 false,
2563 0,
2564 0,
2565 ScannerProfile::EnhancedAndBack,
2566 )
2567 .await;
2568 assert_eq!(code, EXIT_SUCCESS);
2569 let output = String::from_utf8(out).unwrap();
2570 assert!(output.contains("Found 0"));
2571 }
2572
2573 #[tokio::test]
2576 async fn test_output_stacks_table_tiff_format() {
2577 let stacks = vec![make_tiff_stack("IMG_0001")];
2578 let mut buf = Vec::new();
2579 output_stacks_table(&mut buf, &stacks, false, &PathBuf::from("/photos")).await;
2580 let output = String::from_utf8(buf).unwrap();
2581 assert!(output.contains("IMG_0001"));
2583 assert!(output.contains("-"));
2584 }
2585
2586 #[tokio::test]
2587 async fn test_output_stacks_table_no_format() {
2588 let stacks = vec![make_empty_stack("IMG_0001")];
2589 let mut buf = Vec::new();
2590 output_stacks_table(&mut buf, &stacks, false, &PathBuf::from("/photos")).await;
2591 let output = String::from_utf8(buf).unwrap();
2592 assert!(output.contains("-"));
2593 }
2594
2595 #[tokio::test]
2596 async fn test_output_stacks_csv_tiff_format() {
2597 let stacks = vec![make_tiff_stack("IMG_0001")];
2598 let mut buf = Vec::new();
2599 output_stacks_csv(&mut buf, &stacks, false).await;
2600 let output = String::from_utf8(buf).unwrap();
2601 assert!(output.contains("IMG_0001,-,true,false,false"));
2603 }
2604
2605 #[tokio::test]
2606 async fn test_output_stacks_csv_no_format() {
2607 let stacks = vec![make_empty_stack("IMG_0001")];
2608 let mut buf = Vec::new();
2609 output_stacks_csv(&mut buf, &stacks, false).await;
2610 let output = String::from_utf8(buf).unwrap();
2611 assert!(output.contains("IMG_0001,-,false,false,false"));
2613 }
2614
2615 #[tokio::test]
2616 async fn test_output_info_table_with_long_tag_truncation() {
2617 let mut exif_tags = HashMap::new();
2618 exif_tags.insert("Description".to_string(), "A".repeat(100));
2619 let stack = PhotoStack::new("TRUNC");
2620 stack.set_metadata(make_metadata_ref(Metadata {
2621 exif_tags,
2622 xmp_tags: HashMap::new(),
2623 custom_tags: HashMap::new(),
2624 }));
2625 let mut buf = Vec::new();
2626 output_info_table(&mut buf, &stack).await;
2627 let output = String::from_utf8(buf).unwrap();
2628 assert!(output.contains("..."));
2629 }
2630
2631 #[tokio::test]
2632 async fn test_output_info_table_tiff() {
2633 let stack = make_tiff_stack("TIFF_0001");
2634 let mut buf = Vec::new();
2635 output_info_table(&mut buf, &stack).await;
2636 let output = String::from_utf8(buf).unwrap();
2637 assert!(output.contains("Stack: TIFF_0001"));
2638 }
2639
2640 #[tokio::test]
2643 async fn test_run_cli_scan_dispatch() {
2644 let cli = Cli {
2645 command: Commands::Scan {
2646 directory: testdata_path(),
2647 format: OutputFormat::Table,
2648 show_metadata: false,
2649 metadata: false,
2650 tiff_only: false,
2651 jpeg_only: false,
2652 with_back: false,
2653 recursive: false,
2654 limit: 0,
2655 offset: 0,
2656 profile: CliScannerProfile::Auto,
2657 },
2658 };
2659 let mut out = Vec::new();
2660 let mut err = Vec::new();
2661 let code = run_cli(&cli, &mut out, &mut err).await;
2662 assert_eq!(code, EXIT_SUCCESS);
2663 }
2664
2665 #[tokio::test]
2666 async fn test_run_cli_search_dispatch() {
2667 let cli = Cli {
2668 command: Commands::Search {
2669 directory: testdata_path(),
2670 query: "FamilyPhotos".to_string(),
2671 exif_filters: vec![],
2672 tag_filters: vec![],
2673 has_back: false,
2674 has_enhanced: false,
2675 stack_ids: vec![],
2676 format: OutputFormat::Table,
2677 limit: 0,
2678 offset: 0,
2679 },
2680 };
2681 let mut out = Vec::new();
2682 let mut err = Vec::new();
2683 let code = run_cli(&cli, &mut out, &mut err).await;
2684 assert_eq!(code, EXIT_SUCCESS);
2685 }
2686
2687 #[tokio::test]
2688 async fn test_run_cli_info_dispatch() {
2689 let cli = Cli {
2690 command: Commands::Info {
2691 directory: testdata_path(),
2692 stack_id: "FamilyPhotos_0001".to_string(),
2693 format: OutputFormat::Table,
2694 },
2695 };
2696 let mut out = Vec::new();
2697 let mut err = Vec::new();
2698 let code = run_cli(&cli, &mut out, &mut err).await;
2699 assert_eq!(code, EXIT_SUCCESS);
2700 }
2701
2702 #[tokio::test]
2703 async fn test_run_cli_metadata_read_dispatch() {
2704 let cli = Cli {
2705 command: Commands::Metadata(MetadataCommand::Read {
2706 directory: testdata_path(),
2707 stack_id: "FamilyPhotos_0001".to_string(),
2708 format: OutputFormat::Table,
2709 }),
2710 };
2711 let mut out = Vec::new();
2712 let mut err = Vec::new();
2713 let code = run_cli(&cli, &mut out, &mut err).await;
2714 assert_eq!(code, EXIT_SUCCESS);
2715 }
2716
2717 #[tokio::test]
2718 async fn test_run_cli_metadata_write_dispatch() {
2719 let dir = copy_testdata_to_tempdir();
2720 let cli = Cli {
2721 command: Commands::Metadata(MetadataCommand::Write {
2722 directory: dir.path().to_path_buf(),
2723 stack_id: "FamilyPhotos_0001".to_string(),
2724 tags: vec![("test_key".to_string(), "test_val".to_string())],
2725 }),
2726 };
2727 let mut out = Vec::new();
2728 let mut err = Vec::new();
2729 let code = run_cli(&cli, &mut out, &mut err).await;
2730 assert_eq!(code, EXIT_SUCCESS);
2731 }
2732
2733 #[tokio::test]
2734 async fn test_run_cli_metadata_delete_dispatch() {
2735 let dir = copy_testdata_to_tempdir();
2736 let tags_w = vec![("del_key".to_string(), "val".to_string())];
2738 cmd_metadata_write(
2739 &mut Vec::new(),
2740 &mut Vec::new(),
2741 &dir.path().to_path_buf(),
2742 "FamilyPhotos_0001",
2743 &tags_w,
2744 )
2745 .await;
2746
2747 let cli = Cli {
2748 command: Commands::Metadata(MetadataCommand::Delete {
2749 directory: dir.path().to_path_buf(),
2750 stack_id: "FamilyPhotos_0001".to_string(),
2751 tags: vec!["del_key".to_string()],
2752 }),
2753 };
2754 let mut out = Vec::new();
2755 let mut err = Vec::new();
2756 let code = run_cli(&cli, &mut out, &mut err).await;
2757 assert_eq!(code, EXIT_SUCCESS);
2758 }
2759
2760 #[tokio::test]
2761 async fn test_run_cli_export_dispatch() {
2762 let cli = Cli {
2763 command: Commands::Export {
2764 directory: testdata_path(),
2765 output: None,
2766 },
2767 };
2768 let mut out = Vec::new();
2769 let mut err = Vec::new();
2770 let code = run_cli(&cli, &mut out, &mut err).await;
2771 assert_eq!(code, EXIT_SUCCESS);
2772 }
2773
2774 #[tokio::test]
2777 async fn test_output_info_table_with_xmp_tags() {
2778 let mut xmp_tags = HashMap::new();
2779 xmp_tags.insert("Creator".to_string(), "John Doe".to_string());
2780 let stack = PhotoStack::new("XMP_TEST");
2781 stack.set_original(make_image_ref("/photos/XMP_TEST.jpg"));
2782 stack.set_metadata(make_metadata_ref(Metadata {
2783 exif_tags: HashMap::new(),
2784 xmp_tags,
2785 custom_tags: HashMap::new(),
2786 }));
2787 let mut buf = Vec::new();
2788 output_info_table(&mut buf, &stack).await;
2789 let output = String::from_utf8(buf).unwrap();
2790 assert!(output.contains("XMP Tags"));
2791 assert!(output.contains("Creator"));
2792 }
2793
2794 #[tokio::test]
2795 async fn test_output_info_table_with_custom_tags() {
2796 let mut custom_tags = HashMap::new();
2797 custom_tags.insert(
2798 "album".to_string(),
2799 serde_json::Value::String("vacation".to_string()),
2800 );
2801 let stack = PhotoStack::new("CUSTOM_TEST");
2802 stack.set_metadata(make_metadata_ref(Metadata {
2803 exif_tags: HashMap::new(),
2804 xmp_tags: HashMap::new(),
2805 custom_tags,
2806 }));
2807 let mut buf = Vec::new();
2808 output_info_table(&mut buf, &stack).await;
2809 let output = String::from_utf8(buf).unwrap();
2810 assert!(output.contains("Custom Tags"));
2811 assert!(output.contains("album"));
2812 }
2813
2814 #[tokio::test]
2815 async fn test_output_metadata_table_with_xmp_tags() {
2816 let mut xmp_tags = HashMap::new();
2817 xmp_tags.insert("Subject".to_string(), "Landscape".to_string());
2818 let meta = Metadata {
2819 exif_tags: HashMap::new(),
2820 xmp_tags,
2821 custom_tags: HashMap::new(),
2822 };
2823 let mut buf = Vec::new();
2824 output_metadata_table(&mut buf, &meta);
2825 let output = String::from_utf8(buf).unwrap();
2826 assert!(output.contains("XMP Tags"));
2827 assert!(output.contains("Subject"));
2828 }
2829
2830 #[tokio::test]
2831 async fn test_output_info_csv_with_xmp_and_custom_tags() {
2832 let mut xmp_tags = HashMap::new();
2833 xmp_tags.insert("Creator".to_string(), "Jane".to_string());
2834 let mut custom_tags = HashMap::new();
2835 custom_tags.insert("rating".to_string(), serde_json::Value::from(5));
2836 let stack = PhotoStack::new("CSV_TAGS");
2837 stack.set_original(make_image_ref("/photos/CSV_TAGS.jpg"));
2838 stack.set_enhanced(make_image_ref("/photos/CSV_TAGS_a.jpg"));
2839 stack.set_back(make_image_ref("/photos/CSV_TAGS_b.jpg"));
2840 stack.set_metadata(make_metadata_ref(Metadata {
2841 exif_tags: HashMap::new(),
2842 xmp_tags,
2843 custom_tags,
2844 }));
2845 let mut buf = Vec::new();
2846 output_info_csv(&mut buf, &stack).await;
2847 let output = String::from_utf8(buf).unwrap();
2848 assert!(output.contains("xmp,Creator,Jane"));
2849 assert!(output.contains("custom,rating,5"));
2850 assert!(output.contains("file,original"));
2851 assert!(output.contains("file,enhanced"));
2852 assert!(output.contains("file,back"));
2853 }
2854
2855 #[tokio::test]
2856 async fn test_output_metadata_csv_with_xmp() {
2857 let mut xmp_tags = HashMap::new();
2858 xmp_tags.insert("Title".to_string(), "My Photo".to_string());
2859 let meta = Metadata {
2860 exif_tags: HashMap::new(),
2861 xmp_tags,
2862 custom_tags: HashMap::new(),
2863 };
2864 let mut buf = Vec::new();
2865 output_metadata_csv(&mut buf, &meta);
2866 let output = String::from_utf8(buf).unwrap();
2867 assert!(output.contains("xmp,Title,My Photo"));
2868 }
2869
2870 #[tokio::test]
2871 async fn test_output_info_table_with_long_xmp_truncation() {
2872 let mut xmp_tags = HashMap::new();
2873 xmp_tags.insert("Description".to_string(), "X".repeat(100));
2874 let stack = PhotoStack::new("LONG_XMP");
2875 stack.set_metadata(make_metadata_ref(Metadata {
2876 exif_tags: HashMap::new(),
2877 xmp_tags,
2878 custom_tags: HashMap::new(),
2879 }));
2880 let mut buf = Vec::new();
2881 output_info_table(&mut buf, &stack).await;
2882 let output = String::from_utf8(buf).unwrap();
2883 assert!(output.contains("..."));
2884 }
2885
2886 #[tokio::test]
2887 async fn test_output_info_table_with_long_custom_truncation() {
2888 let mut custom_tags = HashMap::new();
2889 custom_tags.insert(
2890 "longval".to_string(),
2891 serde_json::Value::String("Y".repeat(100)),
2892 );
2893 let stack = PhotoStack::new("LONG_CUSTOM");
2894 stack.set_metadata(make_metadata_ref(Metadata {
2895 exif_tags: HashMap::new(),
2896 xmp_tags: HashMap::new(),
2897 custom_tags,
2898 }));
2899 let mut buf = Vec::new();
2900 output_info_table(&mut buf, &stack).await;
2901 let output = String::from_utf8(buf).unwrap();
2902 assert!(output.contains("..."));
2903 }
2904
2905 #[tokio::test]
2906 async fn test_output_metadata_table_with_long_xmp_truncation() {
2907 let mut xmp_tags = HashMap::new();
2908 xmp_tags.insert("LongKey".to_string(), "Z".repeat(100));
2909 let meta = Metadata {
2910 exif_tags: HashMap::new(),
2911 xmp_tags,
2912 custom_tags: HashMap::new(),
2913 };
2914 let mut buf = Vec::new();
2915 output_metadata_table(&mut buf, &meta);
2916 let output = String::from_utf8(buf).unwrap();
2917 assert!(output.contains("..."));
2918 }
2919
2920 #[tokio::test]
2921 async fn test_output_metadata_table_with_long_custom_truncation() {
2922 let mut custom_tags = HashMap::new();
2923 custom_tags.insert(
2924 "longkey".to_string(),
2925 serde_json::Value::String("W".repeat(100)),
2926 );
2927 let meta = Metadata {
2928 exif_tags: HashMap::new(),
2929 xmp_tags: HashMap::new(),
2930 custom_tags,
2931 };
2932 let mut buf = Vec::new();
2933 output_metadata_table(&mut buf, &meta);
2934 let output = String::from_utf8(buf).unwrap();
2935 assert!(output.contains("..."));
2936 }
2937
2938 #[tokio::test]
2941 async fn test_cmd_search_scan_error() {
2942 let mut out = Vec::new();
2943 let mut err = Vec::new();
2944 let code = cmd_search(
2945 &mut out,
2946 &mut err,
2947 &PathBuf::from("/nonexistent/search/dir"),
2948 "query",
2949 &[],
2950 &[],
2951 false,
2952 false,
2953 &[],
2954 OutputFormat::Table,
2955 0,
2956 0,
2957 )
2958 .await;
2959 assert!(code == EXIT_SUCCESS || code == EXIT_ERROR);
2961 }
2962
2963 #[tokio::test]
2964 async fn test_cmd_export_scan_error() {
2965 let mut out = Vec::new();
2966 let mut err = Vec::new();
2967 let code = cmd_export(
2968 &mut out,
2969 &mut err,
2970 &PathBuf::from("/nonexistent/export/dir"),
2971 None,
2972 )
2973 .await;
2974 assert!(code == EXIT_SUCCESS || code == EXIT_ERROR);
2975 }
2976
2977 #[tokio::test]
2978 async fn test_cmd_info_generic_error() {
2979 let mut out = Vec::new();
2981 let mut err = Vec::new();
2982 let code = cmd_info(
2984 &mut out,
2985 &mut err,
2986 &PathBuf::from("/nonexistent/info/dir"),
2987 "NO_STACK",
2988 OutputFormat::Table,
2989 )
2990 .await;
2991 assert!(code == EXIT_NOT_FOUND || code == EXIT_ERROR);
2992 }
2993
2994 #[tokio::test]
2995 async fn test_cmd_metadata_read_generic_error() {
2996 let mut out = Vec::new();
2997 let mut err = Vec::new();
2998 let code = cmd_metadata_read(
2999 &mut out,
3000 &mut err,
3001 &PathBuf::from("/nonexistent/meta/dir"),
3002 "NO_STACK",
3003 OutputFormat::Table,
3004 )
3005 .await;
3006 assert!(code == EXIT_NOT_FOUND || code == EXIT_ERROR);
3007 }
3008
3009 #[tokio::test]
3010 async fn test_cmd_metadata_write_generic_error() {
3011 let mut out = Vec::new();
3012 let mut err = Vec::new();
3013 let tags = vec![("k".to_string(), "v".to_string())];
3014 let code = cmd_metadata_write(
3015 &mut out,
3016 &mut err,
3017 &PathBuf::from("/nonexistent/write/dir"),
3018 "NO_STACK",
3019 &tags,
3020 )
3021 .await;
3022 assert!(code == EXIT_NOT_FOUND || code == EXIT_ERROR);
3023 }
3024
3025 fn create_test_image_jpeg(path: &std::path::Path, width: u32, height: u32) {
3029 let img = image::RgbImage::from_fn(width, height, |x, y| image::Rgb([x as u8, y as u8, 0]));
3030 img.save(path).unwrap();
3031 }
3032
3033 #[tokio::test]
3034 async fn test_cmd_rotate_success() {
3035 let dir = stable_tempdir();
3036 create_test_image_jpeg(&dir.path().join("IMG_001.jpg"), 4, 2);
3037 create_test_image_jpeg(&dir.path().join("IMG_001_a.jpg"), 4, 2);
3038
3039 let mut out = Vec::new();
3040 let mut err = Vec::new();
3041 let code = cmd_rotate(
3042 &mut out,
3043 &mut err,
3044 &dir.path().to_path_buf(),
3045 "IMG_001",
3046 90,
3047 RotationTarget::All,
3048 OutputFormat::Table,
3049 )
3050 .await;
3051 assert_eq!(code, EXIT_SUCCESS);
3052 let output = String::from_utf8(out).unwrap();
3053 assert!(output.contains("Rotated"));
3054 assert!(output.contains("IMG_001"));
3055 assert!(output.contains("90°"));
3056 }
3057
3058 #[tokio::test]
3059 async fn test_cmd_rotate_negative_90() {
3060 let dir = stable_tempdir();
3061 create_test_image_jpeg(&dir.path().join("IMG_001.jpg"), 4, 2);
3062
3063 let mut out = Vec::new();
3064 let mut err = Vec::new();
3065 let code = cmd_rotate(
3066 &mut out,
3067 &mut err,
3068 &dir.path().to_path_buf(),
3069 "IMG_001",
3070 -90,
3071 RotationTarget::All,
3072 OutputFormat::Table,
3073 )
3074 .await;
3075 assert_eq!(code, EXIT_SUCCESS);
3076 let output = String::from_utf8(out).unwrap();
3077 assert!(output.contains("270°"));
3078 }
3079
3080 #[tokio::test]
3081 async fn test_cmd_rotate_180() {
3082 let dir = stable_tempdir();
3083 create_test_image_jpeg(&dir.path().join("IMG_001.jpg"), 4, 2);
3084
3085 let mut out = Vec::new();
3086 let mut err = Vec::new();
3087 let code = cmd_rotate(
3088 &mut out,
3089 &mut err,
3090 &dir.path().to_path_buf(),
3091 "IMG_001",
3092 180,
3093 RotationTarget::All,
3094 OutputFormat::Table,
3095 )
3096 .await;
3097 assert_eq!(code, EXIT_SUCCESS);
3098 }
3099
3100 #[tokio::test]
3101 async fn test_cmd_rotate_invalid_degrees() {
3102 let mut out = Vec::new();
3103 let mut err = Vec::new();
3104 let code = cmd_rotate(
3105 &mut out,
3106 &mut err,
3107 &PathBuf::from("."),
3108 "test",
3109 45,
3110 RotationTarget::All,
3111 OutputFormat::Table,
3112 )
3113 .await;
3114 assert_eq!(code, EXIT_ERROR);
3115 let err_output = String::from_utf8(err).unwrap();
3116 assert!(err_output.contains("Invalid rotation"));
3117 }
3118
3119 #[tokio::test]
3120 async fn test_cmd_rotate_not_found() {
3121 let dir = stable_tempdir();
3122 let mut out = Vec::new();
3123 let mut err = Vec::new();
3124 let code = cmd_rotate(
3125 &mut out,
3126 &mut err,
3127 &dir.path().to_path_buf(),
3128 "nonexistent",
3129 90,
3130 RotationTarget::All,
3131 OutputFormat::Table,
3132 )
3133 .await;
3134 assert_eq!(code, EXIT_NOT_FOUND);
3135 }
3136
3137 #[tokio::test]
3138 async fn test_cmd_rotate_json_output() {
3139 let dir = stable_tempdir();
3140 create_test_image_jpeg(&dir.path().join("IMG_001.jpg"), 4, 2);
3141
3142 let mut out = Vec::new();
3143 let mut err = Vec::new();
3144 let code = cmd_rotate(
3145 &mut out,
3146 &mut err,
3147 &dir.path().to_path_buf(),
3148 "IMG_001",
3149 90,
3150 RotationTarget::All,
3151 OutputFormat::Json,
3152 )
3153 .await;
3154 assert_eq!(code, EXIT_SUCCESS);
3155 let output = String::from_utf8(out).unwrap();
3156 assert!(output.contains("\"name\": \"IMG_001\""));
3157 }
3158}