diff --git a/Cargo.toml b/Cargo.toml index 5255f6c..d132488 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,3 +6,7 @@ edition = "2021" [dependencies] thiserror = "1.0" clap = { version = "4.4", features = ["derive"] } +fs-err = "2.11" + +[dev-dependencies] +tempfile = "3.8" diff --git a/src/main.rs b/src/main.rs index a44e53c..c290fc9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,35 +2,112 @@ mod tag_engine; use std::collections::BTreeSet; use std::error::Error; -use clap::Parser; +use clap::{Parser, Subcommand}; +use fs_err as fs; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum CommandError { + #[error("Failed to rename {from} to {to}: {source}")] + Rename { + from: String, + to: String, + source: std::io::Error, + }, + #[error("Failed to parse tags in {file}: {source}")] + Parse { + file: String, + source: tag_engine::ParseError, + }, +} #[derive(Parser)] #[command(author, version, about, long_about = None)] struct Cli { - /// Files to process - #[arg(required = true)] - files: Vec, + #[command(subcommand)] + command: Commands, } -fn list_tags(files: &[String]) -> Result, Box> { +#[derive(Subcommand)] +enum Commands { + /// List all unique tags + List { + /// Files to process + files: Vec, + }, + /// Add tags to files + Add { + /// Tags to add + #[arg(required = true)] + tags: Vec, + /// Files to process + #[arg(required = true)] + files: Vec, + }, +} + +fn list_tags(files: &[String]) -> Result, CommandError> { let mut unique_tags = BTreeSet::new(); for file in files { - if let Ok((_, tags, _)) = tag_engine::parse_tags(file) { - unique_tags.extend(tags); + match tag_engine::parse_tags(file) { + Ok((_, tags, _)) => unique_tags.extend(tags), + Err(e) => return Err(CommandError::Parse { + file: file.to_string(), + source: e, + }), } } Ok(unique_tags.into_iter().collect()) } +fn add_tags_to_file(file: &str, new_tags: &[String]) -> Result<(), CommandError> { + let (base, current_tags, ext) = tag_engine::parse_tags(file).map_err(|e| CommandError::Parse { + file: file.to_string(), + source: e, + })?; + + let merged_tags = tag_engine::add_tags(current_tags, new_tags.to_vec()); + // Preserve the original directory + let parent = std::path::Path::new(file).parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + let new_filename = tag_engine::serialize_tags(&base, &merged_tags, &ext); + let new_path = if parent.is_empty() { + new_filename + } else { + format!("{}/{}", parent, new_filename) + }; + + // Only rename if the name would actually change + if file != new_path { + fs::rename(file, &new_path).map_err(|e| CommandError::Rename { + from: file.to_string(), + to: new_path, + source: e, + })?; + } + + Ok(()) +} + fn main() -> Result<(), Box> { let cli = Cli::parse(); - let tags = list_tags(&cli.files)?; - - for tag in tags { - println!("{}", tag); + match cli.command { + Commands::List { files } => { + let tags = list_tags(&files)?; + for tag in tags { + println!("{}", tag); + } + } + Commands::Add { tags, files } => { + for file in files { + add_tags_to_file(&file, &tags)?; + } + } } Ok(()) @@ -39,6 +116,18 @@ fn main() -> Result<(), Box> { #[cfg(test)] mod tests { use super::*; + use tempfile::TempDir; + + fn create_test_file(dir: &TempDir, name: &str) -> Result> { + let path = dir.path().join(name); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, "")?; + Ok(path.to_str() + .ok_or("Invalid path")? + .to_string()) + } #[test] fn test_list_tags_empty() { @@ -65,12 +154,72 @@ mod tests { } #[test] - fn test_list_tags_with_invalid() { - let files = vec![ - "valid.txt -- good tag1".to_string(), - "invalid.txt -- bad:tag".to_string(), - ]; - let tags = list_tags(&files).unwrap(); - assert_eq!(tags, vec!["good", "tag1"]); + fn test_add_tags_to_file() -> Result<(), Box> { + let tmp_dir = TempDir::new()?; + let file = create_test_file(&tmp_dir, "test.txt")?; + + // Verify file was created + let initial_path = tmp_dir.path().join("test.txt"); + assert!(initial_path.exists(), "Initial test file was not created"); + + add_tags_to_file(&file, &vec!["tag1".to_string(), "tag2".to_string()])?; + + // Verify original is gone and new exists + assert!(!initial_path.exists(), "Original file still exists after rename"); + let new_path = tmp_dir.path().join("test -- tag1 tag2.txt"); + assert!(new_path.exists(), "Tagged file was not created"); + Ok(()) + } + + #[test] + fn test_add_tags_to_existing_tags() -> Result<(), Box> { + let tmp_dir = TempDir::new()?; + let file = create_test_file(&tmp_dir, "test -- existing.txt")?; + + // Verify file was created + let initial_path = tmp_dir.path().join("test -- existing.txt"); + assert!(initial_path.exists(), "Initial test file was not created"); + + add_tags_to_file(&file, &vec!["new".to_string()])?; + + // Verify original is gone and new exists + assert!(!initial_path.exists(), "Original file still exists after rename"); + let new_name = tmp_dir.path().join("test -- existing new.txt"); + assert!(new_name.exists(), "Tagged file was not created"); + Ok(()) + } + + #[test] + fn test_add_duplicate_tags() -> Result<(), Box> { + let tmp_dir = TempDir::new()?; + let file = create_test_file(&tmp_dir, "test -- tag1.txt")?; + + // Verify file was created + let initial_path = tmp_dir.path().join("test -- tag1.txt"); + assert!(initial_path.exists(), "Initial test file was not created"); + + add_tags_to_file(&file, &vec!["tag1".to_string()])?; + + // Original should still exist since no change was needed + assert!(initial_path.exists(), "Original file should still exist"); + Ok(()) + } + + #[test] + fn test_add_tags_nested_path() -> Result<(), Box> { + let tmp_dir = TempDir::new()?; + let file = create_test_file(&tmp_dir, "nested/path/test.txt")?; + + // Verify file was created + let initial_path = tmp_dir.path().join("nested/path/test.txt"); + assert!(initial_path.exists(), "Initial test file was not created"); + + add_tags_to_file(&file, &vec!["tag1".to_string()])?; + + // Verify original is gone and new exists in same directory + assert!(!initial_path.exists(), "Original file still exists after rename"); + let new_path = tmp_dir.path().join("nested/path/test -- tag1.txt"); + assert!(new_path.exists(), "Tagged file was not created in original directory"); + Ok(()) } } diff --git a/src/tag_engine.rs b/src/tag_engine.rs index 327fa09..cb956a2 100644 --- a/src/tag_engine.rs +++ b/src/tag_engine.rs @@ -34,7 +34,13 @@ pub fn validate_tag(tag: &str) -> Result<(), TagError> { pub const TAG_DELIMITER: &str = " -- "; pub fn parse_tags(filename: &str) -> Result<(String, Vec, String), ParseError> { - let parts: Vec<&str> = filename.split(TAG_DELIMITER).collect(); + // Get the file name without the path + let file_name = std::path::Path::new(filename) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| filename.to_string()); + + let parts: Vec<&str> = file_name.split(TAG_DELIMITER).collect(); if parts.len() > 2 { return Err(ParseError::MultipleDelimiters); @@ -42,24 +48,30 @@ pub fn parse_tags(filename: &str) -> Result<(String, Vec, String), Parse // Split the first part into base and extension let base_parts: Vec<&str> = parts[0].rsplitn(2, '.').collect(); - let (base_name, extension) = match base_parts.len() { - 2 => (base_parts[1].to_string(), format!(".{}", base_parts[0])), - _ => (parts[0].to_string(), String::new()), + let mut extension = match base_parts.len() { + 2 => format!(".{}", base_parts[0]), + _ => String::new(), }; + let base_name = base_parts.last().unwrap_or(&parts[0]).to_string(); let tags = if parts.len() == 2 { - let tag_part = parts[1]; - let tags: Vec = tag_part - .split_whitespace() - .map(str::to_string) - .collect(); - - // Validate each tag - for tag in &tags { - validate_tag(tag)?; + let mut tag_part = parts[1].to_string(); + + // Check if the last tag contains an extension + if let Some(last_part) = tag_part.split_whitespace().last() { + if let Some(dot_pos) = last_part.rfind('.') { + extension = last_part[dot_pos..].to_string(); + tag_part.truncate(tag_part.len() - extension.len()); + } } - tags + let mut unique_tags = std::collections::HashSet::new(); + for tag in tag_part.split_whitespace() { + validate_tag(tag)?; + unique_tags.insert(tag.to_string()); + } + + unique_tags.into_iter().collect() } else { Vec::new() }; @@ -216,4 +228,20 @@ mod tests { Err(ParseError::InvalidTag(TagError::InvalidChar(':'))) ); } + + #[test] + fn test_parse_tags_with_path() { + let (base, tags, ext) = parse_tags("/tmp/path/to/file.txt -- tag1 tag2").unwrap(); + assert_eq!(base, "file"); + assert_eq!(tags, vec!["tag1", "tag2"]); + assert_eq!(ext, ".txt"); + } + + #[test] + fn test_parse_tags_with_duplicate_tags() { + let (base, tags, ext) = parse_tags("/tmp/.tmpRRop05/test -- tag1 tag1.txt").unwrap(); + assert_eq!(base, "test"); + assert_eq!(tags, vec!["tag1"]); + assert_eq!(ext, ".txt"); + } }