Skip to main content

photostax_cli/
lib.rs

1//! # photostax-cli
2//!
3//! Command-line tool for inspecting and managing Epson FastFoto photo stacks.
4//!
5//! This module provides the core CLI logic as a library, enabling both
6//! the CLI binary and unit tests to use the same code.
7
8use 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/// CLI tool for inspecting and managing Epson FastFoto photo stacks
19#[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    /// Scan a directory and list all photo stacks
30    #[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 containing FastFoto scans
40        directory: PathBuf,
41
42        /// Output format
43        #[arg(long, short, value_enum, default_value_t = OutputFormat::Table)]
44        format: OutputFormat,
45
46        /// Include metadata in output
47        #[arg(long)]
48        show_metadata: bool,
49
50        /// Load file-based metadata (EXIF, XMP, sidecar). Auto-enabled when --show-metadata is used.
51        #[arg(long, short = 'm')]
52        metadata: bool,
53
54        /// Only show TIFF stacks
55        #[arg(long, conflicts_with = "jpeg_only")]
56        tiff_only: bool,
57
58        /// Only show JPEG stacks
59        #[arg(long, conflicts_with = "tiff_only")]
60        jpeg_only: bool,
61
62        /// Only show stacks with back scans
63        #[arg(long)]
64        with_back: bool,
65
66        /// Recurse into subdirectories
67        #[arg(long, short)]
68        recursive: bool,
69
70        /// Maximum number of stacks to return per page (0 = no pagination)
71        #[arg(long, default_value_t = 0)]
72        limit: usize,
73
74        /// Number of stacks to skip (0-based offset)
75        #[arg(long, default_value_t = 0)]
76        offset: usize,
77
78        /// FastFoto scanner profile (controls _a classification).
79        /// "auto" uses pixel analysis (disk I/O), others are instant.
80        #[arg(long, value_enum, default_value_t = CliScannerProfile::Auto)]
81        profile: CliScannerProfile,
82    },
83
84    /// Search photo stacks by metadata
85    #[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 containing FastFoto scans
92        directory: PathBuf,
93
94        /// Text to search for in IDs and metadata
95        query: String,
96
97        /// Filter by EXIF tag (format: KEY=VALUE)
98        #[arg(long = "exif", value_parser = parse_key_value)]
99        exif_filters: Vec<(String, String)>,
100
101        /// Filter by custom tag (format: KEY=VALUE)
102        #[arg(long = "tag", value_parser = parse_key_value)]
103        tag_filters: Vec<(String, String)>,
104
105        /// Only show stacks with back scans
106        #[arg(long)]
107        has_back: bool,
108
109        /// Only show stacks with enhanced scans
110        #[arg(long)]
111        has_enhanced: bool,
112
113        /// Only include stacks with these IDs (comma-separated or repeated)
114        #[arg(long = "id", value_delimiter = ',')]
115        stack_ids: Vec<String>,
116
117        /// Output format
118        #[arg(long, short, value_enum, default_value_t = OutputFormat::Table)]
119        format: OutputFormat,
120
121        /// Maximum number of stacks to return per page (0 = no pagination)
122        #[arg(long, default_value_t = 0)]
123        limit: usize,
124
125        /// Number of stacks to skip (0-based offset)
126        #[arg(long, default_value_t = 0)]
127        offset: usize,
128    },
129
130    /// Show detailed information about a specific stack
131    #[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 containing FastFoto scans
138        directory: PathBuf,
139
140        /// Stack ID (base filename without suffix or extension)
141        stack_id: String,
142
143        /// Output format
144        #[arg(long, short, value_enum, default_value_t = OutputFormat::Table)]
145        format: OutputFormat,
146    },
147
148    /// Read or write metadata for a stack
149    #[command(subcommand)]
150    Metadata(MetadataCommand),
151
152    /// Export stack data as JSON
153    #[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 containing FastFoto scans
157        directory: PathBuf,
158
159        /// Output file (default: stdout)
160        #[arg(long, short)]
161        output: Option<PathBuf>,
162    },
163
164    /// Rotate images in a photo stack
165    #[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 containing FastFoto scans
173        directory: PathBuf,
174
175        /// Stack ID (base filename without suffix or extension)
176        stack_id: String,
177
178        /// Rotation in degrees (90, -90, 180, -180)
179        #[arg(long, short, allow_hyphen_values = true)]
180        degrees: i32,
181
182        /// Which images to rotate: all, front, or back
183        #[arg(long, short, value_enum, default_value_t = CliRotationTarget::All)]
184        target: CliRotationTarget,
185
186        /// Output format
187        #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
188        format: OutputFormat,
189    },
190}
191
192#[derive(Subcommand)]
193pub enum MetadataCommand {
194    /// Read metadata for a stack
195    #[command(
196        long_about = "Display all metadata for a photo stack including EXIF, XMP, and custom tags."
197    )]
198    Read {
199        /// Directory containing FastFoto scans
200        directory: PathBuf,
201
202        /// Stack ID
203        stack_id: String,
204
205        /// Output format
206        #[arg(long, short, value_enum, default_value_t = OutputFormat::Table)]
207        format: OutputFormat,
208    },
209
210    /// Write metadata tags to a stack
211    #[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 containing FastFoto scans
216        directory: PathBuf,
217
218        /// Stack ID
219        stack_id: String,
220
221        /// Tags to write (format: KEY=VALUE)
222        #[arg(long = "tag", required = true, value_parser = parse_key_value)]
223        tags: Vec<(String, String)>,
224    },
225
226    /// Delete metadata tags from a stack
227    #[command(long_about = "Remove custom tags from a photo stack's XMP sidecar file.")]
228    Delete {
229        /// Directory containing FastFoto scans
230        directory: PathBuf,
231
232        /// Stack ID
233        stack_id: String,
234
235        /// Tag keys to delete
236        #[arg(long = "tag", required = true)]
237        tags: Vec<String>,
238    },
239}
240
241#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
242pub enum OutputFormat {
243    /// Human-readable table with unicode box-drawing
244    Table,
245    /// JSON output
246    Json,
247    /// Comma-separated values
248    Csv,
249}
250
251/// CLI-facing rotation target (maps to core `RotationTarget`).
252#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
253pub enum CliRotationTarget {
254    /// Rotate all images (original + enhanced + back)
255    All,
256    /// Rotate front-side images only (original + enhanced)
257    Front,
258    /// Rotate back-side image only
259    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/// CLI-facing scanner profile (maps to core `ScannerProfile`).
273#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
274pub enum CliScannerProfile {
275    /// Unknown config — pixel analysis for ambiguous _a (disk I/O)
276    Auto,
277    /// Enhanced + back both enabled (no I/O)
278    EnhancedAndBack,
279    /// Enhanced only, no back (no I/O)
280    EnhancedOnly,
281    /// Original only, no _a or _b (no I/O)
282    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
296// Exit codes
297pub const EXIT_SUCCESS: i32 = 0;
298pub const EXIT_ERROR: i32 = 1;
299pub const EXIT_NOT_FOUND: i32 = 2;
300
301/// Parse KEY=VALUE format for tag filters
302pub 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
310/// Convert a PhotoStack to a JSON-serializable value.
311async 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
333/// Convert a slice of PhotoStacks to a JSON-serializable array.
334async 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
342/// Run the CLI with parsed arguments, writing output to `out` and errors to `err`.
343/// Returns the exit code.
344pub 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/// Scan command implementation
456#[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    // Auto-enable metadata loading when show_metadata is requested
486    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    // Apply pagination if limit > 0
531    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/// Search command implementation
561#[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    // Build search query
596    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    // Apply pagination if limit > 0
615    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
658/// Resolve a stack identifier — try as opaque ID first, then fall back to
659/// matching by display name. This lets users pass either the hash-based ID
660/// or the human-readable stem name on the command line.
661async 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    // Try by exact ID first
670    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    // Fall back to name matching via text query
677    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    // If still not found, query all and search manually
684    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
697/// Info command implementation
698pub 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
749/// Metadata read command
750pub 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
800/// Metadata write command
801pub 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    // Use the stack's MetadataRef to write
836    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
845/// Metadata delete command
846pub 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    // Verify stack exists
863    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    // Open sidecar and delete tags
871    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
884/// Export command implementation
885pub 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
938/// Rotate command implementation
939pub 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    // Resolve the stack ID (supports both opaque IDs and display names)
969    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    // Perform rotation on each ImageRef based on target
982    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
1025// ============================================================================
1026// Output formatting functions
1027// ============================================================================
1028
1029/// Output stacks in the requested format
1030pub 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
1054/// Output stacks as table with unicode box-drawing
1055pub 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    // Calculate column widths
1074    let max_id = stacks
1075        .iter()
1076        .map(|s| s.name().len())
1077        .max()
1078        .unwrap_or(10)
1079        .max(10);
1080
1081    // Header
1082    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
1150/// Output stacks as CSV
1151pub 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
1206/// Output stack info as table
1207pub 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    // Files section
1219    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        // EXIF tags
1257        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        // XMP tags
1276        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        // Custom tags
1295        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
1320/// Output stack info as CSV
1321pub 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
1348/// Output metadata as table
1349pub 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
1435/// Output metadata as CSV
1436pub 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
1450/// Format file size in human-readable format
1451pub 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
1467/// Escape a string for CSV output
1468pub 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    /// A test metadata handle that returns pre-loaded metadata.
1487    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(); // trigger load so cached() returns Some
1510        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    /// Helper: create a temp dir inside target/test-tmp/ to avoid system
1523    /// tmp cleanup on CI runners that can cause flaky test failures.
1524    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    /// Copy testdata to a temp dir for write operations
1538    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                // Ignore NotFound — file may vanish between read_dir and copy
1544                // (e.g., probe files from concurrent is_writable() checks).
1545                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    // ======================== Pure function tests ========================
1599
1600    #[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    // ======================== Output formatting tests ========================
1673
1674    #[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        // Format is now "-" since format() was removed
1699        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        // Format is now "-" since format() was removed
1730        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        // Paths are no longer exposed; show_metadata shows "(original)", "(enhanced)", "(back)"
1740        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        // Format is no longer shown; verify metadata is present
1753        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        // No "Unknown" format — format column removed
1769    }
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        // Should not contain file lines
1793        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    // ======================== Command tests with testdata ========================
1856
1857    #[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        // Should be valid JSON
1907        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        // Should only contain JPEG stacks
1959        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        // Paths no longer shown; show_metadata shows "(original)", "(enhanced)", or "(back)"
2038        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        // LocalRepository::scan may return an error for nonexistent dirs
2092        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        // First write a tag
2476        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        // Then delete it
2489        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    // ======================== Output format for various stack types ========================
2574
2575    #[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        // Format is now "-" since format() was removed
2582        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        // Format is now "-" since format() was removed
2602        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        // Format is now always "-"
2612        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    // ======================== run_cli dispatch tests ========================
2641
2642    #[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        // Write first
2737        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    // ======================== XMP and custom tag coverage ========================
2775
2776    #[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    // ======================== Error path coverage ========================
2939
2940    #[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        // May succeed with 0 results or fail depending on OS
2960        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        // Trigger a generic (non-NotFound) error by using a path that exists but isn't a valid repo
2980        let mut out = Vec::new();
2981        let mut err = Vec::new();
2982        // This will produce either NotFound or another error type depending on OS
2983        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    // ── Rotate command tests ───────────────────────────────────────────────
3026
3027    /// Create a real JPEG file with known dimensions using the `image` crate.
3028    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}