Skip to content

Commit 338886f

Browse files
committed
add path tranfer
1 parent 220cbde commit 338886f

12 files changed

Lines changed: 694 additions & 54 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "filo-rs"
3-
version = "0.4.1"
3+
version = "0.4.2"
44
authors = ["Kanishk <kanishk0422@gmail.com>"]
55
edition = "2021"
66
rust-version = "1.85"

docs/toml-config-reference.md

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ folder_name = "Duplicates"
4545
# rules = [
4646
# { to = "Amazon", keywords = ["amazon"] },
4747
# { to = "Documents", keywords = ["invoice", "receipt", "statement"] },
48+
# { to = "~/personal/kanishk-itr", to_type = "path", keywords = ["itr"] },
4849
# ]
4950

5051
[rules]
@@ -71,6 +72,9 @@ Videos = ["mp4", "mkv", "mov", "avi", "webm", "flv", "wmv", "m4v"]
7172
| `duplicates.action` | `"skip" \| "move"` | `"skip"` | `scan`, `preview`, `start` |
7273
| `duplicates.folder_name` | `string` | `"Duplicates"` | `scan`, `preview`, `start` (when `duplicates.action="move"`) |
7374
| `keyword_rules.rules` | `array<table>` | `[]` | `scan`, `preview`, `start` |
75+
| `keyword_rules.rules[].to` | `string` | (required) | `scan`, `preview`, `start` |
76+
| `keyword_rules.rules[].to_type` | `"folder" \| "path"` | `"folder"` | `scan`, `preview`, `start` |
77+
| `keyword_rules.rules[].keywords` | `array<string>` | `[]` | `scan`, `preview`, `start` |
7478
| `rules.<Category>` | `array<string(extension)>` | built-in per category | `scan`, `preview`, `start` |
7579

7680
## Detailed reference
@@ -198,13 +202,64 @@ All fields in this section are ignored unless `rename.enabled = true`.
198202
- Empty `to` values and empty keywords are ignored.
199203
- If no keyword rule matches, routing falls through to `[rules]`.
200204
- Rule fields:
201-
- `to` (`string`): destination category/folder name.
205+
- `to` (`string`): destination folder name, or path when `to_type = "path"`.
206+
- `to_type` (`"folder" | "path"`): how to read `to`. See below.
202207
- `keywords` (`array<string>`): match keywords for that rule.
203208
- Example:
204209
`[keyword_rules]` with
205210
`rules = [{ to = "Documents", keywords = ["invoice", "receipt"] }]`
206211
routes `May_RECEIPT.png` to `Documents` with first-match precedence.
207212

213+
#### `keyword_rules.rules[].to_type`
214+
215+
- Type: enum string
216+
- Allowed values: `"folder"`, `"path"`
217+
- Default: `"folder"`
218+
- Also accepted as: `destination_type` (alias)
219+
- Meaning:
220+
Whether the rule's `to` value is a folder name to create inside the watch
221+
root, or a filesystem path of its own.
222+
- Runtime behavior:
223+
- `"folder"`: destination is `<watch-root>/<to>/`. Unchanged historical
224+
behavior, applied whenever `to_type` is absent.
225+
- `"path"`: destination is `to` itself, resolved as follows:
226+
1. A leading `~` (or `~\` on Windows) expands to the home directory. A
227+
`~` anywhere else in the string is left literal. Environment variables
228+
are never expanded.
229+
2. If the result is absolute, it is used as-is.
230+
3. If the result is still relative, it is joined onto the watch root —
231+
so `to = "tax/2026"` from `~/Downloads` means `~/Downloads/tax/2026/`.
232+
- Missing destination directories (including parents) are created on the
233+
first move, not at config load.
234+
- Duplicate detection, collision-safe naming, and
235+
`duplicates.action = "move"` all operate on the resolved destination, so
236+
duplicates land in `<resolved-path>/<duplicates.folder_name>/`.
237+
- If a rule resolves to the directory the file already sits in, the file is
238+
left untouched and reported as `already in place` (`keep` in `preview`).
239+
This is checked against resolved paths, so symlinked and `.`-laden
240+
spellings of the same directory are recognized.
241+
- Only keyword rules support `to_type`. Extension rules in `[rules]` and the
242+
`other_category` fallback always route to a folder under the watch root.
243+
- Notes:
244+
- `to_type = "folder"` is never written back to disk by `filo init`, since
245+
it is the default. Existing configs keep working with no edits.
246+
- `~` expansion needs a discoverable home directory. If there is none, the
247+
`~` stays literal and a warning is logged, which surfaces as a folder
248+
actually named `~`.
249+
- Example:
250+
```toml
251+
[keyword_rules]
252+
rules = [
253+
{ to = "~/personal/kanishk-itr", to_type = "path", keywords = ["itr"] },
254+
{ to = "/mnt/archive/scans", to_type = "path", keywords = ["scan"] },
255+
{ to = "tax/2026", to_type = "path", keywords = ["form16"] },
256+
{ to = "Amazon", keywords = ["amazon"] },
257+
]
258+
```
259+
Watching `~/Downloads`, `ITR-kanishk-2026.pdf` moves to
260+
`~/personal/kanishk-itr/`, `form16.pdf` to `~/Downloads/tax/2026/`, and
261+
`amazon-order.pdf` to `~/Downloads/Amazon/`.
262+
208263
### `[rules]`
209264

210265
- Type: map/table of category name -> extension list
@@ -237,6 +292,8 @@ These are the built-in defaults used when no custom rules are set:
237292
- `arrange` command filters (`--keyword`, `--extension`) are CLI options, not TOML (separate from `[keyword_rules].rules`, which affects `scan`/`preview`/`start`).
238293
- `scan`/`preview` depth is currently top-level only.
239294
- `start` watcher is currently non-recursive.
295+
- Keyword rules match on filename keywords only. Matching a keyword *and* an extension in one rule is not expressible in TOML today; use `filo arrange -k <keyword> -e <ext> -d <path>` for that.
296+
- Path destinations are not watched implicitly. If you want files that land in a `to_type = "path"` destination to be organized further, add that path to `watch.folders` yourself.
240297

241298
## Extending this document for future TOML options
242299

readme.md

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ later they're impossible to navigate. Manual cleanup is a chore nobody does.
5151

5252
It is deliberately not clever. It does not look inside files, call machine
5353
learning models, or invent names. It routes by keyword/extension, dedupes by
54-
SHA-256, and moves things into subfolders. That's the whole product — and
55-
that's the point.
54+
SHA-256, and moves things into subfolders — or into any path you name. That's
55+
the whole product — and that's the point.
5656

5757
---
5858

@@ -323,8 +323,11 @@ folder_name = "Duplicates"
323323
# Optional ordered keyword routing in one block (first match wins):
324324
[keyword_rules]
325325
rules = [
326+
# Default: `to` is a folder name, created inside each watched folder.
326327
{ to = "Amazon", keywords = ["amazon"] },
327328
{ to = "Documents", keywords = ["invoice", "receipt", "statement"] },
329+
# `to_type = "path"` sends matches to a path of your choosing instead.
330+
{ to = "~/personal/kanishk-itr", to_type = "path", keywords = ["itr"] },
328331
]
329332

330333
[rules]
@@ -339,6 +342,55 @@ Images = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff", "heic",
339342
Videos = ["mp4", "mkv", "mov", "avi", "webm", "flv", "wmv", "m4v"]
340343
```
341344

345+
### Sending matches to a path instead of a folder
346+
347+
By default a keyword rule's `to` is a **folder name**. filo creates that
348+
folder inside whichever watched folder the file came from, so
349+
`to = "kanishk-itr"` lands files in `~/Downloads/kanishk-itr/`.
350+
351+
Add `to_type = "path"` to the rule and `to` becomes a **path** instead, so
352+
matched files can leave the watched folder entirely:
353+
354+
```toml
355+
[keyword_rules]
356+
rules = [
357+
# ~/Downloads/kanishk-itr/ (folder inside the watched folder — the default)
358+
{ to = "kanishk-itr", keywords = ["itr"] },
359+
360+
# ~/personal/kanishk-itr/ (anywhere you like)
361+
{ to = "~/personal/kanishk-itr", to_type = "path", keywords = ["itr"] },
362+
]
363+
```
364+
365+
`to_type` accepts exactly two values:
366+
367+
| Value | Meaning |
368+
|------------|----------------------------------------------------------------------|
369+
| `"folder"` | `to` is a folder name; destination is `<watched-folder>/<to>`. Default when `to_type` is omitted. |
370+
| `"path"` | `to` is a path; destination is that path. |
371+
372+
Details worth knowing:
373+
374+
- **`~` expands** to your home directory, so one config works across machines
375+
and users. `$HOME` and other environment variables are **not** expanded.
376+
- **Relative paths are anchored to the watched folder.** With
377+
`to_type = "path"` and `to = "tax/2026"`, files from `~/Downloads` land in
378+
`~/Downloads/tax/2026/` — which is how you nest more than one level deep.
379+
- **Missing folders are created** on the first move, including parents.
380+
- **Absolute paths work as written**, including Windows paths like
381+
`C:\\Users\\you\\personal\\itr`.
382+
- **Every safety guarantee still applies.** Nothing is overwritten, duplicate
383+
detection runs against the path destination, and duplicates land in
384+
`<path>/Duplicates/` when `duplicates.action = "move"`.
385+
- **A rule pointing at the folder the file is already in is a no-op.** filo
386+
reports it as `already in place` and leaves the file alone rather than
387+
shuffling it around or treating it as a duplicate of itself.
388+
- Only keyword rules can target a path. Extension rules in `[rules]` always
389+
route to a folder inside the watched folder.
390+
391+
Run `filo preview` after editing rules — it prints the full destination path
392+
for every file, so you can confirm the routing before anything moves.
393+
342394
---
343395

344396
## Safety guarantees
@@ -377,17 +429,22 @@ stops right before any file is touched. The output looks like:
377429
== /Users/you/Downloads ==
378430
move /Users/you/Downloads/invoice.pdf -> /Users/you/Downloads/Documents/invoice.pdf
379431
move /Users/you/Downloads/cat.jpg -> /Users/you/Downloads/Images/cat.jpg
432+
move /Users/you/Downloads/ITR-2026.pdf -> /Users/you/personal/kanishk-itr/ITR-2026.pdf
380433
skip /Users/you/Downloads/cat-copy.jpg (duplicate of /Users/you/Downloads/Images/cat.jpg)
381434
dup -> /Users/you/Downloads/old.pdf -> /Users/you/Downloads/Documents/Duplicates/old.pdf (duplicate of ...)
382435
383-
Preview summary (4 files examined):
384-
would move: 2
436+
Preview summary (5 files examined):
437+
would move: 3
385438
would skip (duplicate): 1
386439
would move to Duplicates: 1
387440
388441
No changes were made. Run `filo scan` to apply.
389442
```
390443

444+
Destinations are always printed in full, so a `to_type = "path"` rule shows
445+
exactly where the file leaves for. A file whose rule resolves to the folder it
446+
already sits in shows as `keep` and is counted under `already in place`.
447+
391448
Because the planner is pure, you can:
392449

393450
- Preview → read the plan → edit `config.toml` → preview again, risk-free.

scripts/comprehensive_e2e_test.sh

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ WATCH_B=""
4040
WATCH_C=""
4141
ARRANGE_SRC=""
4242
ARRANGE_DEST=""
43+
# Destination of a `to_type = "path"` keyword rule. Deliberately outside every
44+
# watched folder, and deliberately not pre-created — filo must create it.
45+
PATH_DEST=""
4346

4447
log() {
4548
printf '\n[%s] %s\n' "$(date '+%H:%M:%S')" "$*"
@@ -161,8 +164,10 @@ backup_existing_config() {
161164
write_comprehensive_config() {
162165
local watch_a_esc
163166
local watch_b_esc
167+
local path_dest_esc
164168
watch_a_esc="$(toml_escape "${WATCH_A}")"
165169
watch_b_esc="$(toml_escape "${WATCH_B}")"
170+
path_dest_esc="$(toml_escape "${PATH_DEST}")"
166171

167172
# Keep this section as the single source of truth for evolving feature tests.
168173
cat > "${CONFIG_PATH}" <<EOF
@@ -188,6 +193,11 @@ folder_name = "Duplicates"
188193
189194
[keyword_rules]
190195
rules = [
196+
# Absolute path destination, outside every watched folder.
197+
{ to = "${path_dest_esc}", to_type = "path", keywords = ["itr"] },
198+
# Relative path destination, anchored to whichever folder the file came from.
199+
{ to = "nested/tax", to_type = "path", keywords = ["form16"] },
200+
# Folder destinations (the default) must keep working unchanged.
191201
{ to = "Amazon", keywords = ["amazon"] },
192202
{ to = "Finance", keywords = ["invoice", "receipt", "statement"] },
193203
{ to = "Travel", keywords = ["ticket", "boarding"] },
@@ -331,6 +341,10 @@ seed_scan_fixtures() {
331341
printf 'fixture-b\n' > "${WATCH_A}/Monthly_receipt.png"
332342
printf 'fixture-c\n' > "${WATCH_A}/plain_photo.JPG"
333343

344+
# Path-destination assertions (to_type = "path").
345+
printf 'fixture-itr\n' > "${WATCH_A}/kanishk_itr_2026.pdf"
346+
printf 'fixture-form16\n' > "${WATCH_A}/form16_summary.pdf"
347+
334348
# Duplicate handling assertions (action = move).
335349
printf 'fixture-dup\n' > "${WATCH_A}/dup_source_one.pdf"
336350
printf 'fixture-dup\n' > "${WATCH_A}/dup_source_two.pdf"
@@ -376,6 +390,8 @@ main() {
376390
WATCH_C="$(cd "${WATCH_C}" && pwd -P)"
377391
ARRANGE_SRC="$(cd "${ARRANGE_SRC}" && pwd -P)"
378392
ARRANGE_DEST="$(cd "${ARRANGE_DEST}" && pwd -P)"
393+
# Left uncreated on purpose: the first move must create it.
394+
PATH_DEST="$(cd "${TEST_ROOT}" && pwd -P)/external_itr"
379395

380396
log "1) Uninstall existing filo binary (if present)"
381397
cargo uninstall filo >/dev/null 2>&1 || true
@@ -410,6 +426,12 @@ main() {
410426
assert_file "${WATCH_A}/Amazon/amazon-invoice-may.pdf"
411427
assert_file "${WATCH_A}/Finance/monthly-receipt.png"
412428
assert_file "${WATCH_A}/Images/plain-photo.jpg"
429+
430+
# Path destinations: absolute path outside the watch root, created on demand.
431+
assert_dir "${PATH_DEST}"
432+
assert_file "${PATH_DEST}/kanishk-itr-2026.pdf"
433+
# Relative path destination, anchored to the watched folder it came from.
434+
assert_file "${WATCH_A}/nested/tax/form16-summary.pdf"
413435
local dup_primary_count dup_duplicate_count
414436
dup_primary_count="$(find "${WATCH_A}/Documents" -maxdepth 1 -type f \( -name 'dup-source-one*.pdf' -o -name 'dup-source-two*.pdf' \) | wc -l | tr -d ' ')"
415437
dup_duplicate_count="$(find "${WATCH_A}/Documents/Duplicates" -maxdepth 1 -type f \( -name 'dup-source-one*.pdf' -o -name 'dup-source-two*.pdf' \) | wc -l | tr -d ' ')"
@@ -467,13 +489,18 @@ main() {
467489
printf 'live-amazon\n' > "${WATCH_B}/amazon_invoice_live.pdf"
468490
printf 'live-dup\n' > "${WATCH_B}/live_dup_one.pdf"
469491
printf 'live-dup\n' > "${WATCH_B}/live_dup_two.pdf"
492+
# Same absolute path rule, this time from the *other* watched folder.
493+
printf 'live-itr\n' > "${WATCH_B}/kanishk_itr_live.pdf"
470494

471495
generate_bulk_files "${WATCH_A}" "live_a" "${LIVE_COUNT}"
472496
generate_bulk_files "${WATCH_B}" "live_b" "${LIVE_COUNT}"
473497

474498
wait_for_file "${WATCH_B}/Amazon/amazon-invoice-live.pdf" 120 || {
475499
die "Watcher did not route amazon_invoice_live.pdf as expected"
476500
}
501+
wait_for_file "${PATH_DEST}/kanishk-itr-live.pdf" 120 || {
502+
die "Watcher did not route kanishk_itr_live.pdf to the path destination ${PATH_DEST}"
503+
}
477504
wait_for_match_count "${WATCH_B}/Documents" 'live-dup-*.pdf' 1 120 || {
478505
die "Watcher did not place any live duplicate in Documents"
479506
}
@@ -535,6 +562,8 @@ main() {
535562
assert_dir "${WATCH_B}/Amazon"
536563
assert_dir "${WATCH_B}/Finance"
537564
assert_dir "${WATCH_B}/Documents"
565+
assert_dir "${PATH_DEST}"
566+
assert_dir "${WATCH_A}/nested/tax"
538567

539568
log "All comprehensive checks passed."
540569
printf '\nSummary:\n'

src/commands/preview.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub fn run(config: &Config) -> Result<()> {
1616
let mut moves = 0usize;
1717
let mut dup_skip = 0usize;
1818
let mut dup_move = 0usize;
19+
let mut in_place = 0usize;
1920
let mut errors = 0usize;
2021

2122
for folder in &config.watch.folders {
@@ -66,6 +67,13 @@ pub fn run(config: &Config) -> Result<()> {
6667
);
6768
dup_move += 1;
6869
}
70+
organizer::Action::AlreadyInPlace => {
71+
println!(
72+
" keep {} (its rule points at the folder it is already in)",
73+
plan.source.display()
74+
);
75+
in_place += 1;
76+
}
6977
},
7078
Err(e) => {
7179
println!(" error {}: {}", entry.path().display(), e);
@@ -80,6 +88,9 @@ pub fn run(config: &Config) -> Result<()> {
8088
println!(" would move: {}", moves);
8189
println!(" would skip (duplicate): {}", dup_skip);
8290
println!(" would move to Duplicates: {}", dup_move);
91+
if in_place > 0 {
92+
println!(" already in place: {}", in_place);
93+
}
8394
if errors > 0 {
8495
println!(" errors: {}", errors);
8596
}

src/commands/scan.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ pub fn run(config: &Config, rename_override: bool) -> Result<()> {
3535

3636
let mut moved = 0usize;
3737
let mut skipped_dup = 0usize;
38+
let mut in_place = 0usize;
3839
let mut errors = 0usize;
3940

4041
for (root, file) in files {
@@ -48,6 +49,7 @@ pub fn run(config: &Config, rename_override: bool) -> Result<()> {
4849
match process_one(&root, &file, &config) {
4950
Ok(Outcome::Moved) => moved += 1,
5051
Ok(Outcome::Skipped) => skipped_dup += 1,
52+
Ok(Outcome::AlreadyInPlace) => in_place += 1,
5153
Err(e) => {
5254
// Per the safety rules, a single file error must not crash
5355
// the whole run. Log and continue.
@@ -65,12 +67,16 @@ pub fn run(config: &Config, rename_override: bool) -> Result<()> {
6567
"Scan complete: {} moved, {} skipped (duplicates), {} errors",
6668
moved, skipped_dup, errors
6769
);
70+
if in_place > 0 {
71+
println!(" {} already in their destination folder", in_place);
72+
}
6873
Ok(())
6974
}
7075

7176
enum Outcome {
7277
Moved,
7378
Skipped,
79+
AlreadyInPlace,
7480
}
7581

7682
fn process_one(
@@ -106,6 +112,13 @@ fn process_one(
106112
);
107113
Ok(Outcome::Skipped)
108114
}
115+
organizer::Action::AlreadyInPlace => {
116+
log::info!(
117+
"scan left in place: {} is already in its destination folder",
118+
plan.source.display()
119+
);
120+
Ok(Outcome::AlreadyInPlace)
121+
}
109122
}
110123
}
111124

0 commit comments

Comments
 (0)