feat: Implement add command with file tag management and error handling
This commit is contained in:
parent
78180a516f
commit
9031ec9af1
3 changed files with 213 additions and 32 deletions
|
|
@ -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"
|
||||
|
|
|
|||
185
src/main.rs
185
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<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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue