feat: Implement add command with file tag management and error handling

This commit is contained in:
Moritz Böhme 2025-02-23 15:53:33 +01:00
parent 78180a516f
commit 9031ec9af1
3 changed files with 213 additions and 32 deletions

View file

@ -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"

View file

@ -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<String>,
#[command(subcommand)]
command: Commands,
}
fn list_tags(files: &[String]) -> Result<Vec<String>, Box<dyn Error>> {
#[derive(Subcommand)]
enum Commands {
/// List all unique tags
List {
/// Files to process
files: Vec<String>,
},
/// Add tags to files
Add {
/// Tags to add
#[arg(required = true)]
tags: Vec<String>,
/// Files to process
#[arg(required = true)]
files: Vec<String>,
},
}
fn list_tags(files: &[String]) -> Result<Vec<String>, 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<dyn Error>> {
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<dyn Error>> {
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_file(dir: &TempDir, name: &str) -> Result<String, Box<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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(())
}
}

View file

@ -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>, 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>, 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<String> = 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");
}
}