feat: Add shell completions using clap_complete_command

This commit is contained in:
Moritz Böhme 2025-02-23 16:56:12 +01:00
parent f23beef347
commit 263b033035
2 changed files with 123 additions and 67 deletions

View file

@ -6,6 +6,7 @@ edition = "2021"
[dependencies]
thiserror = "1.0"
clap = { version = "4.4", features = ["derive"] }
clap_complete_command = "0.5.1"
fs-err = "2.11"
[dev-dependencies]

View file

@ -1,12 +1,13 @@
mod tag_engine;
mod symlink;
use clap::CommandFactory;
mod error;
mod symlink;
mod tag_engine;
use std::collections::BTreeSet;
use std::path::PathBuf;
use crate::error::FileTagsError;
use clap::{Parser, Subcommand};
use fs_err as fs;
use crate::error::FileTagsError;
use std::collections::BTreeSet;
use std::path::PathBuf;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
@ -16,7 +17,13 @@ struct Cli {
}
#[derive(Subcommand)]
#[command(subcommand_required = true)]
enum Commands {
/// Generate shell completions
Completion {
#[arg(value_enum)]
shell: clap_complete_command::Shell,
},
/// List all unique tags found in files
List {
/// Files to process
@ -47,7 +54,11 @@ enum Commands {
#[arg(long, required = true, help = "Target directory for creating the tree")]
dir: String,
/// Maximum depth of the tree
#[arg(long, default_value = "3", help = "Maximum depth of the directory tree")]
#[arg(
long,
default_value = "3",
help = "Maximum depth of the directory tree"
)]
depth: usize,
/// Files to process
#[arg(required = true, help = "One or more files to create tree from")]
@ -61,10 +72,12 @@ fn list_tags(files: &[String]) -> Result<Vec<String>, FileTagsError> {
for file in files {
match tag_engine::parse_tags(file) {
Ok((_, tags, _)) => unique_tags.extend(tags),
Err(e) => return Err(FileTagsError::Parse {
file: PathBuf::from(file),
source: e,
}),
Err(e) => {
return Err(FileTagsError::Parse {
file: PathBuf::from(file),
source: e,
})
}
}
}
@ -72,17 +85,19 @@ fn list_tags(files: &[String]) -> Result<Vec<String>, FileTagsError> {
}
fn add_tags_to_file(file: &str, new_tags: &[String]) -> Result<(), FileTagsError> {
let (base, current_tags, ext) = tag_engine::parse_tags(file).map_err(|e| FileTagsError::Parse {
file: PathBuf::from(file),
source: e,
})?;
let (base, current_tags, ext) =
tag_engine::parse_tags(file).map_err(|e| FileTagsError::Parse {
file: PathBuf::from(file),
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()
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
@ -104,8 +119,11 @@ fn add_tags_to_file(file: &str, new_tags: &[String]) -> Result<(), FileTagsError
fn main() -> Result<(), FileTagsError> {
let cli = Cli::parse();
match cli.command {
Commands::Completion { shell } => {
shell.generate(&mut Cli::command(), &mut std::io::stdout());
}
Commands::List { files } => {
let tags = list_tags(&files)?;
for tag in tags {
@ -131,19 +149,21 @@ fn main() -> Result<(), FileTagsError> {
}
fn remove_tags_from_file(file: &str, remove_tags: &[String]) -> Result<(), FileTagsError> {
let (base, current_tags, ext) = tag_engine::parse_tags(file).map_err(|e| FileTagsError::Parse {
file: PathBuf::from(file),
source: e,
})?;
let (base, current_tags, ext) =
tag_engine::parse_tags(file).map_err(|e| FileTagsError::Parse {
file: PathBuf::from(file),
source: e,
})?;
let filtered_tags = tag_engine::filter_tags(current_tags, remove_tags);
let new_filename = tag_engine::serialize_tags(&base, &filtered_tags, &ext);
// Preserve the original directory
let parent = std::path::Path::new(file).parent()
let parent = std::path::Path::new(file)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let new_path = if parent.is_empty() {
new_filename
} else {
@ -164,12 +184,13 @@ fn remove_tags_from_file(file: &str, remove_tags: &[String]) -> Result<(), FileT
fn create_tag_tree(files: &[String], target_dir: &str, depth: usize) -> Result<(), FileTagsError> {
let target = PathBuf::from(target_dir);
for file in files {
let (_base, tags, _ext) = tag_engine::parse_tags(file).map_err(|e| FileTagsError::Parse {
file: PathBuf::from(file),
source: e,
})?;
let (_base, tags, _ext) =
tag_engine::parse_tags(file).map_err(|e| FileTagsError::Parse {
file: PathBuf::from(file),
source: e,
})?;
// Create root symlink
let paths = vec![PathBuf::from(file)];
@ -182,7 +203,7 @@ fn create_tag_tree(files: &[String], target_dir: &str, depth: usize) -> Result<(
for tag in &combo {
dir_path.push(tag);
}
let paths = vec![PathBuf::from(file)];
symlink::create_symlink_tree(paths, &dir_path)?;
}
@ -202,9 +223,7 @@ mod tests {
fs::create_dir_all(parent)?;
}
fs::write(&path, "")?;
Ok(path.to_str()
.ok_or("Invalid path")?
.to_string())
Ok(path.to_str().ok_or("Invalid path")?.to_string())
}
#[test]
@ -235,15 +254,18 @@ mod tests {
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");
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(())
@ -253,15 +275,18 @@ mod tests {
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");
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(())
@ -271,13 +296,13 @@ mod tests {
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(())
@ -287,17 +312,23 @@ mod tests {
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");
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");
assert!(
new_path.exists(),
"Tagged file was not created in original directory"
);
Ok(())
}
@ -305,9 +336,9 @@ mod tests {
fn test_remove_tags() -> Result<(), Box<dyn Error>> {
let tmp_dir = TempDir::new()?;
let file = create_test_file(&tmp_dir, "test -- tag1 tag2 tag3.txt")?;
remove_tags_from_file(&file, &vec!["tag2".to_string()])?;
let new_path = tmp_dir.path().join("test -- tag1 tag3.txt");
assert!(new_path.exists(), "File with removed tag not found");
Ok(())
@ -318,17 +349,21 @@ mod tests {
let tmp_dir = TempDir::new()?;
let source = create_test_file(&tmp_dir, "test -- tag1 tag2.txt")?;
let tree_dir = tmp_dir.path().join("tree");
create_tag_tree(&[source], tree_dir.to_str().unwrap(), 2)?;
// Check root symlink
assert!(tree_dir.join("test -- tag1 tag2.txt").exists());
// Check tag directories
assert!(tree_dir.join("tag1").join("test -- tag1 tag2.txt").exists());
assert!(tree_dir.join("tag2").join("test -- tag1 tag2.txt").exists());
assert!(tree_dir.join("tag1").join("tag2").join("test -- tag1 tag2.txt").exists());
assert!(tree_dir
.join("tag1")
.join("tag2")
.join("test -- tag1 tag2.txt")
.exists());
Ok(())
}
@ -337,32 +372,52 @@ mod tests {
let tmp_dir = TempDir::new()?;
let file1 = create_test_file(&tmp_dir, "doc1.txt")?;
let file2 = create_test_file(&tmp_dir, "doc2.txt")?;
// Add tags
add_tags_to_file(&file1, &vec!["work".to_string(), "draft".to_string()])?;
add_tags_to_file(&file2, &vec!["work".to_string(), "final".to_string()])?;
// List tags
let tags = list_tags(&[
tmp_dir.path().join("doc1 -- draft work.txt").to_str().unwrap().to_string(),
tmp_dir.path().join("doc2 -- final work.txt").to_str().unwrap().to_string(),
tmp_dir
.path()
.join("doc1 -- draft work.txt")
.to_str()
.unwrap()
.to_string(),
tmp_dir
.path()
.join("doc2 -- final work.txt")
.to_str()
.unwrap()
.to_string(),
])?;
assert_eq!(tags, vec!["draft", "final", "work"]);
// Remove a tag
let file_to_remove = tmp_dir.path().join("doc1 -- draft work.txt").to_str().unwrap().to_string();
let file_to_remove = tmp_dir
.path()
.join("doc1 -- draft work.txt")
.to_str()
.unwrap()
.to_string();
remove_tags_from_file(&file_to_remove, &vec!["draft".to_string()])?;
// Create tree
let tree_dir = tmp_dir.path().join("tree");
create_tag_tree(
&[tmp_dir.path().join("doc1 -- work.txt").to_str().unwrap().to_string()],
&[tmp_dir
.path()
.join("doc1 -- work.txt")
.to_str()
.unwrap()
.to_string()],
tree_dir.to_str().unwrap(),
2
2,
)?;
assert!(tree_dir.join("work").join("doc1 -- work.txt").exists());
Ok(())
}
}