use std::io::Write;
use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand, ValueEnum};
use photostax_core::backends::local::LocalRepository;
use photostax_core::photo_stack::{Metadata, PhotoStack, Rotation, RotationTarget, ScannerProfile};
use photostax_core::scanner::ScannerConfig;
use photostax_core::search::{paginate_stacks, PaginationParams, SearchQuery};
use photostax_core::stack_manager::StackManager;
#[derive(Parser)]
#[command(name = "photostax-cli")]
#[command(author, version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
#[command(
long_about = "Scan a directory for Epson FastFoto photo stacks and display them.\n\n\
FastFoto creates files with naming convention:\n \
- <name>.jpg/.tif Original front scan\n \
- <name>_a.jpg/.tif Enhanced (color-corrected)\n \
- <name>_b.jpg/.tif Back of photo\n\n\
These are grouped into 'stacks' for unified management."
)]
Scan {
directory: PathBuf,
#[arg(long, short, value_enum, default_value_t = OutputFormat::Table)]
format: OutputFormat,
#[arg(long)]
show_metadata: bool,
#[arg(long, short = 'm')]
metadata: bool,
#[arg(long, conflicts_with = "jpeg_only")]
tiff_only: bool,
#[arg(long, conflicts_with = "tiff_only")]
jpeg_only: bool,
#[arg(long)]
with_back: bool,
#[arg(long, short)]
recursive: bool,
#[arg(long, default_value_t = 0)]
limit: usize,
#[arg(long, default_value_t = 0)]
offset: usize,
#[arg(long, value_enum, default_value_t = CliScannerProfile::Auto)]
profile: CliScannerProfile,
},
#[command(
long_about = "Search photo stacks by text query and metadata filters.\n\n\
The text query searches across stack IDs and all metadata values.\n\
Additional filters can narrow results to specific EXIF or custom tags."
)]
Search {
directory: PathBuf,
query: String,
#[arg(long = "exif", value_parser = parse_key_value)]
exif_filters: Vec<(String, String)>,
#[arg(long = "tag", value_parser = parse_key_value)]
tag_filters: Vec<(String, String)>,
#[arg(long)]
has_back: bool,
#[arg(long)]
has_enhanced: bool,
#[arg(long = "id", value_delimiter = ',')]
stack_ids: Vec<String>,
#[arg(long, short, value_enum, default_value_t = OutputFormat::Table)]
format: OutputFormat,
#[arg(long, default_value_t = 0)]
limit: usize,
#[arg(long, default_value_t = 0)]
offset: usize,
},
#[command(
long_about = "Display comprehensive information about a single photo stack.\n\n\
Shows all file paths, file sizes, and complete metadata including\n\
EXIF tags, XMP tags, and custom tags from the XMP sidecar file."
)]
Info {
directory: PathBuf,
stack_id: String,
#[arg(long, short, value_enum, default_value_t = OutputFormat::Table)]
format: OutputFormat,
},
#[command(subcommand)]
Metadata(MetadataCommand),
#[command(long_about = "Export all photo stacks with full metadata as JSON.\n\n\
Output can be written to a file or stdout for piping to other tools.")]
Export {
directory: PathBuf,
#[arg(long, short)]
output: Option<PathBuf>,
},
#[command(
long_about = "Rotate image files in a photo stack by the given angle.\n\n\
Pixel data is re-encoded on disk (lossy for JPEG). Accepted degree\n\
values: 90 (clockwise), -90 (counter-clockwise), 180, -180.\n\n\
Use --target to rotate only front or back images."
)]
Rotate {
directory: PathBuf,
stack_id: String,
#[arg(long, short, allow_hyphen_values = true)]
degrees: i32,
#[arg(long, short, value_enum, default_value_t = CliRotationTarget::All)]
target: CliRotationTarget,
#[arg(long, value_enum, default_value_t = OutputFormat::Table)]
format: OutputFormat,
},
}
#[derive(Subcommand)]
pub enum MetadataCommand {
#[command(
long_about = "Display all metadata for a photo stack including EXIF, XMP, and custom tags."
)]
Read {
directory: PathBuf,
stack_id: String,
#[arg(long, short, value_enum, default_value_t = OutputFormat::Table)]
format: OutputFormat,
},
#[command(long_about = "Add or update custom tags for a photo stack.\n\n\
Tags are written to an XMP sidecar file (.xmp) alongside the images\n\
and do not modify the original image files.")]
Write {
directory: PathBuf,
stack_id: String,
#[arg(long = "tag", required = true, value_parser = parse_key_value)]
tags: Vec<(String, String)>,
},
#[command(long_about = "Remove custom tags from a photo stack's XMP sidecar file.")]
Delete {
directory: PathBuf,
stack_id: String,
#[arg(long = "tag", required = true)]
tags: Vec<String>,
},
}
#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
Table,
Json,
Csv,
}
#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum CliRotationTarget {
All,
Front,
Back,
}
impl From<CliRotationTarget> for RotationTarget {
fn from(t: CliRotationTarget) -> Self {
match t {
CliRotationTarget::All => RotationTarget::All,
CliRotationTarget::Front => RotationTarget::Front,
CliRotationTarget::Back => RotationTarget::Back,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum CliScannerProfile {
Auto,
EnhancedAndBack,
EnhancedOnly,
OriginalOnly,
}
impl From<CliScannerProfile> for ScannerProfile {
fn from(p: CliScannerProfile) -> Self {
match p {
CliScannerProfile::Auto => ScannerProfile::Auto,
CliScannerProfile::EnhancedAndBack => ScannerProfile::EnhancedAndBack,
CliScannerProfile::EnhancedOnly => ScannerProfile::EnhancedOnly,
CliScannerProfile::OriginalOnly => ScannerProfile::OriginalOnly,
}
}
}
pub const EXIT_SUCCESS: i32 = 0;
pub const EXIT_ERROR: i32 = 1;
pub const EXIT_NOT_FOUND: i32 = 2;
pub fn parse_key_value(s: &str) -> Result<(String, String), String> {
let parts: Vec<&str> = s.splitn(2, '=').collect();
if parts.len() != 2 {
return Err(format!("Invalid format '{s}', expected KEY=VALUE"));
}
Ok((parts[0].to_string(), parts[1].to_string()))
}
fn stack_to_json(stack: &PhotoStack) -> serde_json::Value {
let metadata = stack.metadata.cached().map_or_else(
|| serde_json::json!({"exif_tags": {}, "xmp_tags": {}, "custom_tags": {}}),
|m| {
serde_json::json!({
"exif_tags": m.exif_tags,
"xmp_tags": m.xmp_tags,
"custom_tags": m.custom_tags,
})
},
);
serde_json::json!({
"id": stack.id,
"name": stack.name,
"folder": stack.folder,
"location": stack.location,
"original": stack.original.is_present(),
"enhanced": stack.enhanced.is_present(),
"back": stack.back.is_present(),
"image_count": stack.image_count(),
"metadata": metadata,
})
}
fn stacks_to_json(stacks: &[PhotoStack]) -> serde_json::Value {
serde_json::Value::Array(stacks.iter().map(stack_to_json).collect())
}
pub fn run_cli(cli: &Cli, out: &mut dyn Write, err: &mut dyn Write) -> i32 {
match &cli.command {
Commands::Scan {
directory,
format,
show_metadata,
metadata,
tiff_only,
jpeg_only,
with_back,
recursive,
limit,
offset,
profile,
} => cmd_scan(
out,
err,
directory,
*format,
*show_metadata,
*metadata,
*tiff_only,
*jpeg_only,
*with_back,
*recursive,
*limit,
*offset,
(*profile).into(),
),
Commands::Search {
directory,
query,
exif_filters,
tag_filters,
has_back,
has_enhanced,
stack_ids,
format,
limit,
offset,
} => cmd_search(
out,
err,
directory,
query,
exif_filters,
tag_filters,
*has_back,
*has_enhanced,
stack_ids,
*format,
*limit,
*offset,
),
Commands::Info {
directory,
stack_id,
format,
} => cmd_info(out, err, directory, stack_id, *format),
Commands::Metadata(MetadataCommand::Read {
directory,
stack_id,
format,
}) => cmd_metadata_read(out, err, directory, stack_id, *format),
Commands::Metadata(MetadataCommand::Write {
directory,
stack_id,
tags,
}) => cmd_metadata_write(out, err, directory, stack_id, tags),
Commands::Metadata(MetadataCommand::Delete {
directory,
stack_id,
tags,
}) => cmd_metadata_delete(out, err, directory, stack_id, tags),
Commands::Export { directory, output } => {
cmd_export(out, err, directory, output.as_deref())
}
Commands::Rotate {
directory,
stack_id,
degrees,
target,
format,
} => cmd_rotate(
out,
err,
directory,
stack_id,
*degrees,
(*target).into(),
*format,
),
}
}
#[allow(clippy::too_many_arguments)]
pub fn cmd_scan(
out: &mut dyn Write,
err: &mut dyn Write,
directory: &PathBuf,
format: OutputFormat,
show_metadata: bool,
metadata: bool,
_tiff_only: bool,
_jpeg_only: bool,
with_back: bool,
recursive: bool,
limit: usize,
offset: usize,
profile: ScannerProfile,
) -> i32 {
let config = ScannerConfig {
recursive,
..ScannerConfig::default()
};
let repo = LocalRepository::with_config(directory, config);
let mut mgr = match StackManager::single(Box::new(repo), profile) {
Ok(m) => m,
Err(e) => {
let _ = writeln!(err, "Error: {e}");
return EXIT_ERROR;
}
};
let load_metadata = metadata || show_metadata;
let mut progress_cb = |p: &photostax_core::photo_stack::ScanProgress| {
let phase = match p.phase {
photostax_core::photo_stack::ScanPhase::Scanning => "Scanning",
photostax_core::photo_stack::ScanPhase::Classifying => "Classifying",
photostax_core::photo_stack::ScanPhase::Complete => "Complete",
};
let _ = write!(err, "\r{phase}: {}/{}", p.current, p.total);
if p.phase == photostax_core::photo_stack::ScanPhase::Complete {
let _ = writeln!(err);
}
};
if load_metadata {
if let Err(e) = mgr.rescan(None) {
let _ = writeln!(err, "Error scanning {}: {e}", directory.display());
return EXIT_ERROR;
}
let ids: Vec<String> = mgr.stacks().iter().map(|s| s.id.clone()).collect();
for id in &ids {
if let Some(s) = mgr.get_stack_mut(id) {
let _ = s.metadata.read();
}
}
} else if let Err(e) = mgr.query(None, None, Some(&mut progress_cb)) {
let _ = writeln!(err, "Error scanning {}: {e}", directory.display());
return EXIT_ERROR;
}
let stacks: Vec<PhotoStack> = mgr
.query(None, None, None)
.expect("cache already populated")
.all_stacks()
.to_vec();
let filtered: Vec<_> = stacks
.into_iter()
.filter(|s| {
if with_back && !s.back.is_present() {
return false;
}
true
})
.collect();
if limit > 0 {
let paginated = paginate_stacks(&filtered, &PaginationParams { offset, limit });
output_stacks(out, &paginated.items, format, show_metadata, directory);
if format == OutputFormat::Json {
let _ = writeln!(
out,
"{{\"pagination\": {{\"total_count\": {}, \"offset\": {}, \"limit\": {}, \"has_more\": {}}}}}",
paginated.total_count, paginated.offset, paginated.limit, paginated.has_more
);
} else {
let _ = writeln!(
out,
"\nShowing {}-{} of {} stacks{}",
offset + 1,
(offset + paginated.items.len()).min(paginated.total_count),
paginated.total_count,
if paginated.has_more {
" (more available)"
} else {
""
}
);
}
} else {
output_stacks(out, &filtered, format, show_metadata, directory);
}
EXIT_SUCCESS
}
#[allow(clippy::too_many_arguments)]
pub fn cmd_search(
out: &mut dyn Write,
err: &mut dyn Write,
directory: &PathBuf,
query: &str,
exif_filters: &[(String, String)],
tag_filters: &[(String, String)],
has_back: bool,
has_enhanced: bool,
stack_ids: &[String],
format: OutputFormat,
limit: usize,
offset: usize,
) -> i32 {
let repo = LocalRepository::new(directory);
let mut mgr = match StackManager::single(Box::new(repo), ScannerProfile::Auto) {
Ok(m) => m,
Err(e) => {
let _ = writeln!(err, "Error: {e}");
return EXIT_ERROR;
}
};
if let Err(e) = mgr.rescan(None) {
let _ = writeln!(err, "Error scanning {}: {e}", directory.display());
return EXIT_ERROR;
}
let ids: Vec<String> = mgr.stacks().iter().map(|s| s.id.clone()).collect();
for id in &ids {
if let Some(s) = mgr.get_stack_mut(id) {
let _ = s.metadata.read();
}
}
let mut search = SearchQuery::new().with_text(query);
for (key, value) in exif_filters {
search = search.with_exif_filter(key, value);
}
for (key, value) in tag_filters {
search = search.with_custom_filter(key, value);
}
if has_back {
search = search.with_has_back(true);
}
if has_enhanced {
search = search.with_has_enhanced(true);
}
if !stack_ids.is_empty() {
search = search.with_ids(stack_ids.to_vec());
}
if limit > 0 {
let snapshot = match mgr.query(Some(&search), None, None) {
Ok(snap) => snap,
Err(e) => {
let _ = writeln!(err, "Error querying: {e}");
return EXIT_ERROR;
}
};
let paginated = snapshot.snapshot().get_page(offset, limit);
output_stacks(out, &paginated.items, format, false, directory);
if format == OutputFormat::Json {
let _ = writeln!(
out,
"{{\"pagination\": {{\"total_count\": {}, \"offset\": {}, \"limit\": {}, \"has_more\": {}}}}}",
paginated.total_count, paginated.offset, paginated.limit, paginated.has_more
);
} else {
let _ = writeln!(
out,
"\nShowing {}-{} of {} results{}",
offset + 1,
(offset + paginated.items.len()).min(paginated.total_count),
paginated.total_count,
if paginated.has_more {
" (more available)"
} else {
""
}
);
}
} else {
let results = match mgr.query(Some(&search), None, None) {
Ok(snap) => snap,
Err(e) => {
let _ = writeln!(err, "Error querying: {e}");
return EXIT_ERROR;
}
};
output_stacks(out, results.all_stacks(), format, false, directory);
}
EXIT_SUCCESS
}
fn resolve_stack(
mgr: &mut StackManager,
id_or_name: &str,
) -> Result<PhotoStack, photostax_core::repository::RepositoryError> {
if mgr.is_empty() {
mgr.rescan(None)
.map_err(|e| photostax_core::repository::RepositoryError::Other(e.to_string()))?;
}
if let Some(s) = mgr.get_stack(id_or_name) {
return Ok(s.clone());
}
let query = SearchQuery::new().with_text(id_or_name);
let results = mgr
.query(Some(&query), None, None)
.map_err(|e| photostax_core::repository::RepositoryError::Other(e.to_string()))?;
let found = results
.all_stacks()
.iter()
.find(|s| s.name == id_or_name)
.cloned();
found.ok_or_else(|| {
photostax_core::repository::RepositoryError::NotFound(id_or_name.to_string())
})
}
pub fn cmd_info(
out: &mut dyn Write,
err: &mut dyn Write,
directory: &PathBuf,
stack_id: &str,
format: OutputFormat,
) -> i32 {
let repo = LocalRepository::new(directory);
let mut mgr = match StackManager::single(Box::new(repo), ScannerProfile::Auto) {
Ok(m) => m,
Err(e) => {
let _ = writeln!(err, "Error: {e}");
return EXIT_ERROR;
}
};
let mut stack = match resolve_stack(&mut mgr, stack_id) {
Ok(s) => s,
Err(photostax_core::repository::RepositoryError::NotFound(_)) => {
let _ = writeln!(err, "Stack not found: {stack_id}");
return EXIT_NOT_FOUND;
}
Err(e) => {
let _ = writeln!(err, "Error: {e}");
return EXIT_ERROR;
}
};
if let Err(e) = stack.metadata.read() {
let _ = writeln!(err, "Error loading metadata: {e}");
return EXIT_ERROR;
}
match format {
OutputFormat::Json => {
let _ = writeln!(
out,
"{}",
serde_json::to_string_pretty(&stack_to_json(&stack)).unwrap()
);
}
OutputFormat::Csv => {
output_info_csv(out, &stack);
}
OutputFormat::Table => {
output_info_table(out, &stack);
}
}
EXIT_SUCCESS
}
pub fn cmd_metadata_read(
out: &mut dyn Write,
err: &mut dyn Write,
directory: &PathBuf,
stack_id: &str,
format: OutputFormat,
) -> i32 {
let repo = LocalRepository::new(directory);
let mut mgr = match StackManager::single(Box::new(repo), ScannerProfile::Auto) {
Ok(m) => m,
Err(e) => {
let _ = writeln!(err, "Error: {e}");
return EXIT_ERROR;
}
};
let mut stack = match resolve_stack(&mut mgr, stack_id) {
Ok(s) => s,
Err(photostax_core::repository::RepositoryError::NotFound(_)) => {
let _ = writeln!(err, "Stack not found: {stack_id}");
return EXIT_NOT_FOUND;
}
Err(e) => {
let _ = writeln!(err, "Error: {e}");
return EXIT_ERROR;
}
};
let metadata = match stack.metadata.read() {
Ok(m) => m.clone(),
Err(e) => {
let _ = writeln!(err, "Error loading metadata: {e}");
return EXIT_ERROR;
}
};
match format {
OutputFormat::Json => {
let _ = writeln!(out, "{}", serde_json::to_string_pretty(&metadata).unwrap());
}
OutputFormat::Csv => {
output_metadata_csv(out, &metadata);
}
OutputFormat::Table => {
output_metadata_table(out, &metadata);
}
}
EXIT_SUCCESS
}
pub fn cmd_metadata_write(
out: &mut dyn Write,
err: &mut dyn Write,
directory: &PathBuf,
stack_id: &str,
tags: &[(String, String)],
) -> i32 {
let repo = LocalRepository::new(directory);
let mut mgr = match StackManager::single(Box::new(repo), ScannerProfile::Auto) {
Ok(m) => m,
Err(e) => {
let _ = writeln!(err, "Error: {e}");
return EXIT_ERROR;
}
};
let stack = match resolve_stack(&mut mgr, stack_id) {
Ok(s) => s,
Err(photostax_core::repository::RepositoryError::NotFound(_)) => {
let _ = writeln!(err, "Stack not found: {stack_id}");
return EXIT_NOT_FOUND;
}
Err(e) => {
let _ = writeln!(err, "Error: {e}");
return EXIT_ERROR;
}
};
let mut new_tags = Metadata::default();
for (key, value) in tags {
new_tags
.custom_tags
.insert(key.clone(), serde_json::Value::String(value.clone()));
}
if let Err(e) = stack.metadata.write(&new_tags) {
let _ = writeln!(err, "Error writing metadata: {e}");
return EXIT_ERROR;
}
let _ = writeln!(out, "Wrote {} tag(s) to {stack_id}", tags.len());
EXIT_SUCCESS
}
pub fn cmd_metadata_delete(
out: &mut dyn Write,
err: &mut dyn Write,
directory: &PathBuf,
stack_id: &str,
tags: &[String],
) -> i32 {
let repo = LocalRepository::new(directory);
let mut mgr = match StackManager::single(Box::new(repo), ScannerProfile::Auto) {
Ok(m) => m,
Err(e) => {
let _ = writeln!(err, "Error: {e}");
return EXIT_ERROR;
}
};
if let Err(photostax_core::repository::RepositoryError::NotFound(_)) =
resolve_stack(&mut mgr, stack_id)
{
let _ = writeln!(err, "Stack not found: {stack_id}");
return EXIT_NOT_FOUND;
}
for tag in tags {
if let Err(e) =
photostax_core::metadata::sidecar::remove_custom_tag(directory, stack_id, tag)
{
let _ = writeln!(err, "Error deleting tag '{tag}': {e}");
return EXIT_ERROR;
}
}
let _ = writeln!(out, "Deleted {} tag(s) from {stack_id}", tags.len());
EXIT_SUCCESS
}
pub fn cmd_export(
out: &mut dyn Write,
err: &mut dyn Write,
directory: &PathBuf,
output: Option<&Path>,
) -> i32 {
let repo = LocalRepository::new(directory);
let mut mgr = match StackManager::single(Box::new(repo), ScannerProfile::Auto) {
Ok(m) => m,
Err(e) => {
let _ = writeln!(err, "Error: {e}");
return EXIT_ERROR;
}
};
if let Err(e) = mgr.rescan(None) {
let _ = writeln!(err, "Error scanning {}: {e}", directory.display());
return EXIT_ERROR;
}
let ids: Vec<String> = mgr.stacks().iter().map(|s| s.id.clone()).collect();
for id in &ids {
if let Some(s) = mgr.get_stack_mut(id) {
let _ = s.metadata.read();
}
}
let stacks: Vec<PhotoStack> = mgr
.query(None, None, None)
.expect("cache already populated")
.all_stacks()
.to_vec();
let json = serde_json::to_string_pretty(&stacks_to_json(&stacks)).unwrap();
match output {
Some(path) => {
if let Err(e) = std::fs::write(path, &json) {
let _ = writeln!(err, "Error writing to {}: {e}", path.display());
return EXIT_ERROR;
}
let _ = writeln!(
out,
"Exported {} stack(s) to {}",
stacks.len(),
path.display()
);
}
None => {
let _ = writeln!(out, "{json}");
}
}
EXIT_SUCCESS
}
pub fn cmd_rotate(
out: &mut dyn Write,
err: &mut dyn Write,
directory: &PathBuf,
stack_id: &str,
degrees: i32,
target: RotationTarget,
format: OutputFormat,
) -> i32 {
let rotation = match Rotation::from_degrees(degrees) {
Some(r) => r,
None => {
let _ = writeln!(
err,
"Invalid rotation: {degrees}°. Accepted values: 90, -90, 180, -180"
);
return EXIT_ERROR;
}
};
let repo = LocalRepository::new(directory);
let mut mgr = match StackManager::single(Box::new(repo), ScannerProfile::Auto) {
Ok(m) => m,
Err(e) => {
let _ = writeln!(err, "Error: {e}");
return EXIT_ERROR;
}
};
let stack = match resolve_stack(&mut mgr, stack_id) {
Ok(s) => s,
Err(photostax_core::repository::RepositoryError::NotFound(_)) => {
let _ = writeln!(err, "Stack not found: {stack_id}");
return EXIT_NOT_FOUND;
}
Err(e) => {
let _ = writeln!(err, "Error: {e}");
return EXIT_ERROR;
}
};
let rotate_front = matches!(target, RotationTarget::All | RotationTarget::Front);
let rotate_back = matches!(target, RotationTarget::All | RotationTarget::Back);
if rotate_front {
if stack.original.is_present() {
if let Err(e) = stack.original.rotate(rotation) {
let _ = writeln!(err, "Error rotating original: {e}");
return EXIT_ERROR;
}
}
if stack.enhanced.is_present() {
if let Err(e) = stack.enhanced.rotate(rotation) {
let _ = writeln!(err, "Error rotating enhanced: {e}");
return EXIT_ERROR;
}
}
}
if rotate_back && stack.back.is_present() {
if let Err(e) = stack.back.rotate(rotation) {
let _ = writeln!(err, "Error rotating back: {e}");
return EXIT_ERROR;
}
}
let _ = writeln!(
out,
"Rotated {} image(s) in stack '{}' by {}°",
stack.image_count(),
stack.name,
rotation.as_degrees()
);
if format == OutputFormat::Json {
let _ = writeln!(
out,
"{}",
serde_json::to_string_pretty(&stack_to_json(&stack)).unwrap()
);
}
EXIT_SUCCESS
}
pub fn output_stacks(
out: &mut dyn Write,
stacks: &[PhotoStack],
format: OutputFormat,
show_metadata: bool,
dir: &Path,
) {
match format {
OutputFormat::Json => {
let _ = writeln!(
out,
"{}",
serde_json::to_string_pretty(&stacks_to_json(stacks)).unwrap()
);
}
OutputFormat::Csv => {
output_stacks_csv(out, stacks, show_metadata);
}
OutputFormat::Table => {
output_stacks_table(out, stacks, show_metadata, dir);
}
}
}
pub fn output_stacks_table(
out: &mut dyn Write,
stacks: &[PhotoStack],
show_metadata: bool,
dir: &Path,
) {
let _ = writeln!(
out,
"Found {} photo stack(s) in {}",
stacks.len(),
dir.display()
);
let _ = writeln!(out);
if stacks.is_empty() {
return;
}
let max_id = stacks
.iter()
.map(|s| s.name.len())
.max()
.unwrap_or(10)
.max(10);
let _ = writeln!(
out,
"┌─{}─┬─────────┬──────────┬──────┬──────┬────────┐",
"─".repeat(max_id)
);
let _ = writeln!(
out,
"│ {:<max_id$} │ Format │ Original │ Enh. │ Back │ Tags │",
"ID"
);
let _ = writeln!(
out,
"├─{}─┼─────────┼──────────┼──────┼──────┼────────┤",
"─".repeat(max_id)
);
for stack in stacks {
let orig = if stack.original.is_present() {
"✓"
} else {
"-"
};
let enh = if stack.enhanced.is_present() {
"✓"
} else {
"-"
};
let back = if stack.back.is_present() { "✓" } else { "-" };
let tags = stack
.metadata
.cached()
.map_or(0, |m| m.exif_tags.len() + m.custom_tags.len());
let _ = writeln!(
out,
"│ {:<max_id$} │ {:<7} │ {:<5} │ {:<3} │ {:<3} │ {:>6} │",
stack.name, "-", orig, enh, back, tags
);
if show_metadata {
if stack.original.is_present() {
let _ = writeln!(out, "│ {:<max_id$} │ │ (original)", "");
}
if stack.enhanced.is_present() {
let _ = writeln!(out, "│ {:<max_id$} │ │ (enhanced)", "");
}
if stack.back.is_present() {
let _ = writeln!(out, "│ {:<max_id$} │ │ (back)", "");
}
}
}
let _ = writeln!(
out,
"└─{}─┴─────────┴──────────┴──────┴──────┴────────┘",
"─".repeat(max_id)
);
}
pub fn output_stacks_csv(out: &mut dyn Write, stacks: &[PhotoStack], show_metadata: bool) {
if show_metadata {
let _ = writeln!(
out,
"id,format,original,enhanced,back,exif_tags,custom_tags"
);
} else {
let _ = writeln!(
out,
"id,format,has_original,has_enhanced,has_back,tag_count"
);
}
for stack in stacks {
if show_metadata {
let _ = writeln!(
out,
"{},-,{},{},{},{},{}",
stack.name,
if stack.original.is_present() {
"present"
} else {
""
},
if stack.enhanced.is_present() {
"present"
} else {
""
},
if stack.back.is_present() {
"present"
} else {
""
},
stack.metadata.cached().map_or(0, |m| m.exif_tags.len()),
stack.metadata.cached().map_or(0, |m| m.custom_tags.len())
);
} else {
let tags = stack
.metadata
.cached()
.map_or(0, |m| m.exif_tags.len() + m.custom_tags.len());
let _ = writeln!(
out,
"{},-,{},{},{},{}",
stack.name,
stack.original.is_present(),
stack.enhanced.is_present(),
stack.back.is_present(),
tags
);
}
}
}
pub fn output_info_table(out: &mut dyn Write, stack: &PhotoStack) {
let _ = writeln!(
out,
"┌──────────────────────────────────────────────────────────────────┐"
);
let _ = writeln!(out, "│ Stack: {:<57} │", stack.name);
let _ = writeln!(
out,
"├──────────────────────────────────────────────────────────────────┤"
);
let _ = writeln!(
out,
"├──────────────────────────────────────────────────────────────────┤"
);
let _ = writeln!(
out,
"│ Files: │"
);
if stack.original.is_present() {
let size = stack.original.size().unwrap_or(0);
let _ = writeln!(
out,
"│ Original: {:<40} ({:>8}) │",
"(present)",
format_size(size)
);
}
if stack.enhanced.is_present() {
let size = stack.enhanced.size().unwrap_or(0);
let _ = writeln!(
out,
"│ Enhanced: {:<40} ({:>8}) │",
"(present)",
format_size(size)
);
}
if stack.back.is_present() {
let size = stack.back.size().unwrap_or(0);
let _ = writeln!(
out,
"│ Back: {:<40} ({:>8}) │",
"(present)",
format_size(size)
);
}
if let Some(m) = stack.metadata.cached() {
if !m.exif_tags.is_empty() {
let _ = writeln!(
out,
"├──────────────────────────────────────────────────────────────────┤"
);
let _ = writeln!(out, "│ EXIF Tags ({}):", m.exif_tags.len());
let width = 62;
for (key, value) in &m.exif_tags {
let kv = format!("{}: {}", key, value);
let truncated = if kv.len() > width {
format!("{}...", &kv[..width - 3])
} else {
kv
};
let _ = writeln!(out, "│ {:<width$} │", truncated);
}
}
if !m.xmp_tags.is_empty() {
let _ = writeln!(
out,
"├──────────────────────────────────────────────────────────────────┤"
);
let _ = writeln!(out, "│ XMP Tags ({}):", m.xmp_tags.len());
let width = 62;
for (key, value) in &m.xmp_tags {
let kv = format!("{}: {}", key, value);
let truncated = if kv.len() > width {
format!("{}...", &kv[..width - 3])
} else {
kv
};
let _ = writeln!(out, "│ {:<width$} │", truncated);
}
}
if !m.custom_tags.is_empty() {
let _ = writeln!(
out,
"├──────────────────────────────────────────────────────────────────┤"
);
let _ = writeln!(out, "│ Custom Tags ({}):", m.custom_tags.len());
let width = 62;
for (key, value) in &m.custom_tags {
let kv = format!("{}: {}", key, value);
let truncated = if kv.len() > width {
format!("{}...", &kv[..width - 3])
} else {
kv
};
let _ = writeln!(out, "│ {:<width$} │", truncated);
}
}
}
let _ = writeln!(
out,
"└──────────────────────────────────────────────────────────────────┘"
);
}
pub fn output_info_csv(out: &mut dyn Write, stack: &PhotoStack) {
let _ = writeln!(out, "type,key,value");
let _ = writeln!(out, "id,,{}", stack.name);
if stack.original.is_present() {
let _ = writeln!(out, "file,original,present");
}
if stack.enhanced.is_present() {
let _ = writeln!(out, "file,enhanced,present");
}
if stack.back.is_present() {
let _ = writeln!(out, "file,back,present");
}
if let Some(m) = stack.metadata.cached() {
for (key, value) in &m.exif_tags {
let _ = writeln!(out, "exif,{},{}", key, escape_csv(value));
}
for (key, value) in &m.xmp_tags {
let _ = writeln!(out, "xmp,{},{}", key, escape_csv(value));
}
for (key, value) in &m.custom_tags {
let _ = writeln!(out, "custom,{},{}", key, escape_csv(&value.to_string()));
}
}
}
pub fn output_metadata_table(out: &mut dyn Write, metadata: &Metadata) {
let _ = writeln!(
out,
"┌──────────────────────────────────────────────────────────────────┐"
);
let _ = writeln!(
out,
"│ Metadata │"
);
let _ = writeln!(
out,
"├──────────────────────────────────────────────────────────────────┤"
);
let width = 62;
if !metadata.exif_tags.is_empty() {
let _ = writeln!(out, "│ EXIF Tags ({}):", metadata.exif_tags.len());
for (key, value) in &metadata.exif_tags {
let kv = format!("{}: {}", key, value);
let truncated = if kv.len() > width {
format!("{}...", &kv[..width - 3])
} else {
kv
};
let _ = writeln!(out, "│ {:<width$} │", truncated);
}
} else {
let _ = writeln!(
out,
"│ EXIF Tags: (none) │"
);
}
let _ = writeln!(
out,
"├──────────────────────────────────────────────────────────────────┤"
);
if !metadata.xmp_tags.is_empty() {
let _ = writeln!(out, "│ XMP Tags ({}):", metadata.xmp_tags.len());
for (key, value) in &metadata.xmp_tags {
let kv = format!("{}: {}", key, value);
let truncated = if kv.len() > width {
format!("{}...", &kv[..width - 3])
} else {
kv
};
let _ = writeln!(out, "│ {:<width$} │", truncated);
}
} else {
let _ = writeln!(
out,
"│ XMP Tags: (none) │"
);
}
let _ = writeln!(
out,
"├──────────────────────────────────────────────────────────────────┤"
);
if !metadata.custom_tags.is_empty() {
let _ = writeln!(out, "│ Custom Tags ({}):", metadata.custom_tags.len());
for (key, value) in &metadata.custom_tags {
let kv = format!("{}: {}", key, value);
let truncated = if kv.len() > width {
format!("{}...", &kv[..width - 3])
} else {
kv
};
let _ = writeln!(out, "│ {:<width$} │", truncated);
}
} else {
let _ = writeln!(
out,
"│ Custom Tags: (none) │"
);
}
let _ = writeln!(
out,
"└──────────────────────────────────────────────────────────────────┘"
);
}
pub fn output_metadata_csv(out: &mut dyn Write, metadata: &Metadata) {
let _ = writeln!(out, "type,key,value");
for (key, value) in &metadata.exif_tags {
let _ = writeln!(out, "exif,{},{}", key, escape_csv(value));
}
for (key, value) in &metadata.xmp_tags {
let _ = writeln!(out, "xmp,{},{}", key, escape_csv(value));
}
for (key, value) in &metadata.custom_tags {
let _ = writeln!(out, "custom,{},{}", key, escape_csv(&value.to_string()));
}
}
pub fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
pub fn escape_csv(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use photostax_core::backends::local_handles::LocalImageHandle;
use photostax_core::image_handle::ImageRef;
use photostax_core::metadata_handle::{MetadataHandle, MetadataRef};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
struct InlineMetadataHandle {
data: Metadata,
}
impl MetadataHandle for InlineMetadataHandle {
fn load(&self) -> Result<Metadata, photostax_core::repository::RepositoryError> {
Ok(self.data.clone())
}
fn write(&self, _: &Metadata) -> Result<(), photostax_core::repository::RepositoryError> {
Ok(())
}
fn is_valid(&self) -> bool {
true
}
}
fn make_image_ref(path: &str) -> ImageRef {
ImageRef::new(Arc::new(LocalImageHandle::new(path, 0)))
}
fn make_metadata_ref(metadata: Metadata) -> MetadataRef {
let handle = Arc::new(InlineMetadataHandle { data: metadata });
let mut mr = MetadataRef::new(handle);
let _ = mr.read(); mr
}
fn testdata_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("core")
.join("tests")
.join("testdata")
}
fn copy_testdata_to_tempdir() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
for entry in std::fs::read_dir(testdata_path()).unwrap() {
let entry = entry.unwrap();
if entry.file_type().unwrap().is_file() {
std::fs::copy(entry.path(), dir.path().join(entry.file_name())).unwrap();
}
}
dir
}
fn make_stack(id: &str) -> PhotoStack {
let mut stack = PhotoStack::new(id);
stack.original = make_image_ref(&format!("/photos/{id}.jpg"));
stack.enhanced = make_image_ref(&format!("/photos/{id}_a.jpg"));
stack.back = make_image_ref(&format!("/photos/{id}_b.jpg"));
stack
}
fn make_stack_with_metadata(id: &str) -> PhotoStack {
let mut exif_tags = HashMap::new();
exif_tags.insert("Make".to_string(), "EPSON".to_string());
exif_tags.insert("Model".to_string(), "FastFoto FF-680W".to_string());
let mut xmp_tags = HashMap::new();
xmp_tags.insert("Creator".to_string(), "Test User".to_string());
let mut custom_tags = HashMap::new();
custom_tags.insert(
"album".to_string(),
serde_json::Value::String("Family".to_string()),
);
let mut stack = PhotoStack::new(id);
stack.original = make_image_ref(&format!("/photos/{id}.jpg"));
stack.enhanced = make_image_ref(&format!("/photos/{id}_a.jpg"));
stack.metadata = make_metadata_ref(Metadata {
exif_tags,
xmp_tags,
custom_tags,
});
stack
}
fn make_tiff_stack(id: &str) -> PhotoStack {
let mut stack = PhotoStack::new(id);
stack.original = make_image_ref(&format!("/photos/{id}.tif"));
stack
}
fn make_empty_stack(id: &str) -> PhotoStack {
PhotoStack::new(id)
}
#[test]
fn test_parse_key_value_valid() {
let (k, v) = parse_key_value("Make=EPSON").unwrap();
assert_eq!(k, "Make");
assert_eq!(v, "EPSON");
}
#[test]
fn test_parse_key_value_with_equals_in_value() {
let (k, v) = parse_key_value("expr=a=b").unwrap();
assert_eq!(k, "expr");
assert_eq!(v, "a=b");
}
#[test]
fn test_parse_key_value_missing_equals() {
let result = parse_key_value("noequals");
assert!(result.is_err());
assert!(result.unwrap_err().contains("KEY=VALUE"));
}
#[test]
fn test_parse_key_value_empty_value() {
let (k, v) = parse_key_value("key=").unwrap();
assert_eq!(k, "key");
assert_eq!(v, "");
}
#[test]
fn test_format_size_bytes() {
assert_eq!(format_size(0), "0 B");
assert_eq!(format_size(512), "512 B");
assert_eq!(format_size(1023), "1023 B");
}
#[test]
fn test_format_size_kb() {
assert_eq!(format_size(1024), "1.0 KB");
assert_eq!(format_size(1536), "1.5 KB");
}
#[test]
fn test_format_size_mb() {
assert_eq!(format_size(1024 * 1024), "1.0 MB");
assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB");
}
#[test]
fn test_format_size_gb() {
assert_eq!(format_size(1024 * 1024 * 1024), "1.0 GB");
}
#[test]
fn test_escape_csv_plain() {
assert_eq!(escape_csv("hello"), "hello");
}
#[test]
fn test_escape_csv_with_comma() {
assert_eq!(escape_csv("hello,world"), "\"hello,world\"");
}
#[test]
fn test_escape_csv_with_quotes() {
assert_eq!(escape_csv("say \"hi\""), "\"say \"\"hi\"\"\"");
}
#[test]
fn test_escape_csv_with_newline() {
assert_eq!(escape_csv("line1\nline2"), "\"line1\nline2\"");
}
#[test]
fn test_output_stacks_json() {
let stacks = vec![make_stack("IMG_0001")];
let mut buf = Vec::new();
output_stacks(
&mut buf,
&stacks,
OutputFormat::Json,
false,
&PathBuf::from("/photos"),
);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("IMG_0001"));
assert!(output.contains("original"));
}
#[test]
fn test_output_stacks_csv_no_metadata() {
let stacks = vec![make_stack("IMG_0001"), make_tiff_stack("IMG_0002")];
let mut buf = Vec::new();
output_stacks_csv(&mut buf, &stacks, false);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("id,format,has_original"));
assert!(output.contains("IMG_0001,-,true,true,true"));
assert!(output.contains("IMG_0002,-,true,false,false"));
}
#[test]
fn test_output_stacks_csv_with_metadata() {
let stacks = vec![make_stack("IMG_0001")];
let mut buf = Vec::new();
output_stacks_csv(&mut buf, &stacks, true);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("id,format,original,enhanced,back"));
}
#[test]
fn test_output_stacks_table_empty() {
let stacks: Vec<PhotoStack> = vec![];
let mut buf = Vec::new();
output_stacks_table(&mut buf, &stacks, false, &PathBuf::from("/photos"));
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Found 0 photo stack(s)"));
}
#[test]
fn test_output_stacks_table_with_stacks() {
let stacks = vec![make_stack("IMG_0001"), make_empty_stack("IMG_0002")];
let mut buf = Vec::new();
output_stacks_table(&mut buf, &stacks, false, &PathBuf::from("/photos"));
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Found 2 photo stack(s)"));
assert!(output.contains("IMG_0001"));
assert!(output.contains("-"));
}
#[test]
fn test_output_stacks_table_with_metadata_paths() {
let stacks = vec![make_stack("IMG_0001")];
let mut buf = Vec::new();
output_stacks_table(&mut buf, &stacks, true, &PathBuf::from("/photos"));
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("(original)"));
assert!(output.contains("(enhanced)"));
assert!(output.contains("(back)"));
}
#[test]
fn test_output_info_table_jpeg() {
let stack = make_stack_with_metadata("IMG_0001");
let mut buf = Vec::new();
output_info_table(&mut buf, &stack);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Stack: IMG_0001"));
assert!(output.contains("EXIF Tags"));
assert!(output.contains("EPSON"));
assert!(output.contains("XMP Tags"));
assert!(output.contains("Creator"));
assert!(output.contains("Custom Tags"));
assert!(output.contains("album"));
}
#[test]
fn test_output_info_table_no_images() {
let stack = make_empty_stack("EMPTY");
let mut buf = Vec::new();
output_info_table(&mut buf, &stack);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Stack: EMPTY"));
}
#[test]
fn test_output_info_csv() {
let stack = make_stack_with_metadata("IMG_0001");
let mut buf = Vec::new();
output_info_csv(&mut buf, &stack);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("type,key,value"));
assert!(output.contains("id,,IMG_0001"));
assert!(output.contains("file,original,"));
assert!(output.contains("exif,Make,EPSON"));
assert!(output.contains("xmp,Creator,Test User"));
assert!(output.contains("custom,album,"));
}
#[test]
fn test_output_info_csv_no_files() {
let stack = make_empty_stack("EMPTY");
let mut buf = Vec::new();
output_info_csv(&mut buf, &stack);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("id,,EMPTY"));
assert!(!output.contains("file,original"));
}
#[test]
fn test_output_metadata_table_empty() {
let metadata = Metadata::default();
let mut buf = Vec::new();
output_metadata_table(&mut buf, &metadata);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("EXIF Tags: (none)"));
assert!(output.contains("XMP Tags: (none)"));
assert!(output.contains("Custom Tags: (none)"));
}
#[test]
fn test_output_metadata_table_with_tags() {
let stack = make_stack_with_metadata("test");
let mut buf = Vec::new();
output_metadata_table(&mut buf, stack.metadata.cached().unwrap());
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("EXIF Tags (2):"));
assert!(output.contains("EPSON"));
assert!(output.contains("XMP Tags (1):"));
assert!(output.contains("Test User"));
assert!(output.contains("Custom Tags (1):"));
}
#[test]
fn test_output_metadata_table_truncation() {
let mut exif_tags = HashMap::new();
exif_tags.insert("VeryLongTag".to_string(), "x".repeat(100));
let metadata = Metadata {
exif_tags,
xmp_tags: HashMap::new(),
custom_tags: HashMap::new(),
};
let mut buf = Vec::new();
output_metadata_table(&mut buf, &metadata);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("..."));
}
#[test]
fn test_output_metadata_csv_empty() {
let metadata = Metadata::default();
let mut buf = Vec::new();
output_metadata_csv(&mut buf, &metadata);
let output = String::from_utf8(buf).unwrap();
assert_eq!(output.trim(), "type,key,value");
}
#[test]
fn test_output_metadata_csv_with_tags() {
let stack = make_stack_with_metadata("test");
let mut buf = Vec::new();
output_metadata_csv(&mut buf, stack.metadata.cached().unwrap());
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("exif,Make,EPSON"));
assert!(output.contains("xmp,Creator,Test User"));
assert!(output.contains("custom,album,"));
}
#[test]
fn test_cmd_scan_testdata() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_scan(
&mut out,
&mut err,
&testdata_path(),
OutputFormat::Table,
false,
false,
false,
false,
false,
false,
0,
0,
ScannerProfile::EnhancedAndBack,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("photo stack(s)"));
assert!(output.contains("FamilyPhotos"));
}
#[test]
fn test_cmd_scan_json() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_scan(
&mut out,
&mut err,
&testdata_path(),
OutputFormat::Json,
false,
false,
false,
false,
false,
false,
0,
0,
ScannerProfile::EnhancedAndBack,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("FamilyPhotos"));
let parsed: serde_json::Value = serde_json::from_str(output.trim()).unwrap();
assert!(parsed.is_array());
}
#[test]
fn test_cmd_scan_csv() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_scan(
&mut out,
&mut err,
&testdata_path(),
OutputFormat::Csv,
false,
false,
false,
false,
false,
false,
0,
0,
ScannerProfile::EnhancedAndBack,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("id,format"));
}
#[test]
fn test_cmd_scan_jpeg_only() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_scan(
&mut out,
&mut err,
&testdata_path(),
OutputFormat::Csv,
false,
false,
false,
true,
false,
false,
0,
0,
ScannerProfile::EnhancedAndBack,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
for line in output.lines().skip(1) {
if !line.is_empty() {
assert!(
line.contains("jpeg") || line.contains("true") || line.contains("false"),
"Non-JPEG line found: {line}"
);
}
}
}
#[test]
fn test_cmd_scan_tiff_only() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_scan(
&mut out,
&mut err,
&testdata_path(),
OutputFormat::Csv,
false,
false,
true,
false,
false,
false,
0,
0,
ScannerProfile::EnhancedAndBack,
);
assert_eq!(code, EXIT_SUCCESS);
}
#[test]
fn test_cmd_scan_with_back_filter() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_scan(
&mut out,
&mut err,
&testdata_path(),
OutputFormat::Csv,
false,
false,
false,
false,
true,
false,
0,
0,
ScannerProfile::EnhancedAndBack,
);
assert_eq!(code, EXIT_SUCCESS);
}
#[test]
fn test_cmd_scan_show_metadata() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_scan(
&mut out,
&mut err,
&testdata_path(),
OutputFormat::Table,
true,
false,
false,
false,
false,
false,
0,
0,
ScannerProfile::EnhancedAndBack,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(
output.contains("(original)")
|| output.contains("(enhanced)")
|| output.contains("(back)")
|| output.contains("present")
);
}
#[test]
fn test_cmd_scan_csv_with_metadata() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_scan(
&mut out,
&mut err,
&testdata_path(),
OutputFormat::Csv,
true,
false,
false,
false,
false,
false,
0,
0,
ScannerProfile::EnhancedAndBack,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("id,format,original,enhanced,back"));
}
#[test]
fn test_cmd_scan_nonexistent_dir() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_scan(
&mut out,
&mut err,
&PathBuf::from("/nonexistent/dir"),
OutputFormat::Table,
false,
false,
false,
false,
false,
false,
0,
0,
ScannerProfile::EnhancedAndBack,
);
assert!(code == EXIT_SUCCESS || code == EXIT_ERROR);
}
#[test]
fn test_cmd_search_testdata() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_search(
&mut out,
&mut err,
&testdata_path(),
"FamilyPhotos",
&[],
&[],
false,
false,
&[],
OutputFormat::Table,
0,
0,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("FamilyPhotos"));
}
#[test]
fn test_cmd_search_no_results() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_search(
&mut out,
&mut err,
&testdata_path(),
"zzz_nonexistent",
&[],
&[],
false,
false,
&[],
OutputFormat::Table,
0,
0,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("Found 0"));
}
#[test]
fn test_cmd_search_with_exif_filter() {
let mut out = Vec::new();
let mut err = Vec::new();
let exif_filters = vec![("Make".to_string(), "EPSON".to_string())];
let code = cmd_search(
&mut out,
&mut err,
&testdata_path(),
"Family",
&exif_filters,
&[],
false,
false,
&[],
OutputFormat::Json,
0,
0,
);
assert_eq!(code, EXIT_SUCCESS);
}
#[test]
fn test_cmd_search_with_has_back() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_search(
&mut out,
&mut err,
&testdata_path(),
"Family",
&[],
&[],
true,
false,
&[],
OutputFormat::Csv,
0,
0,
);
assert_eq!(code, EXIT_SUCCESS);
}
#[test]
fn test_cmd_search_with_has_enhanced() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_search(
&mut out,
&mut err,
&testdata_path(),
"Family",
&[],
&[],
false,
true,
&[],
OutputFormat::Table,
0,
0,
);
assert_eq!(code, EXIT_SUCCESS);
}
#[test]
fn test_cmd_search_with_tag_filter() {
let mut out = Vec::new();
let mut err = Vec::new();
let tag_filters = vec![("album".to_string(), "Family".to_string())];
let code = cmd_search(
&mut out,
&mut err,
&testdata_path(),
"Family",
&[],
&tag_filters,
false,
false,
&[],
OutputFormat::Table,
0,
0,
);
assert_eq!(code, EXIT_SUCCESS);
}
#[test]
fn test_cmd_search_with_stack_ids() {
let mut out = Vec::new();
let mut err = Vec::new();
let ids = vec!["FamilyPhotos_0001".to_string()];
let code = cmd_search(
&mut out,
&mut err,
&testdata_path(),
"",
&[],
&[],
false,
false,
&ids,
OutputFormat::Table,
0,
0,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("FamilyPhotos_0001"));
}
#[test]
fn test_cmd_search_with_stack_ids_no_match() {
let mut out = Vec::new();
let mut err = Vec::new();
let ids = vec!["NONEXISTENT_ID".to_string()];
let code = cmd_search(
&mut out,
&mut err,
&testdata_path(),
"",
&[],
&[],
false,
false,
&ids,
OutputFormat::Table,
0,
0,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("Found 0"));
}
#[test]
fn test_cmd_info_happy_path() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_info(
&mut out,
&mut err,
&testdata_path(),
"FamilyPhotos_0001",
OutputFormat::Table,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("FamilyPhotos_0001"));
}
#[test]
fn test_cmd_info_json() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_info(
&mut out,
&mut err,
&testdata_path(),
"FamilyPhotos_0001",
OutputFormat::Json,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
let _parsed: serde_json::Value = serde_json::from_str(output.trim()).unwrap();
}
#[test]
fn test_cmd_info_csv() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_info(
&mut out,
&mut err,
&testdata_path(),
"FamilyPhotos_0001",
OutputFormat::Csv,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("type,key,value"));
}
#[test]
fn test_cmd_info_not_found() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_info(
&mut out,
&mut err,
&testdata_path(),
"nonexistent_stack",
OutputFormat::Table,
);
assert_eq!(code, EXIT_NOT_FOUND);
let error_output = String::from_utf8(err).unwrap();
assert!(error_output.contains("not found"));
}
#[test]
fn test_cmd_metadata_read_table() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_metadata_read(
&mut out,
&mut err,
&testdata_path(),
"FamilyPhotos_0001",
OutputFormat::Table,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("Metadata"));
}
#[test]
fn test_cmd_metadata_read_json() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_metadata_read(
&mut out,
&mut err,
&testdata_path(),
"FamilyPhotos_0001",
OutputFormat::Json,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
let _parsed: serde_json::Value = serde_json::from_str(output.trim()).unwrap();
}
#[test]
fn test_cmd_metadata_read_csv() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_metadata_read(
&mut out,
&mut err,
&testdata_path(),
"FamilyPhotos_0001",
OutputFormat::Csv,
);
assert_eq!(code, EXIT_SUCCESS);
}
#[test]
fn test_cmd_metadata_read_not_found() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_metadata_read(
&mut out,
&mut err,
&testdata_path(),
"nonexistent",
OutputFormat::Table,
);
assert_eq!(code, EXIT_NOT_FOUND);
}
#[test]
fn test_cmd_metadata_write_happy_path() {
let dir = copy_testdata_to_tempdir();
let mut out = Vec::new();
let mut err = Vec::new();
let tags = vec![
("album".to_string(), "Family".to_string()),
("year".to_string(), "2024".to_string()),
];
let code = cmd_metadata_write(
&mut out,
&mut err,
&dir.path().to_path_buf(),
"FamilyPhotos_0001",
&tags,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("Wrote 2 tag(s)"));
}
#[test]
fn test_cmd_metadata_write_not_found() {
let dir = copy_testdata_to_tempdir();
let mut out = Vec::new();
let mut err = Vec::new();
let tags = vec![("album".to_string(), "Test".to_string())];
let code = cmd_metadata_write(
&mut out,
&mut err,
&dir.path().to_path_buf(),
"nonexistent",
&tags,
);
assert_eq!(code, EXIT_NOT_FOUND);
}
#[test]
fn test_cmd_metadata_delete_not_found() {
let dir = copy_testdata_to_tempdir();
let mut out = Vec::new();
let mut err = Vec::new();
let tags = vec!["album".to_string()];
let code = cmd_metadata_delete(
&mut out,
&mut err,
&dir.path().to_path_buf(),
"nonexistent",
&tags,
);
assert_eq!(code, EXIT_NOT_FOUND);
}
#[test]
fn test_cmd_metadata_delete_happy_path() {
let dir = copy_testdata_to_tempdir();
let mut out = Vec::new();
let mut err = Vec::new();
let tags = vec![("album".to_string(), "Family".to_string())];
cmd_metadata_write(
&mut out,
&mut err,
&dir.path().to_path_buf(),
"FamilyPhotos_0001",
&tags,
);
let mut out = Vec::new();
let mut err = Vec::new();
let tags = vec!["album".to_string()];
let code = cmd_metadata_delete(
&mut out,
&mut err,
&dir.path().to_path_buf(),
"FamilyPhotos_0001",
&tags,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("Deleted 1 tag(s)"));
}
#[test]
fn test_cmd_export_stdout() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_export(&mut out, &mut err, &testdata_path(), None);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
let parsed: serde_json::Value = serde_json::from_str(output.trim()).unwrap();
assert!(parsed.is_array());
}
#[test]
fn test_cmd_export_to_file() {
let dir = tempfile::tempdir().unwrap();
let output_file = dir.path().join("export.json");
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_export(&mut out, &mut err, &testdata_path(), Some(&output_file));
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("Exported"));
assert!(output_file.exists());
let content = std::fs::read_to_string(&output_file).unwrap();
let _: serde_json::Value = serde_json::from_str(&content).unwrap();
}
#[test]
fn test_cmd_export_to_invalid_path() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_export(
&mut out,
&mut err,
&testdata_path(),
Some(Path::new("/nonexistent/dir/out.json")),
);
assert_eq!(code, EXIT_ERROR);
let error_output = String::from_utf8(err).unwrap();
assert!(error_output.contains("Error writing"));
}
#[test]
fn test_cmd_scan_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_scan(
&mut out,
&mut err,
&dir.path().to_path_buf(),
OutputFormat::Table,
false,
false,
false,
false,
false,
false,
0,
0,
ScannerProfile::EnhancedAndBack,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("Found 0"));
}
#[test]
fn test_output_stacks_table_tiff_format() {
let stacks = vec![make_tiff_stack("IMG_0001")];
let mut buf = Vec::new();
output_stacks_table(&mut buf, &stacks, false, &PathBuf::from("/photos"));
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("IMG_0001"));
assert!(output.contains("-"));
}
#[test]
fn test_output_stacks_table_no_format() {
let stacks = vec![make_empty_stack("IMG_0001")];
let mut buf = Vec::new();
output_stacks_table(&mut buf, &stacks, false, &PathBuf::from("/photos"));
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("-"));
}
#[test]
fn test_output_stacks_csv_tiff_format() {
let stacks = vec![make_tiff_stack("IMG_0001")];
let mut buf = Vec::new();
output_stacks_csv(&mut buf, &stacks, false);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("IMG_0001,-,true,false,false"));
}
#[test]
fn test_output_stacks_csv_no_format() {
let stacks = vec![make_empty_stack("IMG_0001")];
let mut buf = Vec::new();
output_stacks_csv(&mut buf, &stacks, false);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("IMG_0001,-,false,false,false"));
}
#[test]
fn test_output_info_table_with_long_tag_truncation() {
let mut exif_tags = HashMap::new();
exif_tags.insert("Description".to_string(), "A".repeat(100));
let mut stack = PhotoStack::new("TRUNC");
stack.metadata = make_metadata_ref(Metadata {
exif_tags,
xmp_tags: HashMap::new(),
custom_tags: HashMap::new(),
});
let mut buf = Vec::new();
output_info_table(&mut buf, &stack);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("..."));
}
#[test]
fn test_output_info_table_tiff() {
let stack = make_tiff_stack("TIFF_0001");
let mut buf = Vec::new();
output_info_table(&mut buf, &stack);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Stack: TIFF_0001"));
}
#[test]
fn test_run_cli_scan_dispatch() {
let cli = Cli {
command: Commands::Scan {
directory: testdata_path(),
format: OutputFormat::Table,
show_metadata: false,
metadata: false,
tiff_only: false,
jpeg_only: false,
with_back: false,
recursive: false,
limit: 0,
offset: 0,
profile: CliScannerProfile::Auto,
},
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = run_cli(&cli, &mut out, &mut err);
assert_eq!(code, EXIT_SUCCESS);
}
#[test]
fn test_run_cli_search_dispatch() {
let cli = Cli {
command: Commands::Search {
directory: testdata_path(),
query: "FamilyPhotos".to_string(),
exif_filters: vec![],
tag_filters: vec![],
has_back: false,
has_enhanced: false,
stack_ids: vec![],
format: OutputFormat::Table,
limit: 0,
offset: 0,
},
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = run_cli(&cli, &mut out, &mut err);
assert_eq!(code, EXIT_SUCCESS);
}
#[test]
fn test_run_cli_info_dispatch() {
let cli = Cli {
command: Commands::Info {
directory: testdata_path(),
stack_id: "FamilyPhotos_0001".to_string(),
format: OutputFormat::Table,
},
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = run_cli(&cli, &mut out, &mut err);
assert_eq!(code, EXIT_SUCCESS);
}
#[test]
fn test_run_cli_metadata_read_dispatch() {
let cli = Cli {
command: Commands::Metadata(MetadataCommand::Read {
directory: testdata_path(),
stack_id: "FamilyPhotos_0001".to_string(),
format: OutputFormat::Table,
}),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = run_cli(&cli, &mut out, &mut err);
assert_eq!(code, EXIT_SUCCESS);
}
#[test]
fn test_run_cli_metadata_write_dispatch() {
let dir = copy_testdata_to_tempdir();
let cli = Cli {
command: Commands::Metadata(MetadataCommand::Write {
directory: dir.path().to_path_buf(),
stack_id: "FamilyPhotos_0001".to_string(),
tags: vec![("test_key".to_string(), "test_val".to_string())],
}),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = run_cli(&cli, &mut out, &mut err);
assert_eq!(code, EXIT_SUCCESS);
}
#[test]
fn test_run_cli_metadata_delete_dispatch() {
let dir = copy_testdata_to_tempdir();
let tags_w = vec![("del_key".to_string(), "val".to_string())];
cmd_metadata_write(
&mut Vec::new(),
&mut Vec::new(),
&dir.path().to_path_buf(),
"FamilyPhotos_0001",
&tags_w,
);
let cli = Cli {
command: Commands::Metadata(MetadataCommand::Delete {
directory: dir.path().to_path_buf(),
stack_id: "FamilyPhotos_0001".to_string(),
tags: vec!["del_key".to_string()],
}),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = run_cli(&cli, &mut out, &mut err);
assert_eq!(code, EXIT_SUCCESS);
}
#[test]
fn test_run_cli_export_dispatch() {
let cli = Cli {
command: Commands::Export {
directory: testdata_path(),
output: None,
},
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = run_cli(&cli, &mut out, &mut err);
assert_eq!(code, EXIT_SUCCESS);
}
#[test]
fn test_output_info_table_with_xmp_tags() {
let mut xmp_tags = HashMap::new();
xmp_tags.insert("Creator".to_string(), "John Doe".to_string());
let mut stack = PhotoStack::new("XMP_TEST");
stack.original = make_image_ref("/photos/XMP_TEST.jpg");
stack.metadata = make_metadata_ref(Metadata {
exif_tags: HashMap::new(),
xmp_tags,
custom_tags: HashMap::new(),
});
let mut buf = Vec::new();
output_info_table(&mut buf, &stack);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("XMP Tags"));
assert!(output.contains("Creator"));
}
#[test]
fn test_output_info_table_with_custom_tags() {
let mut custom_tags = HashMap::new();
custom_tags.insert(
"album".to_string(),
serde_json::Value::String("vacation".to_string()),
);
let mut stack = PhotoStack::new("CUSTOM_TEST");
stack.metadata = make_metadata_ref(Metadata {
exif_tags: HashMap::new(),
xmp_tags: HashMap::new(),
custom_tags,
});
let mut buf = Vec::new();
output_info_table(&mut buf, &stack);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Custom Tags"));
assert!(output.contains("album"));
}
#[test]
fn test_output_metadata_table_with_xmp_tags() {
let mut xmp_tags = HashMap::new();
xmp_tags.insert("Subject".to_string(), "Landscape".to_string());
let meta = Metadata {
exif_tags: HashMap::new(),
xmp_tags,
custom_tags: HashMap::new(),
};
let mut buf = Vec::new();
output_metadata_table(&mut buf, &meta);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("XMP Tags"));
assert!(output.contains("Subject"));
}
#[test]
fn test_output_info_csv_with_xmp_and_custom_tags() {
let mut xmp_tags = HashMap::new();
xmp_tags.insert("Creator".to_string(), "Jane".to_string());
let mut custom_tags = HashMap::new();
custom_tags.insert("rating".to_string(), serde_json::Value::from(5));
let mut stack = PhotoStack::new("CSV_TAGS");
stack.original = make_image_ref("/photos/CSV_TAGS.jpg");
stack.enhanced = make_image_ref("/photos/CSV_TAGS_a.jpg");
stack.back = make_image_ref("/photos/CSV_TAGS_b.jpg");
stack.metadata = make_metadata_ref(Metadata {
exif_tags: HashMap::new(),
xmp_tags,
custom_tags,
});
let mut buf = Vec::new();
output_info_csv(&mut buf, &stack);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("xmp,Creator,Jane"));
assert!(output.contains("custom,rating,5"));
assert!(output.contains("file,original"));
assert!(output.contains("file,enhanced"));
assert!(output.contains("file,back"));
}
#[test]
fn test_output_metadata_csv_with_xmp() {
let mut xmp_tags = HashMap::new();
xmp_tags.insert("Title".to_string(), "My Photo".to_string());
let meta = Metadata {
exif_tags: HashMap::new(),
xmp_tags,
custom_tags: HashMap::new(),
};
let mut buf = Vec::new();
output_metadata_csv(&mut buf, &meta);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("xmp,Title,My Photo"));
}
#[test]
fn test_output_info_table_with_long_xmp_truncation() {
let mut xmp_tags = HashMap::new();
xmp_tags.insert("Description".to_string(), "X".repeat(100));
let mut stack = PhotoStack::new("LONG_XMP");
stack.metadata = make_metadata_ref(Metadata {
exif_tags: HashMap::new(),
xmp_tags,
custom_tags: HashMap::new(),
});
let mut buf = Vec::new();
output_info_table(&mut buf, &stack);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("..."));
}
#[test]
fn test_output_info_table_with_long_custom_truncation() {
let mut custom_tags = HashMap::new();
custom_tags.insert(
"longval".to_string(),
serde_json::Value::String("Y".repeat(100)),
);
let mut stack = PhotoStack::new("LONG_CUSTOM");
stack.metadata = make_metadata_ref(Metadata {
exif_tags: HashMap::new(),
xmp_tags: HashMap::new(),
custom_tags,
});
let mut buf = Vec::new();
output_info_table(&mut buf, &stack);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("..."));
}
#[test]
fn test_output_metadata_table_with_long_xmp_truncation() {
let mut xmp_tags = HashMap::new();
xmp_tags.insert("LongKey".to_string(), "Z".repeat(100));
let meta = Metadata {
exif_tags: HashMap::new(),
xmp_tags,
custom_tags: HashMap::new(),
};
let mut buf = Vec::new();
output_metadata_table(&mut buf, &meta);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("..."));
}
#[test]
fn test_output_metadata_table_with_long_custom_truncation() {
let mut custom_tags = HashMap::new();
custom_tags.insert(
"longkey".to_string(),
serde_json::Value::String("W".repeat(100)),
);
let meta = Metadata {
exif_tags: HashMap::new(),
xmp_tags: HashMap::new(),
custom_tags,
};
let mut buf = Vec::new();
output_metadata_table(&mut buf, &meta);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("..."));
}
#[test]
fn test_cmd_search_scan_error() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_search(
&mut out,
&mut err,
&PathBuf::from("/nonexistent/search/dir"),
"query",
&[],
&[],
false,
false,
&[],
OutputFormat::Table,
0,
0,
);
assert!(code == EXIT_SUCCESS || code == EXIT_ERROR);
}
#[test]
fn test_cmd_export_scan_error() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_export(
&mut out,
&mut err,
&PathBuf::from("/nonexistent/export/dir"),
None,
);
assert!(code == EXIT_SUCCESS || code == EXIT_ERROR);
}
#[test]
fn test_cmd_info_generic_error() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_info(
&mut out,
&mut err,
&PathBuf::from("/nonexistent/info/dir"),
"NO_STACK",
OutputFormat::Table,
);
assert!(code == EXIT_NOT_FOUND || code == EXIT_ERROR);
}
#[test]
fn test_cmd_metadata_read_generic_error() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_metadata_read(
&mut out,
&mut err,
&PathBuf::from("/nonexistent/meta/dir"),
"NO_STACK",
OutputFormat::Table,
);
assert!(code == EXIT_NOT_FOUND || code == EXIT_ERROR);
}
#[test]
fn test_cmd_metadata_write_generic_error() {
let mut out = Vec::new();
let mut err = Vec::new();
let tags = vec![("k".to_string(), "v".to_string())];
let code = cmd_metadata_write(
&mut out,
&mut err,
&PathBuf::from("/nonexistent/write/dir"),
"NO_STACK",
&tags,
);
assert!(code == EXIT_NOT_FOUND || code == EXIT_ERROR);
}
fn create_test_image_jpeg(path: &std::path::Path, width: u32, height: u32) {
let img = image::RgbImage::from_fn(width, height, |x, y| image::Rgb([x as u8, y as u8, 0]));
img.save(path).unwrap();
}
#[test]
fn test_cmd_rotate_success() {
let dir = tempfile::tempdir().unwrap();
create_test_image_jpeg(&dir.path().join("IMG_001.jpg"), 4, 2);
create_test_image_jpeg(&dir.path().join("IMG_001_a.jpg"), 4, 2);
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_rotate(
&mut out,
&mut err,
&dir.path().to_path_buf(),
"IMG_001",
90,
RotationTarget::All,
OutputFormat::Table,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("Rotated"));
assert!(output.contains("IMG_001"));
assert!(output.contains("90°"));
}
#[test]
fn test_cmd_rotate_negative_90() {
let dir = tempfile::tempdir().unwrap();
create_test_image_jpeg(&dir.path().join("IMG_001.jpg"), 4, 2);
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_rotate(
&mut out,
&mut err,
&dir.path().to_path_buf(),
"IMG_001",
-90,
RotationTarget::All,
OutputFormat::Table,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("270°"));
}
#[test]
fn test_cmd_rotate_180() {
let dir = tempfile::tempdir().unwrap();
create_test_image_jpeg(&dir.path().join("IMG_001.jpg"), 4, 2);
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_rotate(
&mut out,
&mut err,
&dir.path().to_path_buf(),
"IMG_001",
180,
RotationTarget::All,
OutputFormat::Table,
);
assert_eq!(code, EXIT_SUCCESS);
}
#[test]
fn test_cmd_rotate_invalid_degrees() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_rotate(
&mut out,
&mut err,
&PathBuf::from("."),
"test",
45,
RotationTarget::All,
OutputFormat::Table,
);
assert_eq!(code, EXIT_ERROR);
let err_output = String::from_utf8(err).unwrap();
assert!(err_output.contains("Invalid rotation"));
}
#[test]
fn test_cmd_rotate_not_found() {
let dir = tempfile::tempdir().unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_rotate(
&mut out,
&mut err,
&dir.path().to_path_buf(),
"nonexistent",
90,
RotationTarget::All,
OutputFormat::Table,
);
assert_eq!(code, EXIT_NOT_FOUND);
}
#[test]
fn test_cmd_rotate_json_output() {
let dir = tempfile::tempdir().unwrap();
create_test_image_jpeg(&dir.path().join("IMG_001.jpg"), 4, 2);
let mut out = Vec::new();
let mut err = Vec::new();
let code = cmd_rotate(
&mut out,
&mut err,
&dir.path().to_path_buf(),
"IMG_001",
90,
RotationTarget::All,
OutputFormat::Json,
);
assert_eq!(code, EXIT_SUCCESS);
let output = String::from_utf8(out).unwrap();
assert!(output.contains("\"name\": \"IMG_001\""));
}
}