commit 929f711cbf5cb14cfcd93abe1d7cd3a202c5c67b Author: Joseph O'Brien <98370624+89jobrien@users.noreply.github.com> Date: Tue Mar 24 06:43:49 2026 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46b5d68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +.envrc diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8d7e017 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What is notfiles? + +A modern dotfiles manager written in Rust — a pure Rust alternative to GNU Stow. It symlinks (or copies) files from organized "package" directories into a target location (typically `~`). + +## Build & Test Commands + +```bash +cargo build # build +cargo test # run all tests (unit + integration) +cargo test --lib # unit tests only +cargo test --test integration # integration tests only +cargo test # run a single test by name +cargo clippy # lint +cargo fmt --check # check formatting +``` + +## Architecture + +The binary has four subcommands: `init`, `link`, `unlink`, `status`. CLI parsing uses clap derive in `src/cli.rs`; command dispatch happens in `src/main.rs`. + +**Core flow for `link`:** `main` → `resolve_packages` (discover subdirs or validate requested names) → `collect_files` (recursive walk with ignore filtering) → `linker::link_package` (create symlinks or copies, record in state). + +Key modules: +- **linker** — Creates/removes symlinks or copies. Manages `State` (serialized to `.notfiles-state.toml` in the dotfiles dir) which tracks every linked file with source, target, method, and timestamp. Handles conflict detection, `--force` backups, and empty-parent cleanup on unlink. +- **config** — Parses `notfiles.toml`. Provides per-package overrides for method (symlink/copy), target directory, and ignore patterns. Falls back to `[defaults]` section values. +- **package** — Discovers packages (non-hidden subdirectories of the dotfiles dir) and recursively collects files, filtering through `IgnoreMatcher`. +- **ignore** — Glob-based ignore matching using `globset`. Matches both full relative paths and individual path components. +- **paths** — `expand_tilde` utility. +- **status** — Compares expected state (files on disk + config) against actual state (symlinks/copies + state file) to report linked/copied/missing/conflict/orphan. +- **error** — `NotfilesError` enum via `thiserror`. + +Integration tests (`tests/integration.rs`) run the compiled binary against temp directories using `tempfile`. + +## Configuration + +The config file is `notfiles.toml` at the dotfiles directory root. Each subdirectory of the dotfiles dir is a "package". Per-package config can override the link method (`symlink` or `copy`), target directory, and additional ignore patterns. + +## Edition + +Uses Rust edition 2024. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..94245fd --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1111 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert_fs" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652f6cb1f516886fcfee5e7a5c078b9ade62cfcb889524efe5a64d682dd27a9" +dependencies = [ + "anstyle", + "doc-comment", + "globwalk", + "predicates", + "predicates-core", + "predicates-tree", + "tempfile", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +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 = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "notfiles" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_fs", + "chrono", + "clap", + "dirs", + "globset", + "serde", + "tempfile", + "thiserror", + "toml", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f0fbfac --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "notfiles" +version = "0.1.0" +edition = "2024" +description = "A modern dotfiles manager — a pure Rust alternative to GNU Stow" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" +thiserror = "2" +anyhow = "1" +globset = "0.4" +dirs = "6" +chrono = { version = "0.4", default-features = false, features = ["clock"] } + +[dev-dependencies] +tempfile = "3" +assert_fs = "1" diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..2ef8693 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,53 @@ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(name = "notfiles", about = "A modern dotfiles manager")] +pub struct Cli { + /// Path to the dotfiles directory (default: current directory) + #[arg(long, global = true)] + pub dir: Option, + + /// Show what would be done without making changes + #[arg(long, global = true)] + pub dry_run: bool, + + /// Show verbose output + #[arg(long, short, global = true)] + pub verbose: bool, + + #[command(subcommand)] + pub command: Command, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Create a starter notfiles.toml + Init, + + /// Create symlinks/copies for packages + Link { + /// Overwrite existing files (backs up first) + #[arg(long)] + force: bool, + + /// Skip creating backups when using --force + #[arg(long)] + no_backup: bool, + + /// Specific packages to link (default: all) + packages: Vec, + }, + + /// Remove managed symlinks/copies + Unlink { + /// Specific packages to unlink (default: all) + packages: Vec, + }, + + /// Show link state per package + Status { + /// Specific packages to check (default: all) + packages: Vec, + }, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..68410e0 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,163 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; + +use crate::error::NotfilesError; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + #[serde(default)] + pub defaults: Defaults, + #[serde(default)] + pub packages: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Defaults { + #[serde(default = "default_target")] + pub target: String, + #[serde(default = "default_ignore")] + pub ignore: Vec, +} + +impl Default for Defaults { + fn default() -> Self { + Self { + target: default_target(), + ignore: default_ignore(), + } + } +} + +fn default_target() -> String { + "~".to_string() +} + +fn default_ignore() -> Vec { + vec![ + ".git".to_string(), + ".DS_Store".to_string(), + "README.md".to_string(), + "LICENSE".to_string(), + "notfiles.toml".to_string(), + ".notfiles-state.toml".to_string(), + ] +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PackageConfig { + #[serde(default)] + pub method: Option, + pub target: Option, + #[serde(default)] + pub ignore: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Method { + #[default] + Symlink, + Copy, +} + +impl std::fmt::Display for Method { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Method::Symlink => write!(f, "symlink"), + Method::Copy => write!(f, "copy"), + } + } +} + +impl Config { + pub fn load(dotfiles_dir: &Path) -> Result { + let config_path = dotfiles_dir.join("notfiles.toml"); + if !config_path.exists() { + return Ok(Config::default()); + } + let content = std::fs::read_to_string(&config_path) + .map_err(|e| NotfilesError::Config(format!("reading {}: {e}", config_path.display())))?; + let config: Config = toml::from_str(&content) + .map_err(|e| NotfilesError::Config(format!("parsing {}: {e}", config_path.display())))?; + Ok(config) + } + + pub fn method_for(&self, package: &str) -> Method { + self.packages + .get(package) + .and_then(|p| p.method) + .unwrap_or_default() + } + + pub fn target_for(&self, package: &str) -> &str { + self.packages + .get(package) + .and_then(|p| p.target.as_deref()) + .unwrap_or(&self.defaults.target) + } + + pub fn ignore_patterns_for(&self, package: &str) -> Vec<&str> { + let mut patterns: Vec<&str> = self.defaults.ignore.iter().map(|s| s.as_str()).collect(); + if let Some(pkg) = self.packages.get(package) { + for p in &pkg.ignore { + patterns.push(p.as_str()); + } + } + patterns + } +} + +pub fn starter_toml() -> &'static str { + r#"[defaults] +target = "~" +ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml"] + +# Per-package overrides (only needed for non-default behavior): +# [packages.ssh] +# method = "copy" +# ignore = ["known_hosts"] +# +# [packages.scripts] +# target = "~/bin" +"# +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = Config::default(); + assert_eq!(config.defaults.target, "~"); + assert!(config.defaults.ignore.contains(&".git".to_string())); + assert_eq!(config.method_for("anything"), Method::Symlink); + assert_eq!(config.target_for("anything"), "~"); + } + + #[test] + fn test_parse_config() { + let toml_str = r#" +[defaults] +target = "~" +ignore = [".git"] + +[packages.ssh] +method = "copy" +ignore = ["known_hosts"] + +[packages.scripts] +target = "~/bin" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.method_for("ssh"), Method::Copy); + assert_eq!(config.method_for("scripts"), Method::Symlink); + assert_eq!(config.target_for("scripts"), "~/bin"); + assert_eq!(config.target_for("ssh"), "~"); + + let ssh_ignores = config.ignore_patterns_for("ssh"); + assert!(ssh_ignores.contains(&".git")); + assert!(ssh_ignores.contains(&"known_hosts")); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..38979e7 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,25 @@ +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum NotfilesError { + #[error("config file error: {0}")] + Config(String), + + #[error("package not found: {name}")] + PackageNotFound { name: String }, + + #[error("conflict at {path}: {reason}")] + Conflict { path: PathBuf, reason: String }, + + #[error("path error: {0}")] + Path(String), + + #[error("state file error: {0}")] + State(String), + + #[error("{0}")] + Io(#[from] std::io::Error), + + #[error("{0}")] + Other(String), +} diff --git a/src/ignore.rs b/src/ignore.rs new file mode 100644 index 0000000..1f6474b --- /dev/null +++ b/src/ignore.rs @@ -0,0 +1,81 @@ +use globset::{Glob, GlobSet, GlobSetBuilder}; +use std::path::Path; + +use crate::error::NotfilesError; + +pub struct IgnoreMatcher { + globset: GlobSet, +} + +impl IgnoreMatcher { + pub fn new(patterns: &[&str]) -> Result { + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { + // Match the pattern as a filename component and also as a path suffix. + let glob = Glob::new(pattern) + .or_else(|_| Glob::new(&format!("**/{pattern}"))) + .map_err(|e| NotfilesError::Other(format!("invalid ignore pattern '{pattern}': {e}")))?; + builder.add(glob); + // Also add a recursive variant so "foo" matches "a/foo" etc. + if !pattern.contains('/') && !pattern.starts_with("**/") { + if let Ok(g) = Glob::new(&format!("**/{pattern}")) { + builder.add(g); + } + } + } + let globset = builder + .build() + .map_err(|e| NotfilesError::Other(format!("building ignore set: {e}")))?; + Ok(Self { globset }) + } + + /// Check if a relative path should be ignored. + pub fn is_ignored(&self, relative_path: &Path) -> bool { + if self.globset.is_match(relative_path) { + return true; + } + // Also check each component individually (for directory-level ignores like ".git"). + for component in relative_path.components() { + if self.globset.is_match(component.as_os_str()) { + return true; + } + } + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_basic_ignore() { + let m = IgnoreMatcher::new(&[".git", "README.md"]).unwrap(); + assert!(m.is_ignored(Path::new(".git"))); + assert!(m.is_ignored(Path::new("README.md"))); + assert!(!m.is_ignored(Path::new("config"))); + } + + #[test] + fn test_nested_ignore() { + let m = IgnoreMatcher::new(&[".DS_Store"]).unwrap(); + assert!(m.is_ignored(Path::new(".DS_Store"))); + assert!(m.is_ignored(Path::new("subdir/.DS_Store"))); + } + + #[test] + fn test_glob_pattern() { + let m = IgnoreMatcher::new(&["*.bak"]).unwrap(); + assert!(m.is_ignored(Path::new("file.bak"))); + assert!(m.is_ignored(Path::new("subdir/file.bak"))); + assert!(!m.is_ignored(Path::new("file.txt"))); + } + + #[test] + fn test_directory_component_ignore() { + let m = IgnoreMatcher::new(&[".git"]).unwrap(); + assert!(m.is_ignored(Path::new(".git/config"))); + assert!(m.is_ignored(Path::new(".git/objects/abc"))); + } +} diff --git a/src/linker.rs b/src/linker.rs new file mode 100644 index 0000000..97522af --- /dev/null +++ b/src/linker.rs @@ -0,0 +1,292 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use crate::config::{Config, Method}; +use crate::error::NotfilesError; +use crate::package::collect_files; +use crate::paths::expand_tilde; + +const STATE_FILE: &str = ".notfiles-state.toml"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StateEntry { + pub package: String, + pub source: String, + pub target: String, + pub method: Method, + pub linked_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct State { + #[serde(default)] + pub entries: Vec, +} + +impl State { + pub fn load(dotfiles_dir: &Path) -> Result { + let path = dotfiles_dir.join(STATE_FILE); + if !path.exists() { + return Ok(State::default()); + } + let content = fs::read_to_string(&path) + .map_err(|e| NotfilesError::State(format!("reading state: {e}")))?; + let state: State = toml::from_str(&content) + .map_err(|e| NotfilesError::State(format!("parsing state: {e}")))?; + Ok(state) + } + + pub fn save(&self, dotfiles_dir: &Path) -> Result<(), NotfilesError> { + let path = dotfiles_dir.join(STATE_FILE); + let content = toml::to_string_pretty(self) + .map_err(|e| NotfilesError::State(format!("serializing state: {e}")))?; + fs::write(&path, content)?; + Ok(()) + } + + pub fn entries_for_package(&self, package: &str) -> Vec<&StateEntry> { + self.entries.iter().filter(|e| e.package == package).collect() + } + + pub fn remove_package(&mut self, package: &str) { + self.entries.retain(|e| e.package != package); + } + + pub fn add_entry(&mut self, entry: StateEntry) { + // Remove existing entry for same source+target, then add new + self.entries.retain(|e| !(e.source == entry.source && e.target == entry.target)); + self.entries.push(entry); + } +} + +pub struct LinkOptions { + pub force: bool, + pub no_backup: bool, + pub dry_run: bool, + pub verbose: bool, +} + +pub fn link_package( + dotfiles_dir: &Path, + config: &Config, + state: &mut State, + package: &str, + opts: &LinkOptions, +) -> Result<(), NotfilesError> { + let package_dir = dotfiles_dir.join(package); + let method = config.method_for(package); + let target_base = expand_tilde(config.target_for(package))?; + let files = collect_files(&package_dir, config, package)?; + + if files.is_empty() { + if opts.verbose { + println!(" {package}: no files to link"); + } + return Ok(()); + } + + for relative in &files { + let source = package_dir.join(relative); + let target = target_base.join(relative); + let source_display = format!("{package}/{}", relative.display()); + + // Check if already correctly linked + if is_already_linked(&source, &target, method) { + if opts.verbose { + println!(" \x1b[90mskip\x1b[0m {source_display} (already linked)"); + } + continue; + } + + // Conflict detection + if target.exists() || target.symlink_metadata().is_ok() { + if !opts.force { + return Err(NotfilesError::Conflict { + path: target.clone(), + reason: format!( + "already exists (use --force to overwrite); source: {source_display}" + ), + }); + } + // Force mode: backup then remove + if !opts.no_backup { + let backup = backup_path(&target); + if opts.dry_run { + println!(" \x1b[33mwould backup\x1b[0m {} -> {}", target.display(), backup.display()); + } else { + if opts.verbose { + println!(" \x1b[33mbackup\x1b[0m {} -> {}", target.display(), backup.display()); + } + fs::rename(&target, &backup)?; + } + } else if !opts.dry_run { + if target.is_dir() { + fs::remove_dir_all(&target)?; + } else { + fs::remove_file(&target)?; + } + } + } + + // Create parent directories + if let Some(parent) = target.parent() { + if !parent.exists() { + if opts.dry_run { + if opts.verbose { + println!(" \x1b[90mwould create dir\x1b[0m {}", parent.display()); + } + } else { + fs::create_dir_all(parent)?; + } + } + } + + // Create link or copy + let action_word = match method { + Method::Symlink => "link", + Method::Copy => "copy", + }; + + if opts.dry_run { + println!(" \x1b[36mwould {action_word}\x1b[0m {source_display} -> {}", target.display()); + } else { + match method { + Method::Symlink => { + #[cfg(unix)] + std::os::unix::fs::symlink(&source, &target)?; + #[cfg(not(unix))] + fs::copy(&source, &target)?; + } + Method::Copy => { + fs::copy(&source, &target)?; + } + } + if opts.verbose { + println!(" \x1b[32m{action_word}\x1b[0m {source_display} -> {}", target.display()); + } + + state.add_entry(StateEntry { + package: package.to_string(), + source: source.to_string_lossy().to_string(), + target: target.to_string_lossy().to_string(), + method, + linked_at: Utc::now().to_rfc3339(), + }); + } + } + + Ok(()) +} + +pub fn unlink_package( + _dotfiles_dir: &Path, + state: &mut State, + package: &str, + opts: &LinkOptions, +) -> Result<(), NotfilesError> { + let entries: Vec = state.entries_for_package(package).into_iter().cloned().collect(); + + if entries.is_empty() { + if opts.verbose { + println!(" {package}: nothing to unlink"); + } + return Ok(()); + } + + for entry in &entries { + let target = PathBuf::from(&entry.target); + + if !target.exists() && target.symlink_metadata().is_err() { + if opts.verbose { + println!(" \x1b[90mskip\x1b[0m {} (already gone)", target.display()); + } + continue; + } + + match entry.method { + Method::Symlink => { + // Verify it's a symlink pointing to our source + if let Ok(link_target) = fs::read_link(&target) { + let source = PathBuf::from(&entry.source); + if link_target != source { + if opts.verbose { + println!( + " \x1b[33mskip\x1b[0m {} (symlink points elsewhere)", + target.display() + ); + } + continue; + } + } else { + if opts.verbose { + println!(" \x1b[33mskip\x1b[0m {} (not a symlink)", target.display()); + } + continue; + } + } + Method::Copy => { + // For copies, trust the state file + } + } + + if opts.dry_run { + println!(" \x1b[36mwould remove\x1b[0m {}", target.display()); + } else { + if target.is_dir() { + fs::remove_dir_all(&target)?; + } else { + fs::remove_file(&target)?; + } + if opts.verbose { + println!(" \x1b[31mremove\x1b[0m {}", target.display()); + } + + // Clean up empty parent dirs + cleanup_empty_parents(&target); + } + } + + if !opts.dry_run { + state.remove_package(package); + } + + Ok(()) +} + +fn is_already_linked(source: &Path, target: &Path, method: Method) -> bool { + match method { + Method::Symlink => { + if let Ok(link_target) = fs::read_link(target) { + link_target == source + } else { + false + } + } + Method::Copy => false, // Always re-copy + } +} + +fn backup_path(path: &Path) -> PathBuf { + let timestamp = Utc::now().format("%Y%m%d%H%M%S"); + let name = path.to_string_lossy(); + PathBuf::from(format!("{name}.notfiles-backup-{timestamp}")) +} + +fn cleanup_empty_parents(path: &Path) { + let mut dir = path.parent(); + while let Some(parent) = dir { + // Stop at home dir or root + if Some(parent.to_path_buf()) == dirs::home_dir() || parent == Path::new("/") { + break; + } + if fs::read_dir(parent).map(|mut d| d.next().is_none()).unwrap_or(false) { + let _ = fs::remove_dir(parent); + dir = parent.parent(); + } else { + break; + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..50e9758 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,134 @@ +mod cli; +mod config; +mod error; +mod ignore; +mod linker; +mod package; +mod paths; +mod status; + +use std::fs; + +use anyhow::{Context, Result}; +use clap::Parser; + +use cli::{Cli, Command}; +use config::Config; +use linker::{LinkOptions, State}; +use package::resolve_packages; + +fn main() -> Result<()> { + let cli = Cli::parse(); + let dotfiles_dir = cli + .dir + .unwrap_or_else(|| std::env::current_dir().expect("cannot determine current directory")); + let dotfiles_dir = fs::canonicalize(&dotfiles_dir) + .with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?; + + match cli.command { + Command::Init => cmd_init(&dotfiles_dir)?, + Command::Link { + force, + no_backup, + packages, + } => { + let config = Config::load(&dotfiles_dir)?; + let mut state = State::load(&dotfiles_dir)?; + let pkgs = resolve_packages(&dotfiles_dir, &packages)?; + let opts = LinkOptions { + force, + no_backup, + dry_run: cli.dry_run, + verbose: cli.verbose, + }; + + if cli.dry_run { + println!("\x1b[36m(dry run)\x1b[0m"); + } + + for pkg in &pkgs { + if cli.verbose || cli.dry_run { + println!("Linking {pkg}..."); + } + linker::link_package(&dotfiles_dir, &config, &mut state, pkg, &opts)?; + } + + if !cli.dry_run { + state.save(&dotfiles_dir)?; + let count: usize = pkgs.iter().map(|p| state.entries_for_package(p).len()).sum(); + println!( + "\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m", + if count == 1 { "" } else { "s" }, + pkgs.len(), + if pkgs.len() == 1 { "" } else { "s" }, + ); + } + } + Command::Unlink { packages } => { + let config = Config::load(&dotfiles_dir)?; + let mut state = State::load(&dotfiles_dir)?; + let pkgs = if packages.is_empty() { + state + .entries + .iter() + .map(|e| e.package.clone()) + .collect::>() + .into_iter() + .collect::>() + } else { + // Validate requested packages exist in state + let _ = resolve_packages(&dotfiles_dir, &packages).or_else(|_| { + // Package dir might be gone but state entries exist — that's fine for unlink + Ok::, anyhow::Error>(packages.clone()) + }); + packages + }; + let opts = LinkOptions { + force: false, + no_backup: false, + dry_run: cli.dry_run, + verbose: cli.verbose, + }; + + if cli.dry_run { + println!("\x1b[36m(dry run)\x1b[0m"); + } + + let _ = &config; // loaded but not needed for unlink + for pkg in &pkgs { + if cli.verbose || cli.dry_run { + println!("Unlinking {pkg}..."); + } + linker::unlink_package(&dotfiles_dir, &mut state, pkg, &opts)?; + } + + if !cli.dry_run { + state.save(&dotfiles_dir)?; + println!("\x1b[32mUnlinked {} package{}.\x1b[0m", pkgs.len(), if pkgs.len() == 1 { "" } else { "s" }); + } + } + Command::Status { packages } => { + let config = Config::load(&dotfiles_dir)?; + let state = State::load(&dotfiles_dir)?; + let pkgs = resolve_packages(&dotfiles_dir, &packages)?; + + for pkg in &pkgs { + let entries = status::package_status(&dotfiles_dir, &config, &state, pkg); + status::print_status(pkg, &entries); + } + } + } + + Ok(()) +} + +fn cmd_init(dotfiles_dir: &std::path::Path) -> Result<()> { + let config_path = dotfiles_dir.join("notfiles.toml"); + if config_path.exists() { + println!("notfiles.toml already exists."); + return Ok(()); + } + fs::write(&config_path, config::starter_toml())?; + println!("Created notfiles.toml"); + Ok(()) +} diff --git a/src/package.rs b/src/package.rs new file mode 100644 index 0000000..3279202 --- /dev/null +++ b/src/package.rs @@ -0,0 +1,130 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::config::Config; +use crate::error::NotfilesError; +use crate::ignore::IgnoreMatcher; + +/// Discover available packages (subdirectories of the dotfiles dir). +pub fn discover_packages(dotfiles_dir: &Path) -> Result, NotfilesError> { + let mut packages = Vec::new(); + for entry in fs::read_dir(dotfiles_dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + let name = entry.file_name().to_string_lossy().to_string(); + // Skip hidden dirs and common non-package dirs + if !name.starts_with('.') { + packages.push(name); + } + } + } + packages.sort(); + Ok(packages) +} + +/// Resolve which packages to operate on: if specific names given, validate them; +/// otherwise discover all. +pub fn resolve_packages( + dotfiles_dir: &Path, + requested: &[String], +) -> Result, NotfilesError> { + let available = discover_packages(dotfiles_dir)?; + if requested.is_empty() { + return Ok(available); + } + for name in requested { + if !available.contains(name) { + return Err(NotfilesError::PackageNotFound { name: name.clone() }); + } + } + Ok(requested.to_vec()) +} + +/// Recursively walk a package directory and return all file paths (relative to the package dir). +pub fn collect_files( + package_dir: &Path, + config: &Config, + package_name: &str, +) -> Result, NotfilesError> { + let patterns = config.ignore_patterns_for(package_name); + let matcher = IgnoreMatcher::new(&patterns)?; + let mut files = Vec::new(); + walk_dir(package_dir, package_dir, &matcher, &mut files)?; + files.sort(); + Ok(files) +} + +fn walk_dir( + base: &Path, + current: &Path, + matcher: &IgnoreMatcher, + files: &mut Vec, +) -> Result<(), NotfilesError> { + for entry in fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + let relative = path.strip_prefix(base).unwrap().to_path_buf(); + + if matcher.is_ignored(&relative) { + continue; + } + + if path.is_dir() { + walk_dir(base, &path, matcher, files)?; + } else { + files.push(relative); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_discover_packages() { + let tmp = TempDir::new().unwrap(); + fs::create_dir(tmp.path().join("zsh")).unwrap(); + fs::create_dir(tmp.path().join("git")).unwrap(); + fs::create_dir(tmp.path().join(".git")).unwrap(); + + let pkgs = discover_packages(tmp.path()).unwrap(); + assert_eq!(pkgs, vec!["git", "zsh"]); + } + + #[test] + fn test_resolve_specific_packages() { + let tmp = TempDir::new().unwrap(); + fs::create_dir(tmp.path().join("zsh")).unwrap(); + fs::create_dir(tmp.path().join("git")).unwrap(); + + let pkgs = resolve_packages(tmp.path(), &["zsh".into()]).unwrap(); + assert_eq!(pkgs, vec!["zsh"]); + } + + #[test] + fn test_resolve_missing_package() { + let tmp = TempDir::new().unwrap(); + fs::create_dir(tmp.path().join("zsh")).unwrap(); + + let err = resolve_packages(tmp.path(), &["nope".into()]).unwrap_err(); + assert!(err.to_string().contains("nope")); + } + + #[test] + fn test_collect_files_with_ignore() { + let tmp = TempDir::new().unwrap(); + let pkg = tmp.path().join("zsh"); + fs::create_dir_all(pkg.join(".config/zsh")).unwrap(); + fs::write(pkg.join(".config/zsh/zshrc"), "# zshrc").unwrap(); + fs::write(pkg.join("README.md"), "readme").unwrap(); + fs::write(pkg.join(".DS_Store"), "junk").unwrap(); + + let config = Config::default(); + let files = collect_files(&pkg, &config, "zsh").unwrap(); + assert_eq!(files, vec![PathBuf::from(".config/zsh/zshrc")]); + } +} diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..40b1be1 --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,39 @@ +use std::path::PathBuf; + +use crate::error::NotfilesError; + +/// Expand a leading `~` to the user's home directory. +pub fn expand_tilde(path: &str) -> Result { + if path == "~" { + return dirs::home_dir().ok_or_else(|| NotfilesError::Path("cannot determine home directory".into())); + } + if let Some(rest) = path.strip_prefix("~/") { + let home = dirs::home_dir() + .ok_or_else(|| NotfilesError::Path("cannot determine home directory".into()))?; + return Ok(home.join(rest)); + } + Ok(PathBuf::from(path)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expand_tilde_home() { + let home = dirs::home_dir().unwrap(); + assert_eq!(expand_tilde("~").unwrap(), home); + } + + #[test] + fn test_expand_tilde_subpath() { + let home = dirs::home_dir().unwrap(); + assert_eq!(expand_tilde("~/foo/bar").unwrap(), home.join("foo/bar")); + } + + #[test] + fn test_expand_tilde_absolute() { + assert_eq!(expand_tilde("/usr/bin").unwrap(), PathBuf::from("/usr/bin")); + } + +} diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..36990f1 --- /dev/null +++ b/src/status.rs @@ -0,0 +1,128 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::config::{Config, Method}; +use crate::linker::State; +use crate::package::collect_files; +use crate::paths::expand_tilde; + +#[derive(Debug, PartialEq)] +pub enum FileStatus { + Linked, + Copied, + Missing, + Conflict, + Orphan, +} + +impl std::fmt::Display for FileStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FileStatus::Linked => write!(f, "\x1b[32mlinked\x1b[0m"), + FileStatus::Copied => write!(f, "\x1b[32mcopied\x1b[0m"), + FileStatus::Missing => write!(f, "\x1b[31mmissing\x1b[0m"), + FileStatus::Conflict => write!(f, "\x1b[33mconflict\x1b[0m"), + FileStatus::Orphan => write!(f, "\x1b[35morphan\x1b[0m"), + } + } +} + +pub struct StatusEntry { + pub source_display: String, + pub target: PathBuf, + pub status: FileStatus, +} + +pub fn package_status( + dotfiles_dir: &Path, + config: &Config, + state: &State, + package: &str, +) -> Vec { + let mut results = Vec::new(); + let package_dir = dotfiles_dir.join(package); + let method = config.method_for(package); + let target_base = match expand_tilde(config.target_for(package)) { + Ok(p) => p, + Err(_) => return results, + }; + + // Check files that should exist + if let Ok(files) = collect_files(&package_dir, config, package) { + for relative in &files { + let source = package_dir.join(relative); + let target = target_base.join(relative); + let source_display = format!("{package}/{}", relative.display()); + + let status = match method { + Method::Symlink => { + if let Ok(link_target) = fs::read_link(&target) { + if link_target == source { + FileStatus::Linked + } else { + FileStatus::Conflict + } + } else if target.exists() { + FileStatus::Conflict + } else { + FileStatus::Missing + } + } + Method::Copy => { + let has_state = state.entries.iter().any(|e| { + e.package == package + && e.target == target.to_string_lossy().as_ref() + }); + if has_state && target.exists() { + FileStatus::Copied + } else if target.exists() { + FileStatus::Conflict + } else { + FileStatus::Missing + } + } + }; + + results.push(StatusEntry { + source_display, + target, + status, + }); + } + } + + // Check for orphans: entries in state that no longer have a source file + for entry in state.entries_for_package(package) { + let source = PathBuf::from(&entry.source); + if !source.exists() { + let target = PathBuf::from(&entry.target); + // Only add if we didn't already report this target + if !results.iter().any(|r| r.target == target) { + results.push(StatusEntry { + source_display: format!( + "{package}/{}", + source + .strip_prefix(&package_dir) + .unwrap_or(&source) + .display() + ), + target, + status: FileStatus::Orphan, + }); + } + } + } + + results +} + +pub fn print_status(package: &str, entries: &[StatusEntry]) { + if entries.is_empty() { + println!(" {package}: (empty)"); + return; + } + println!(" \x1b[1m{package}\x1b[0m:"); + for entry in entries { + println!(" {} {} -> {}", entry.status, entry.source_display, entry.target.display()); + } +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..56014b9 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,364 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +use tempfile::TempDir; + +fn notfiles_bin() -> std::path::PathBuf { + // Built by `cargo test` + let mut path = std::env::current_exe().unwrap(); + path.pop(); // remove test binary name + path.pop(); // remove `deps` + path.push("notfiles"); + path +} + +fn run(dotfiles: &Path, args: &[&str]) -> (String, String, bool) { + let output = Command::new(notfiles_bin()) + .arg("--dir") + .arg(dotfiles) + .args(args) + .output() + .expect("failed to run notfiles"); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + (stdout, stderr, output.status.success()) +} + +fn setup_dotfiles(tmp: &TempDir) { + let dotfiles = tmp.path().join("dotfiles"); + let target = tmp.path().join("home"); + fs::create_dir_all(&dotfiles).unwrap(); + fs::create_dir_all(&target).unwrap(); + + // Create a zsh package + let zsh = dotfiles.join("zsh"); + fs::create_dir_all(zsh.join(".config/zsh")).unwrap(); + fs::write(zsh.join(".config/zsh/zshrc"), "# zshrc content").unwrap(); + fs::write(zsh.join(".zshenv"), "# zshenv content").unwrap(); + + // Create a git package + let git = dotfiles.join("git"); + fs::create_dir_all(&git).unwrap(); + fs::write(git.join(".gitconfig"), "[user]\nname = Test").unwrap(); + + // Write config pointing target to our temp home + let config = format!( + r#"[defaults] +target = "{}" +ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfiles-state.toml"] +"#, + target.display() + ); + fs::write(dotfiles.join("notfiles.toml"), config).unwrap(); +} + +#[test] +fn test_init_creates_config() { + let tmp = TempDir::new().unwrap(); + let dotfiles = tmp.path().join("dotfiles"); + fs::create_dir_all(&dotfiles).unwrap(); + + let (stdout, _, ok) = run(&dotfiles, &["init"]); + assert!(ok); + assert!(stdout.contains("Created notfiles.toml")); + assert!(dotfiles.join("notfiles.toml").exists()); +} + +#[test] +fn test_init_idempotent() { + let tmp = TempDir::new().unwrap(); + let dotfiles = tmp.path().join("dotfiles"); + fs::create_dir_all(&dotfiles).unwrap(); + + run(&dotfiles, &["init"]); + let (stdout, _, ok) = run(&dotfiles, &["init"]); + assert!(ok); + assert!(stdout.contains("already exists")); +} + +#[test] +fn test_link_and_status() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + let target = tmp.path().join("home"); + + let (stdout, stderr, ok) = run(&dotfiles, &["link", "--verbose"]); + assert!(ok, "link failed: stdout={stdout} stderr={stderr}"); + + // Verify symlinks exist + let zshrc = target.join(".config/zsh/zshrc"); + assert!(zshrc.exists(), "zshrc should exist"); + assert!(zshrc.symlink_metadata().unwrap().file_type().is_symlink()); + + let gitconfig = target.join(".gitconfig"); + assert!(gitconfig.exists()); + assert!(gitconfig.symlink_metadata().unwrap().file_type().is_symlink()); + + // State file should exist + assert!(dotfiles.join(".notfiles-state.toml").exists()); + + // Status should show linked + let (stdout, _, ok) = run(&dotfiles, &["status"]); + assert!(ok); + assert!(stdout.contains("linked")); +} + +#[test] +fn test_link_idempotent() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + + let (_, _, ok) = run(&dotfiles, &["link"]); + assert!(ok); + + // Link again — should succeed (skips already linked) + let (_, _, ok) = run(&dotfiles, &["link"]); + assert!(ok); +} + +#[test] +fn test_link_specific_package() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + let target = tmp.path().join("home"); + + let (_, _, ok) = run(&dotfiles, &["link", "zsh"]); + assert!(ok); + + assert!(target.join(".config/zsh/zshrc").exists()); + assert!(!target.join(".gitconfig").exists()); +} + +#[test] +fn test_unlink() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + let target = tmp.path().join("home"); + + run(&dotfiles, &["link"]); + assert!(target.join(".gitconfig").exists()); + + let (_, _, ok) = run(&dotfiles, &["unlink", "--verbose"]); + assert!(ok); + + assert!(!target.join(".gitconfig").exists()); + assert!(!target.join(".config/zsh/zshrc").exists()); +} + +#[test] +fn test_unlink_specific_package() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + let target = tmp.path().join("home"); + + run(&dotfiles, &["link"]); + + let (_, _, ok) = run(&dotfiles, &["unlink", "git"]); + assert!(ok); + + assert!(!target.join(".gitconfig").exists()); + // zsh should still be linked + assert!(target.join(".config/zsh/zshrc").exists()); +} + +#[test] +fn test_conflict_without_force() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + let target = tmp.path().join("home"); + + // Pre-create a conflicting file + fs::write(target.join(".gitconfig"), "existing content").unwrap(); + + let (_, stderr, ok) = run(&dotfiles, &["link"]); + assert!(!ok, "should fail on conflict"); + assert!( + stderr.contains("conflict") || stderr.contains("already exists"), + "stderr: {stderr}" + ); +} + +#[test] +fn test_force_with_backup() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + let target = tmp.path().join("home"); + + // Pre-create conflicting file + fs::write(target.join(".gitconfig"), "old content").unwrap(); + + let (_, _, ok) = run(&dotfiles, &["link", "--force", "--verbose"]); + assert!(ok); + + // The link should now exist + assert!(target.join(".gitconfig").symlink_metadata().unwrap().file_type().is_symlink()); + + // A backup should exist + let backups: Vec<_> = fs::read_dir(&target) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_string_lossy() + .starts_with(".gitconfig.notfiles-backup-") + }) + .collect(); + assert_eq!(backups.len(), 1); + let backup_content = fs::read_to_string(backups[0].path()).unwrap(); + assert_eq!(backup_content, "old content"); +} + +#[test] +fn test_force_no_backup() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + let target = tmp.path().join("home"); + + fs::write(target.join(".gitconfig"), "old content").unwrap(); + + let (_, _, ok) = run(&dotfiles, &["link", "--force", "--no-backup"]); + assert!(ok); + + // No backup should exist + let backups: Vec<_> = fs::read_dir(&target) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_string_lossy() + .contains("notfiles-backup") + }) + .collect(); + assert_eq!(backups.len(), 0); +} + +#[test] +fn test_copy_method() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + let target = tmp.path().join("home"); + + // Create an ssh package with copy method + let ssh = dotfiles.join("ssh"); + fs::create_dir_all(ssh.join(".ssh")).unwrap(); + fs::write(ssh.join(".ssh/config"), "Host *\n AddKeysToAgent yes").unwrap(); + + // Update config to use copy for ssh + let config = format!( + r#"[defaults] +target = "{}" +ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfiles-state.toml"] + +[packages.ssh] +method = "copy" +"#, + target.display() + ); + fs::write(dotfiles.join("notfiles.toml"), config).unwrap(); + + let (_, _, ok) = run(&dotfiles, &["link", "ssh", "--verbose"]); + assert!(ok); + + let ssh_config = target.join(".ssh/config"); + assert!(ssh_config.exists()); + // Should NOT be a symlink + assert!(!ssh_config.symlink_metadata().unwrap().file_type().is_symlink()); + assert_eq!(fs::read_to_string(&ssh_config).unwrap(), "Host *\n AddKeysToAgent yes"); + + // Status should show "copied" + let (stdout, _, _) = run(&dotfiles, &["status", "ssh"]); + assert!(stdout.contains("copied")); + + // Unlink should remove the copy + let (_, _, ok) = run(&dotfiles, &["unlink", "ssh"]); + assert!(ok); + assert!(!ssh_config.exists()); +} + +#[test] +fn test_dry_run() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + let target = tmp.path().join("home"); + + let (stdout, _, ok) = run(&dotfiles, &["--dry-run", "link"]); + assert!(ok); + assert!(stdout.contains("dry run")); + assert!(stdout.contains("would link")); + + // Nothing should actually be created + assert!(!target.join(".gitconfig").exists()); + assert!(!dotfiles.join(".notfiles-state.toml").exists()); +} + +#[test] +fn test_status_missing() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + + // Don't link anything — status should show missing + let (stdout, _, ok) = run(&dotfiles, &["status"]); + assert!(ok); + assert!(stdout.contains("missing")); +} + +#[test] +fn test_ignore_patterns() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + let target = tmp.path().join("home"); + + // Add a README to a package — it should be ignored + fs::write(dotfiles.join("zsh/README.md"), "read me").unwrap(); + fs::write(dotfiles.join("zsh/.DS_Store"), "junk").unwrap(); + + let (_, _, ok) = run(&dotfiles, &["link"]); + assert!(ok); + + assert!(!target.join("README.md").exists()); + assert!(!target.join(".DS_Store").exists()); + // But real files should be linked + assert!(target.join(".config/zsh/zshrc").exists()); +} + +#[test] +fn test_package_not_found() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + + let (_, stderr, ok) = run(&dotfiles, &["link", "nonexistent"]); + assert!(!ok); + assert!(stderr.contains("nonexistent"), "stderr: {stderr}"); +} + +#[test] +fn test_unlink_cleans_empty_dirs() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + let target = tmp.path().join("home"); + + let (_, _, ok) = run(&dotfiles, &["link", "zsh"]); + assert!(ok); + assert!(target.join(".config/zsh").is_dir()); + + let (_, _, ok) = run(&dotfiles, &["unlink", "zsh"]); + assert!(ok); + + // The .config/zsh directory should be cleaned up + assert!(!target.join(".config/zsh").exists()); +}