diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 5cb5b1c..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,421 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anstream" -version = "0.6.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" - -[[package]] -name = "anstyle-parse" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" -dependencies = [ - "anstyle", - "once_cell", - "windows-sys", -] - -[[package]] -name = "autocfg" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" - -[[package]] -name = "bitflags" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "clap" -version = "4.5.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92b7b18d71fad5313a1e320fa9897994228ce274b60faa4d694fe0ea89cd9e6d" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a35db2071778a7344791a4fb4f95308b5673d219dee3ae348b86642574ecc90c" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_complete" -version = "4.5.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e3040c8291884ddf39445dc033c70abc2bc44a42f0a3a00571a0f483a83f0cd" -dependencies = [ - "clap", -] - -[[package]] -name = "clap_complete_command" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183495371ea78d4c9ff638bfc6497d46fed2396e4f9c50aebc1278a4a9919a3d" -dependencies = [ - "clap", - "clap_complete", - "clap_complete_fig", - "clap_complete_nushell", -] - -[[package]] -name = "clap_complete_fig" -version = "4.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d494102c8ff3951810c72baf96910b980fb065ca5d3101243e6a8dc19747c86b" -dependencies = [ - "clap", - "clap_complete", -] - -[[package]] -name = "clap_complete_nushell" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d02bc8b1a18ee47c4d2eec3fb5ac034dc68ebea6125b1509e9ccdffcddce66e" -dependencies = [ - "clap", - "clap_complete", -] - -[[package]] -name = "clap_derive" -version = "4.5.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" - -[[package]] -name = "colorchoice" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" - -[[package]] -name = "errno" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "filetags" -version = "0.1.0" -dependencies = [ - "clap", - "clap_complete_command", - "fs-err", - "tempfile", - "thiserror", -] - -[[package]] -name = "fs-err" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" -dependencies = [ - "autocfg", -] - -[[package]] -name = "getrandom" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" -dependencies = [ - "cfg-if", - "libc", - "wasi", - "windows-targets", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" - -[[package]] -name = "libc" -version = "0.2.170" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" - -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "once_cell" -version = "1.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" - -[[package]] -name = "proc-macro2" -version = "1.0.93" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "syn" -version = "2.0.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tempfile" -version = "3.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230" -dependencies = [ - "cfg-if", - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "unicode-ident" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "wasi" -version = "0.13.3+wasi-0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" -dependencies = [ - "wit-bindgen-rt", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen-rt" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" -dependencies = [ - "bitflags", -] diff --git a/Cargo.toml b/Cargo.toml index e44349d..5255f6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,8 @@ [package] -name = "filetags" +name = "filetags_rs" version = "0.1.0" edition = "2021" [dependencies] thiserror = "1.0" clap = { version = "4.4", features = ["derive"] } -clap_complete_command = "0.5.1" -fs-err = "2.11" - -[dev-dependencies] -tempfile = "3.8" diff --git a/README.md b/README.md deleted file mode 100644 index 5082c30..0000000 --- a/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# filetags-rs - -A command-line tool for managing tags in file and directory names. Tags are embedded directly in filenames using a specific delimiter pattern. - -## Installation -This is a nix flake :) - -## Usage - -Tags in filenames use the following format: -``` -filename -- tag1 tag2 tag3.ext -``` - -Where: -- Tag delimiter is ` -- ` (space, double dash, space) -- Tags are separated by spaces -- Tags are stored in alphabetical order -- Tags cannot contain spaces, NUL, ":", or "/" - -### Commands - -#### List Tags -List all unique tags found in the specified files: - -```bash -filetags list "document -- tag1 tag2.pdf" "notes -- tag2 tag3.txt" -# Output: -# tag1 -# tag2 -# tag3 -``` - -#### Add Tags -Add one or more tags to files: - -```bash -filetags add --tag=work --tag=draft document.pdf -# Result: document -- draft work.pdf -``` - -#### Remove Tags -Remove specified tags from files: - -```bash -filetags remove --tag=draft "document -- draft work.pdf" -# Result: document -- work.pdf -``` - -#### Create Tag Tree -Create a directory tree with symlinks based on file tags: - -```bash -filetags tree --dir=./tagged --depth=2 "document -- tag1 tag2.pdf" -``` - -This creates a directory structure like: -``` -tagged/ -├── document.pdf -> /path/to/document -- tag1 tag2.pdf -├── tag1/ -│ ├── document.pdf -> /path/to/document -- tag1 tag2.pdf -│ └── tag2/ -│ └── document.pdf -> /path/to/document -- tag1 tag2.pdf -└── tag2/ - ├── document.pdf -> /path/to/document -- tag1 tag2.pdf - └── tag1/ - └── document.pdf -> /path/to/document -- tag1 tag2.pdf -``` - -#### Shell Completions - -Generate shell completions for your preferred shell: - -```bash -# For fish shell -filetags completion fish > ~/.config/fish/completions/filetags.fish - -# For bash -filetags completion bash > ~/.local/share/bash-completion/completions/filetags - -# For zsh -filetags completion zsh > ~/.zsh/completions/_filetags -``` - -## Error Handling - -The tool provides clear error messages for: -- Invalid characters in tags -- Empty tags -- Missing arguments -- File permission issues -- Symlink creation failures -- Directory creation failures - -## License - -GPL-3.0-or-later diff --git a/flake.nix b/flake.nix index 0db4ae8..a54d6b0 100644 --- a/flake.nix +++ b/flake.nix @@ -13,7 +13,7 @@ inherit (pkgs) lib; pkgs = import nixpkgs { inherit system; }; fenix = inputs.fenix.packages.${system}; - craneLib = (crane.mkLib pkgs).overrideToolchain toolchain.toolchain; + craneLib = crane.lib.${system}.overrideToolchain toolchain.toolchain; mkSrc = extraPaths: with lib.fileset; let root = ./.; rustFiles = fromSource (craneLib.cleanCargoSource root); @@ -38,15 +38,6 @@ ]; # Additional environment variables can be set directly # MY_CUSTOM_VAR = "some value"; - - nativeBuildInputs = with pkgs; [ installShellFiles ]; - meta.mainProgram = "filetags"; - postInstall = '' - installShellCompletion --cmd timers \ - --bash <($out/bin/filetags completions bash) \ - --fish <($out/bin/filetags completions fish) \ - --zsh <($out/bin/filetags completions zsh) \ - ''; }; devShells.default = pkgs.mkShell.override { inherit stdenv; } diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 2a30b64..0000000 --- a/src/error.rs +++ /dev/null @@ -1,94 +0,0 @@ -use thiserror::Error; -use std::path::PathBuf; - -#[derive(Error, Debug)] -pub enum FileTagsError { - #[error("Failed to create directory {path}: {source}")] - CreateDir { - path: PathBuf, - source: std::io::Error, - }, - - #[error("Failed to create symlink from {from} to {to}: {source}")] - CreateLink { - from: PathBuf, - to: PathBuf, - source: std::io::Error, - }, - - #[error("Invalid path: {0}")] - InvalidPath(PathBuf), - - #[error("Failed to rename {from} to {to}: {source}")] - Rename { - from: PathBuf, - to: PathBuf, - source: std::io::Error, - }, - - #[error("Failed to parse tags in {file}: {source}")] - Parse { - file: PathBuf, - source: ParseError, - }, - - #[error("Tag error: {0}")] - Tag(#[from] TagError), -} - -#[derive(Error, Debug, PartialEq)] -pub enum TagError { - #[error("tag cannot be empty")] - Empty, - #[error("tag contains invalid character: {0}")] - InvalidChar(char), -} - -#[derive(Error, Debug, PartialEq)] -pub enum ParseError { - #[error("multiple tag delimiters found")] - MultipleDelimiters, - #[error("invalid tag: {0}")] - InvalidTag(#[from] TagError), -} - -impl From for FileTagsError { - fn from(err: ParseError) -> Self { - FileTagsError::Parse { - file: PathBuf::from(""), - source: err, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::error::Error; - - #[test] - fn test_error_conversion() { - let tag_err = TagError::Empty; - let parse_err = ParseError::InvalidTag(tag_err); - let file_err = FileTagsError::from(parse_err); - - assert!(matches!(file_err, FileTagsError::Parse { .. })); - } - - #[test] - fn test_error_display() { - let err = FileTagsError::InvalidPath(PathBuf::from("/bad/path")); - assert_eq!(err.to_string(), "Invalid path: /bad/path"); - } - - #[test] - fn test_error_source() { - let io_err = std::io::Error::from(std::io::ErrorKind::NotFound); - let err = FileTagsError::CreateDir { - path: PathBuf::from("/test"), - source: io_err, - }; - - assert!(err.source().is_some()); - } -} diff --git a/src/main.rs b/src/main.rs index a213b20..a44e53c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,212 +1,36 @@ -use clap::CommandFactory; -mod error; -mod symlink; mod tag_engine; -use crate::error::FileTagsError; -use clap::{Parser, Subcommand}; -use fs_err as fs; use std::collections::BTreeSet; -use std::path::PathBuf; +use std::error::Error; +use clap::Parser; #[derive(Parser)] #[command(author, version, about, long_about = None)] struct Cli { - #[command(subcommand)] - command: Commands, + /// Files to process + #[arg(required = true)] + files: Vec, } -#[derive(Subcommand)] -#[command(subcommand_required = true)] -enum Commands { - /// Generate shell completions - Completions { - #[arg(value_enum)] - shell: clap_complete_command::Shell, - }, - /// List all unique tags found in files - List { - /// Files to process - #[arg(required = true, help = "One or more files to process")] - files: Vec, - }, - /// Add tags to files - Add { - /// Tags to add - #[arg(long = "tag", required = true, help = "Tags to add to files")] - tags: Vec, - /// Files to process - #[arg(required = true, help = "One or more files to add tags to")] - files: Vec, - }, - /// Remove tags from files - Remove { - /// Tags to remove - #[arg(long = "tag", required = true, help = "Tags to remove from files")] - tags: Vec, - /// Files to process - #[arg(required = true, help = "One or more files to remove tags from")] - files: Vec, - }, - /// Create a tag-based directory tree with symlinks - Tree { - /// Target directory for the tree - #[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" - )] - depth: usize, - /// Files to process - #[arg(required = true, help = "One or more files to create tree from")] - files: Vec, - }, -} - -fn list_tags(files: &[String]) -> Result, FileTagsError> { +fn list_tags(files: &[String]) -> Result, Box> { let mut unique_tags = BTreeSet::new(); 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, - }) - } + if let Ok((_, tags, _)) = tag_engine::parse_tags(file) { + unique_tags.extend(tags); } } Ok(unique_tags.into_iter().collect()) } -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 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| FileTagsError::Rename { - from: PathBuf::from(file), - to: PathBuf::from(&new_path), - source: e, - })?; - } - - Ok(()) -} - -fn main() -> Result<(), FileTagsError> { +fn main() -> Result<(), Box> { let cli = Cli::parse(); - - match cli.command { - Commands::Completions { shell } => { - shell.generate(&mut Cli::command(), &mut std::io::stdout()); - } - 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)?; - } - } - Commands::Remove { tags, files } => { - for file in files { - remove_tags_from_file(&file, &tags)?; - } - } - Commands::Tree { dir, depth, files } => { - create_tag_tree(&files, &dir, depth)?; - } - } - - Ok(()) -} - -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 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() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(); - - let new_path = if parent.is_empty() { - new_filename - } else { - format!("{}/{}", parent, new_filename) - }; - - // Only rename if tags actually changed - if file != new_path { - fs::rename(file, &new_path).map_err(|e| FileTagsError::Rename { - from: PathBuf::from(file), - to: PathBuf::from(&new_path), - source: e, - })?; - } - - Ok(()) -} - -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, - })?; - - // Create root symlink - let paths = vec![PathBuf::from(file)]; - symlink::create_symlink_tree(paths, &target)?; - - // Generate all tag combinations and create directory structure - let combinations = tag_engine::create_tag_combinations(&tags, depth); - for combo in combinations { - let mut dir_path = target.clone(); - for tag in &combo { - dir_path.push(tag); - } - - let paths = vec![PathBuf::from(file)]; - symlink::create_symlink_tree(paths, &dir_path)?; - } + + let tags = list_tags(&cli.files)?; + + for tag in tags { + println!("{}", tag); } Ok(()) @@ -215,17 +39,6 @@ fn create_tag_tree(files: &[String], target_dir: &str, depth: usize) -> Result<( #[cfg(test)] mod tests { use super::*; - use std::error::Error; - 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() { @@ -252,173 +65,12 @@ mod tests { } #[test] - 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(()) - } - - #[test] - fn test_remove_tags() -> Result<(), Box> { - 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(()) - } - - #[test] - fn test_create_tag_tree() -> Result<(), Box> { - 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()); - - Ok(()) - } - - #[test] - fn test_integration_all_commands() -> Result<(), Box> { - 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(), - ])?; - 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(); - 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()], - tree_dir.to_str().unwrap(), - 2, - )?; - - assert!(tree_dir.join("work").join("doc1 -- work.txt").exists()); - - Ok(()) + 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"]); } } diff --git a/src/symlink.rs b/src/symlink.rs deleted file mode 100644 index 1ab47fa..0000000 --- a/src/symlink.rs +++ /dev/null @@ -1,117 +0,0 @@ -use std::path::{Path, PathBuf}; -use fs_err as fs; -use crate::error::FileTagsError; - -pub fn create_symlink_tree(paths: Vec, target_dir: &Path) -> Result<(), FileTagsError> { - for path in paths { - // Ensure path is absolute and clean - let abs_path = fs::canonicalize(&path).map_err(|_| { - FileTagsError::InvalidPath(path.clone()) - })?; - - // Get the file name for the symlink - let file_name = abs_path.file_name().ok_or_else(|| { - FileTagsError::InvalidPath(abs_path.clone()) - })?; - - // Create target directory if it doesn't exist - fs::create_dir_all(target_dir).map_err(|e| FileTagsError::CreateDir { - path: target_dir.to_path_buf(), - source: e, - })?; - - // Create the symlink - let link_path = target_dir.join(file_name); - #[cfg(unix)] - std::os::unix::fs::symlink(&abs_path, &link_path).map_err(|e| FileTagsError::CreateLink { - from: abs_path, - to: link_path, - source: e, - })?; - - #[cfg(windows)] - std::os::windows::fs::symlink_file(&abs_path, &link_path).map_err(|e| FileTagsError::CreateLink { - from: abs_path, - to: link_path, - source: e, - })?; - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_create_symlink_tree_basic() -> Result<(), Box> { - let source_dir = TempDir::new()?; - let target_dir = TempDir::new()?; - - // Create a test file - let test_file = source_dir.path().join("test.txt"); - fs::write(&test_file, "test content")?; - - // Create symlink tree - create_symlink_tree( - vec![test_file.clone()], - target_dir.path() - )?; - - // Verify symlink exists and points to correct file - let symlink = target_dir.path().join("test.txt"); - assert!(symlink.exists()); - assert!(symlink.is_symlink()); - - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - assert_eq!( - fs::metadata(&test_file)?.ino(), - fs::metadata(&symlink)?.ino() - ); - } - - Ok(()) - } - - #[test] - fn test_create_symlink_tree_nested() -> Result<(), Box> { - let source_dir = TempDir::new()?; - let target_dir = TempDir::new()?; - - // Create nested test file - let nested_dir = source_dir.path().join("nested"); - fs::create_dir_all(&nested_dir)?; - let test_file = nested_dir.join("test.txt"); - fs::write(&test_file, "test content")?; - - // Create symlink tree - create_symlink_tree( - vec![test_file], - &target_dir.path().join("nested") - )?; - - // Verify directory and symlink were created - let symlink = target_dir.path().join("nested/test.txt"); - assert!(symlink.exists()); - assert!(symlink.is_symlink()); - - Ok(()) - } - - #[test] - fn test_create_symlink_tree_invalid_path() { - let target_dir = TempDir::new().unwrap(); - - // Try to create symlink with non-existent source - let result = create_symlink_tree( - vec![PathBuf::from("/nonexistent/path")], - target_dir.path() - ); - - assert!(matches!(result, Err(FileTagsError::InvalidPath(_)))); - } -} diff --git a/src/tag_engine.rs b/src/tag_engine.rs index 89503ff..327fa09 100644 --- a/src/tag_engine.rs +++ b/src/tag_engine.rs @@ -1,4 +1,20 @@ -use crate::error::{ParseError, TagError}; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum TagError { + #[error("tag cannot be empty")] + Empty, + #[error("tag contains invalid character: {0}")] + InvalidChar(char), +} + +#[derive(Error, Debug, PartialEq)] +pub enum ParseError { + #[error("multiple tag delimiters found")] + MultipleDelimiters, + #[error("invalid tag: {0}")] + InvalidTag(#[from] TagError), +} pub fn validate_tag(tag: &str) -> Result<(), TagError> { if tag.is_empty() { @@ -18,13 +34,7 @@ pub fn validate_tag(tag: &str) -> Result<(), TagError> { pub const TAG_DELIMITER: &str = " -- "; pub fn parse_tags(filename: &str) -> Result<(String, Vec, String), ParseError> { - // 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(); + let parts: Vec<&str> = filename.split(TAG_DELIMITER).collect(); if parts.len() > 2 { return Err(ParseError::MultipleDelimiters); @@ -32,42 +42,23 @@ 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 mut extension = match base_parts.len() { - 2 => format!(".{}", base_parts[0]), - _ => String::new(), + 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 base_name = base_parts.last().unwrap_or(&parts[0]).to_string(); let tags = if parts.len() == 2 { - 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()); - } + 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)?; } - // First parse all tags - let parsed_tags: Vec = tag_part - .split_whitespace() - .map(|tag| { - validate_tag(tag)?; - Ok(tag.to_string()) - }) - .collect::>()?; - - // Then filter duplicates using a HashSet - let unique_tags: Vec = parsed_tags - .into_iter() - .collect::>() - .into_iter() - .collect(); - - // Finally sort - let mut tags = unique_tags; - tags.sort(); tags } else { Vec::new() @@ -76,48 +67,6 @@ pub fn parse_tags(filename: &str) -> Result<(String, Vec, String), Parse Ok((base_name, tags, extension)) } -pub fn create_tag_combinations(tags: &[String], depth: usize) -> Vec> { - let mut result = Vec::new(); - - // Handle empty tags or depth 0 - if tags.is_empty() || depth == 0 { - return result; - } - - // Add individual tags first - for tag in tags { - result.push(vec![tag.clone()]); - } - - // Generate combinations up to specified depth - for len in 2..=depth.min(tags.len()) { - let mut temp = Vec::new(); - - // Start with existing combinations of length-1 - for combo in result.iter().filter(|c| c.len() == len - 1) { - // Try to add each remaining tag that comes after the last tag in combo - if let Some(last) = combo.last() { - for tag in tags { - if tag > last && !combo.contains(tag) { - let mut new_combo = combo.clone(); - new_combo.push(tag.clone()); - temp.push(new_combo); - } - } - } - } - result.extend(temp); - } - - result -} - -pub fn filter_tags(current: Vec, remove: &[String]) -> Vec { - current.into_iter() - .filter(|tag| !remove.contains(tag)) - .collect() -} - pub fn add_tags(current: Vec, new: Vec) -> Vec { let mut result = current; @@ -267,84 +216,4 @@ 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"); - } - - #[test] - fn test_filter_tags_multi_remove() { - let current = vec!["tag1".to_string(), "tag2".to_string(), "tag3".to_string()]; - let remove = vec!["tag1".to_string(), "tag3".to_string()]; - let result = filter_tags(current, &remove); - assert_eq!(result, vec!["tag2"]); - } - - #[test] - fn test_filter_tags_non_existent() { - let current = vec!["tag1".to_string(), "tag2".to_string()]; - let remove = vec!["tag3".to_string()]; - let result = filter_tags(current, &remove); - assert_eq!(result, vec!["tag1", "tag2"]); - } - - #[test] - fn test_filter_tags_empty_result() { - let current = vec!["tag1".to_string(), "tag2".to_string()]; - let remove = vec!["tag1".to_string(), "tag2".to_string()]; - let result = filter_tags(current, &remove); - assert!(result.is_empty()); - } - - #[test] - fn test_create_tag_combinations_empty() { - let tags: Vec = vec![]; - let result = create_tag_combinations(&tags, 2); - assert!(result.is_empty()); - } - - #[test] - fn test_create_tag_combinations_depth_limit() { - let tags = vec!["tag1".to_string(), "tag2".to_string(), "tag3".to_string()]; - let result = create_tag_combinations(&tags, 2); - - // Should contain individual tags and pairs, but no triples - assert!(result.iter().all(|combo| combo.len() <= 2)); - assert_eq!(result.len(), 6); // 3 individual + 3 pairs - } - - #[test] - fn test_create_tag_combinations_order() { - let tags = vec!["tag2".to_string(), "tag1".to_string(), "tag3".to_string()]; - let result = create_tag_combinations(&tags, 2); - - // Check that all combinations maintain alphabetical order - for combo in result { - if combo.len() > 1 { - assert!(combo.windows(2).all(|w| w[0] < w[1])); - } - } - } - - #[test] - fn test_create_tag_combinations_uniqueness() { - let tags = vec!["tag1".to_string(), "tag2".to_string(), "tag3".to_string()]; - let result = create_tag_combinations(&tags, 3); - - // Convert to set to check for duplicates - let result_set: std::collections::HashSet<_> = result.into_iter().collect(); - assert_eq!(result_set.len(), 7); // 3 individual + 3 pairs + 1 triple - } }