Compare commits
No commits in common. "6aa46041df77cc2e5c923c92150ec9d7e02760b7" and "9bea7c905341106e75c618a04a1c6bb214b86769" have entirely different histories.
6aa46041df
...
9bea7c9053
8 changed files with 53 additions and 1276 deletions
421
Cargo.lock
generated
421
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
98
README.md
98
README.md
|
|
@ -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
|
||||
11
flake.nix
11
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; }
|
||||
|
|
|
|||
94
src/error.rs
94
src/error.rs
|
|
@ -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<ParseError> for FileTagsError {
|
||||
fn from(err: ParseError) -> Self {
|
||||
FileTagsError::Parse {
|
||||
file: PathBuf::from("<unknown>"),
|
||||
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());
|
||||
}
|
||||
}
|
||||
390
src/main.rs
390
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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
},
|
||||
/// Add tags to files
|
||||
Add {
|
||||
/// Tags to add
|
||||
#[arg(long = "tag", required = true, help = "Tags to add to files")]
|
||||
tags: Vec<String>,
|
||||
/// Files to process
|
||||
#[arg(required = true, help = "One or more files to add tags to")]
|
||||
files: Vec<String>,
|
||||
},
|
||||
/// Remove tags from files
|
||||
Remove {
|
||||
/// Tags to remove
|
||||
#[arg(long = "tag", required = true, help = "Tags to remove from files")]
|
||||
tags: Vec<String>,
|
||||
/// Files to process
|
||||
#[arg(required = true, help = "One or more files to remove tags from")]
|
||||
files: Vec<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn list_tags(files: &[String]) -> Result<Vec<String>, FileTagsError> {
|
||||
fn list_tags(files: &[String]) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
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<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() {
|
||||
|
|
@ -252,173 +65,12 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
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(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_tag_tree() -> Result<(), Box<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
117
src/symlink.rs
117
src/symlink.rs
|
|
@ -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<PathBuf>, 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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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(_))));
|
||||
}
|
||||
}
|
||||
|
|
@ -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>, 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>, 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<String> = 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<String> = tag_part
|
||||
.split_whitespace()
|
||||
.map(|tag| {
|
||||
validate_tag(tag)?;
|
||||
Ok(tag.to_string())
|
||||
})
|
||||
.collect::<Result<_, TagError>>()?;
|
||||
|
||||
// Then filter duplicates using a HashSet
|
||||
let unique_tags: Vec<String> = parsed_tags
|
||||
.into_iter()
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.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>, String), Parse
|
|||
Ok((base_name, tags, extension))
|
||||
}
|
||||
|
||||
pub fn create_tag_combinations(tags: &[String], depth: usize) -> Vec<Vec<String>> {
|
||||
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<String>, remove: &[String]) -> Vec<String> {
|
||||
current.into_iter()
|
||||
.filter(|tag| !remove.contains(tag))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn add_tags(current: Vec<String>, new: Vec<String>) -> Vec<String> {
|
||||
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<String> = 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue