From 57a0ec63c8acaf5d0f63f71f8b2feb2c72ff0049 Mon Sep 17 00:00:00 2001 From: RinChanNOWWW Date: Mon, 7 Sep 2026 17:40:59 +0800 Subject: [PATCH] feat: implement FileTrail file synchronization tool Add file watching, configurable mappings, SQLite sync state, conflict handling, and Git diff and commit commands. Support quoted file lists, background services, bilingual documentation, and automated Rust checks and tests. --- .github/workflows/ci.yml | 65 ++ .gitignore | 2 + .taplo.toml | 7 + AGENTS.md | 105 +++ Cargo.lock | 1410 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 28 + README.md | 204 ++++++ README_zh.md | 190 +++++ rust-toolchain.toml | 4 + rustfmt.toml | 5 + src/config.rs | 310 +++++++++ src/daemon.rs | 267 ++++++++ src/git.rs | 334 +++++++++ src/lib.rs | 12 + src/main.rs | 489 +++++++++++++ src/manifest.rs | 83 +++ src/service.rs | 195 ++++++ src/state.rs | 208 ++++++ src/sync.rs | 417 +++++++++++ tests/state.rs | 125 ++++ tests/workflow.rs | 730 ++++++++++++++++++++ 21 files changed, 5190 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .taplo.toml create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 README_zh.md create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml create mode 100644 src/config.rs create mode 100644 src/daemon.rs create mode 100644 src/git.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/manifest.rs create mode 100644 src/service.rs create mode 100644 src/state.rs create mode 100644 src/sync.rs create mode 100644 tests/state.rs create mode 100644 tests/workflow.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f6b1ad7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + format: + name: Rust and TOML formatting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Install pinned toolchain + run: rustup show + - name: Check Rust formatting (one import per line) + run: cargo fmt --all -- --check + - name: Install pinned TOML formatter + run: cargo install taplo-cli --version 0.10.0 --locked + - name: Check TOML formatting and alphabetical key order + run: taplo fmt --check + + check: + name: Check, Clippy, tests, and binary (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Install pinned toolchain + run: rustup show + - name: Compile all targets + run: cargo check --locked --all-targets + - name: Clippy (warnings are errors) + run: cargo clippy --locked --all-targets -- -D warnings + - name: Unit and integration tests + run: cargo test --locked --all-targets + - name: Documentation tests + run: cargo test --locked --doc + - name: Build standalone binary + run: cargo build --locked --release + - name: Smoke test + run: ./target/release/filetrail --help + - uses: actions/upload-artifact@v4 + with: + name: filetrail-${{ runner.os }}-${{ runner.arch }} + path: target/release/filetrail + if-no-files-found: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e89d4af --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target/ +.DS_Store diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 0000000..a492c70 --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,7 @@ +exclude = ["Cargo.lock", "target/**"] +include = ["**/*.toml"] + +[formatting] +column_width = 100 +reorder_inline_tables = true +reorder_keys = true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..708cbb4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,105 @@ +# Working on Filetrail + +Filetrail is a Rust CLI and background file synchronization daemon distributed as +one executable for macOS and Linux. Read README.md before changing its behavior. + +## Development + +- README.md is the primary English README; README_zh.md is the Chinese version. + Every README change must update both files in the same change, keeping behavior, + examples, and section coverage equivalent. Preserve their language-switch links. +- Use the exact toolchain in rust-toolchain.toml. Keep Cargo.lock checked in. +- Building requires a C compiler for vendored libgit2 and SQLite. The distributed executable + does not require a separate Rust or Git installation. +- Run `cargo fmt --all`, `cargo clippy --locked --all-targets -- -D warnings`, + `cargo test --locked --all-targets`, and `cargo test --locked --doc`. +- Run `taplo fmt` and `taplo fmt --check` with taplo-cli 0.10.0. +- Every Rust import must be its own `use` statement. Do not use grouped braces. + rustfmt's `imports_granularity = "Item"` enforces this on the pinned nightly. +- Keep TOML keys alphabetically sorted, particularly `[package]` and dependency + names in Cargo.toml. Use the repository's .taplo.toml; do not format Cargo.lock. +- Put filesystem/Git regression tests in tests/workflow.rs using temporary + directories and a temporary Git identity. Never test against real dotfiles. +- Keep OS service installation out of automated tests. Test rendered definitions. +- Do not introduce a dependency on an external `git` executable for core commands. + +## Architecture and invariants + +- config.rs owns editable TOML mappings, validation, atomic config writes, and the shared + operation lock. Repository path, init subdirectory, and entry target are + separate concepts: destination = repository / subdir / target / relative file. + Without `add --to`, sources inside Home use their Home-relative path; sources + outside Home use their absolute path with the leading `/` removed. The same + default applies to list imports. Explicit targets override either default. +- Application data defaults to `$HOME/.filetrail` on both macOS and Linux. The + `--data-dir` option overrides this location for all configuration, mappings, + synchronization state, locks, sockets, and logs. Use the same resolved data + directory when spawning the daemon or rendering system service definitions. +- sync.rs reconciles current filesystem contents, records ownership and content + baselines, protects external destination edits, and copies without following + symlinks. Events are hints; periodic scans recover missed events. +- state.rs persists synchronization state in `state.db` using bundled SQLite. + Ownership, baselines, conflicts, and the last sync timestamp live in separate + tables. Apply related changes in one transaction, updating only changed rows. + Ownership survives a source deletion so Git can still commit that deletion. + Conflicts may refer to files not yet owned by FileTrail. + Validate application_id and user_version before accessing a database; never + silently reset damaged or unknown schemas. Publish a new database only after + its initial transaction succeeds. Reads and dry runs must not create a database. + There is no legacy JSON state reader or migration path. Callers hold the shared + operation lock across a state read/modify/write sequence; SQLite also provides + transactional consistency for state readers and writers. +- manifest.rs parses file-list lines as `source [target]` separated by whitespace. + Single/double quotes and escapes support spaces in either path; comments and + blank lines are allowed. Never execute a shell or expand variables in the list. + Reject extra fields, empty paths, and malformed quotes with a line-numbered + error, and validate the complete list before saving any mappings. +- git.rs handles local status/diff/commit. Background sync never stages or commits. + A commit includes only previously synchronized, still-managed paths. Preexisting + staged changes cause a refusal, without changing the index. +- daemon.rs owns native watching, periodic reconciliation, and the local socket. + All disk mutations share operation.lock; daemon.lock prevents duplicate daemons. + CLI config edits are atomic and picked up by the daemon without restarting it. +- service.rs renders/installs user-level launchd or systemd definitions. +- Default synchronization preserves deleted source files in the destination. + Opt-in deletion applies only to previously synchronized paths. A missing source + root directory must never trigger mass deletion. +- Never permit repository-relative paths to escape via `..`, `.git`, or a + destination ancestor symlink. Do not overwrite external destination edits + unless the user explicitly requests conflict resolution for that path. +- Default commit messages start with `filetrail: ` and list every selected file + change. Explicit user messages are preserved. No automatic commit or push. + +## Using Filetrail as an agent + +Use `filetrail --help` and subcommand help to discover the installed CLI. Select +an isolated profile with `--data-dir ` when testing. This directory +must be outside the source and target repository. + +Example real-user setup (execute only when the user requests configuration): + +```sh +filetrail init ~/dotfiles --subdir macos +filetrail add ~/.zshrc +filetrail add ~/.config/nvim +filetrail daemon start +``` + +On Linux, use `--subdir linux`, or omit it to write at the repository root. An +`add --to` path is relative to that configured subdirectory; paths passed to +`diff`, `commit`, and `resolve` are relative to the repository root. +For example, `filetrail add /opt/scripts/build.sh` with `--subdir macos` configured +stores `macos/opt/scripts/build.sh`; no explicit target is required. + +Keep both READMEs focused on how to use the product. Database schemas, state-file +layouts, internal locking/hashing details, toolchain versions, and formatter +configuration belong in source/configuration files and this guide, not the READMEs. + +For review, run `filetrail status` and `filetrail diff`. If a stable working tree +is needed, run `filetrail pause` first and `filetrail resume` afterward. Explicit +`filetrail sync` and `add` still synchronize while automatic sync is paused. +Only run `filetrail commit` when a commit is within the user's requested scope. +Omit `-m` to use the generated message. Do not run `resolve --use-source`, enable +deletion, or install a service merely to make a test or diagnostic pass. + +Report actual test outcomes and distinguish local checks from GitHub-hosted CI. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d0de3aa --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1410 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[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 0.61.2", +] + +[[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 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + +[[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 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[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 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filetrail" +version = "0.1.0" +dependencies = [ + "anyhow", + "blake3", + "clap", + "clap_complete", + "ctrlc", + "dirs", + "fs2", + "git2", + "globset", + "notify", + "rusqlite", + "serde", + "shlex", + "tempfile", + "toml", + "walkdir", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "url", +] + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libgit2-sys" +version = "0.18.8+1.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[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 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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 = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[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 = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[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 = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6861818 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,28 @@ +[package] +description = "Watch files, sync them into a Git repository, and commit on your terms." +edition = "2024" +name = "filetrail" +version = "0.1.0" + +[dependencies] +anyhow = "1.0" +blake3 = "1.8" +clap = { features = ["derive"], version = "4.5" } +clap_complete = "4.5" +ctrlc = { features = ["termination"], version = "3.4" } +dirs = "6.0" +fs2 = "0.4" +git2 = { default-features = false, features = ["vendored-libgit2"], version = "0.20" } +globset = "0.4" +notify = "8.2" +rusqlite = { features = ["bundled"], version = "0.40" } +serde = { features = ["derive"], version = "1.0" } +shlex = "2.0" +tempfile = "3.20" +toml = "0.9" +walkdir = "2.5" + +[profile.release] +codegen-units = 1 +lto = "thin" +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..c86fa5c --- /dev/null +++ b/README.md @@ -0,0 +1,204 @@ +# FileTrail + +English | [简体中文](README_zh.md) + +FileTrail is a file synchronization tool with Git version control. It watches the +files and directories you choose, syncs changes into a local Git repository, and +lets you review and commit them on your terms. + +Use it for dotfiles, scripts, notes, or other files spread across your machine. +Keep separate macOS and Linux configurations in the same repository. Everything +runs from a single executable, with no separate Git installation required. + +## Install + +Run the following from the project directory: + +```sh +cargo install --path . --locked +filetrail --help +``` + +## Get started + +```sh +filetrail init ~/dotfiles --subdir macos +filetrail add ~/.zshrc +filetrail add ~/.config/nvim +filetrail daemon start +``` + +On Linux, use `--subdir linux`. Omit `--subdir` to save at the repository root. +FileTrail creates the destination and initializes Git if needed. Adding a source +immediately copies its existing files; the daemon keeps subsequent changes in sync. + +## Choose where files go + +Sources inside HOME keep their Home-relative paths. Sources outside HOME keep their +absolute hierarchy without the leading `/`. Use `--to` to choose a different target, +relative to the subdirectory selected during `init`. + +| Source | Subdirectory | Target | File in the repository | +| --- | --- | --- | --- | +| `~/.zshrc` | `macos` | Default | `macos/.zshrc` | +| `~/.config/nvim` | `linux` | Default | `linux/.config/nvim/init.lua` | +| `/opt/scripts/build.sh` | `macos` | Default | `macos/opt/scripts/build.sh` | +| `/opt/scripts` | `macos` | `scripts` | `macos/scripts/build.sh` | + +```sh +filetrail add /opt/scripts --to scripts +filetrail add ~/notes --to notes --exclude '**/*.tmp' +``` + +Directories are watched recursively. Exclusions are relative to the source root. +Relative source paths are resolved from your current directory; parent-directory +symlinks are resolved to their actual locations. Sources, destinations, and the +application data directory must not overlap. + +## Add sources from a list + +```sh +filetrail add --from ./files.txt +``` + +Write one entry per line as `source` or `source target`, separated by spaces. +Use single or double quotes around paths containing spaces. Targets are optional; +omitting one uses the defaults above. + +```text +# source [target] +~/.zshrc +~/.config/nvim +/opt/scripts scripts +"~/My Notes" "notes backup" +'./local scripts' 'scripts backup' +``` + +Relative source paths are resolved from the list's directory. Blank lines and +`#` comments are allowed. Use `~` for HOME; environment variables and commands in +the list are not expanded or executed. The whole list is checked before any entries +are added. Editing it later does not update previously imported entries. + +## Manage sources + +```sh +filetrail list +filetrail disable 1 +filetrail enable 1 +filetrail remove 1 +filetrail sync +filetrail sync --dry-run +``` + +Use IDs from `list`. `remove` stops tracking a source and keeps its destination +files. `sync` copies current changes immediately; `--dry-run` previews them. + +Source deletions are retained at the destination by default. Enable deletion +propagation when adding a source: + +```sh +filetrail add ~/scripts --to scripts --delete +``` + +Only previously synchronized files can be deleted. If an entire source directory +becomes unavailable, FileTrail keeps its destination files. Symlinks are copied as +links, not followed; Git does not track empty directories. `.git` is always excluded, +and destination Git ignore rules apply when committing. + +## Review and commit + +```sh +filetrail status +filetrail diff +filetrail diff -- macos/.config/nvim +filetrail commit +filetrail commit -m 'Update shell configuration' +filetrail commit -- macos/.zshrc +``` + +The daemon never commits or pushes automatically. `diff` includes new file contents. +`commit` includes only managed files and refuses to proceed if other changes are +already staged. Set your Git name and email before your first commit. + +Without `-m`, FileTrail generates a message listing the selected changes: + +```text +filetrail: sync 3 files (+1 ~1 -1) + +add "macos/.config/nvim/init.lua" +delete "macos/.oldrc" +modify "macos/.zshrc" +``` + +To keep the destination stable while reviewing: + +```sh +filetrail pause +filetrail diff +filetrail commit +filetrail resume +``` + +`resume` catches up with changes made while paused. Explicit `sync` and `add` +commands still copy files while automatic synchronization is paused. Paths passed +to `diff`, `commit`, and `resolve` are relative to the repository root. + +## Resolve conflicts + +FileTrail reports a conflict if a destination differs from an existing source on +first sync, or if you modify the destination outside FileTrail. To explicitly use +the source version: + +```sh +filetrail conflicts +filetrail resolve macos/.zshrc --use-source +``` + +You can also make both copies identical yourself and run `filetrail sync` again. +Finish any Git merge/rebase or unresolved Git conflicts before resuming synchronization. + +## Run in the background + +```sh +filetrail daemon start +filetrail daemon status +filetrail daemon restart +filetrail daemon stop +filetrail daemon run # Foreground mode +filetrail daemon start --poll # Use periodic scans instead of filesystem events +``` + +For automatic startup at login, install a user service after placing the executable +in a stable location: + +```sh +filetrail service show +filetrail service install +filetrail service uninstall +``` + +macOS uses launchd and Linux uses systemd user services. Once installed, use +`service uninstall` to stop the service and prevent automatic restarts. + +## Data directory and troubleshooting + +FileTrail stores its application data in `$HOME/.filetrail` on both platforms. +Your synchronized files and Git history live in the repository chosen during `init`. +To use a different data directory, pass the same `--data-dir` to each command: + +```sh +filetrail --data-dir ~/filetrail-work init ~/work-dotfiles --subdir macos +filetrail --data-dir ~/filetrail-work daemon start +``` + +Use one data directory per repository. To inspect problems or discover more options: + +```sh +filetrail doctor +filetrail logs --follow +filetrail --help +filetrail add --help +filetrail completions zsh +``` + +For development instructions, see [AGENTS.md](AGENTS.md). diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 0000000..6272c16 --- /dev/null +++ b/README_zh.md @@ -0,0 +1,190 @@ +# FileTrail + +[English](README.md) | 简体中文 + +FileTrail 是一个支持 Git 版本管理的文件同步工具。它监听你指定的文件和目录, +将变化同步到本地 Git 仓库,让你查看差异并决定何时提交。 + +你可以用它管理分散在电脑上的 dotfiles、脚本、笔记等文件,也可以在同一仓库中 +分别保存 macOS 和 Linux 的配置。所有功能由一个可执行文件完成,无需额外安装 Git。 + +## 安装 + +在项目目录下执行: + +```sh +cargo install --path . --locked +filetrail --help +``` + +## 开始使用 + +```sh +filetrail init ~/dotfiles --subdir macos +filetrail add ~/.zshrc +filetrail add ~/.config/nvim +filetrail daemon start +``` + +Linux 上使用 `--subdir linux`;省略 `--subdir` 则保存到仓库根目录。 +目标不存在时,FileTrail 会创建目录并初始化 Git。添加来源后会立即复制已有文件, +后台任务负责同步后续变化。 + +## 选择保存位置 + +HOME 内的来源保留相对 HOME 的路径;HOME 外的来源保留绝对路径层级,去掉开头的 +`/`。使用 `--to` 可以自定义目标位置,它相对于 `init` 时选择的子目录。 + +| 来源 | 子目录 | 目标 | 仓库中的文件 | +| --- | --- | --- | --- | +| `~/.zshrc` | `macos` | 默认 | `macos/.zshrc` | +| `~/.config/nvim` | `linux` | 默认 | `linux/.config/nvim/init.lua` | +| `/opt/scripts/build.sh` | `macos` | 默认 | `macos/opt/scripts/build.sh` | +| `/opt/scripts` | `macos` | `scripts` | `macos/scripts/build.sh` | + +```sh +filetrail add /opt/scripts --to scripts +filetrail add ~/notes --to notes --exclude '**/*.tmp' +``` + +目录默认递归监听,排除规则相对于来源根目录。相对来源路径以当前目录为基准, +父目录中的符号链接会解析为实际路径。来源、目标和应用数据目录不能相互重叠。 + +## 从列表批量添加 + +```sh +filetrail add --from ./files.txt +``` + +每行写成 `source` 或 `source target`,用空格分隔。路径中包含空格时,使用单引号 +或双引号包住。目标位置可选,省略时使用前面介绍的默认规则。 + +```text +# source [target] +~/.zshrc +~/.config/nvim +/opt/scripts scripts +"~/My Notes" "notes backup" +'./local scripts' 'scripts backup' +``` + +相对来源路径以列表文件所在目录为基准,支持空行和 `#` 注释。HOME 路径使用 `~`; +列表中的环境变量和命令不会被展开或执行。整个列表检查通过后才会添加,后续编辑 +列表不会更新已经导入的条目。 + +## 管理监听项 + +```sh +filetrail list +filetrail disable 1 +filetrail enable 1 +filetrail remove 1 +filetrail sync +filetrail sync --dry-run +``` + +条目 ID 从 `list` 中查看。`remove` 停止监听并保留目标文件。`sync` 立即同步当前 +变化,`--dry-run` 只预览将执行的操作。 + +默认情况下,源文件删除后仍保留目标文件。添加来源时可开启同步删除: + +```sh +filetrail add ~/scripts --to scripts --delete +``` + +只会删除以前成功同步过的文件。如果整个来源目录不可用,FileTrail 会保留目标 +文件。符号链接按链接本身复制,不跟随其内容;Git 不记录空目录。`.git` 始终排除, +提交时遵循目标仓库的 Git 忽略规则。 + +## 查看差异与提交 + +```sh +filetrail status +filetrail diff +filetrail diff -- macos/.config/nvim +filetrail commit +filetrail commit -m 'Update shell configuration' +filetrail commit -- macos/.zshrc +``` + +后台不会自动提交或推送。`diff` 包含新增文件的内容;`commit` 只提交受管理的文件, +仓库已有暂存修改时会拒绝提交。首次提交前,请配置好 Git 用户名和邮箱。 + +不传 `-m` 时,FileTrail 会自动生成列出本次变化的消息: + +```text +filetrail: sync 3 files (+1 ~1 -1) + +add "macos/.config/nvim/init.lua" +delete "macos/.oldrc" +modify "macos/.zshrc" +``` + +检查期间需要保持目标内容稳定,可以暂停自动同步: + +```sh +filetrail pause +filetrail diff +filetrail commit +filetrail resume +``` + +`resume` 会补齐暂停期间的变化。显式执行 `sync` 和 `add` 时,即使自动同步已暂停, +仍会复制文件。`diff`、`commit` 和 `resolve` 接收的路径都相对于仓库根目录。 + +## 处理冲突 + +首次同步时目标与已有来源内容不同,或者你在 FileTrail 之外修改了目标文件, +FileTrail 会报告冲突。明确希望使用来源版本时执行: + +```sh +filetrail conflicts +filetrail resolve macos/.zshrc --use-source +``` + +也可以自行将两份文件改为相同内容,再运行 `filetrail sync`。仓库正在进行 +merge/rebase 或存在 Git 冲突时,需要先处理完成,再恢复同步。 + +## 后台运行 + +```sh +filetrail daemon start +filetrail daemon status +filetrail daemon restart +filetrail daemon stop +filetrail daemon run # 前台运行 +filetrail daemon start --poll # 使用定期扫描代替文件事件监听 +``` + +需要登录后自动启动时,先把可执行文件放到固定位置,再安装用户服务: + +```sh +filetrail service show +filetrail service install +filetrail service uninstall +``` + +macOS 使用 launchd,Linux 使用 systemd 用户服务。安装后,使用 `service uninstall` +停止服务并取消自动拉起。 + +## 数据目录与排查问题 + +两个平台都默认将应用数据保存在 `$HOME/.filetrail`。同步后的文件与 Git 历史保存在 +`init` 指定的仓库。需要使用其他数据目录时,每条命令传入相同的 `--data-dir`: + +```sh +filetrail --data-dir ~/filetrail-work init ~/work-dotfiles --subdir macos +filetrail --data-dir ~/filetrail-work daemon start +``` + +每个仓库使用一套数据目录。遇到问题或需要查看更多用法时,可以执行: + +```sh +filetrail doctor +filetrail logs --follow +filetrail --help +filetrail add --help +filetrail completions zsh +``` + +开发说明见 [AGENTS.md](AGENTS.md)。 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..f627384 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "nightly-2026-09-07" +components = ["clippy", "rustfmt"] +profile = "minimal" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..9aa2d6b --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,5 @@ +edition = "2024" +group_imports = "StdExternalCrate" +imports_granularity = "Item" +reorder_imports = true +unstable_features = true diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e068e44 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,310 @@ +use std::collections::BTreeMap; +use std::fs; +use std::fs::File; +use std::fs::OpenOptions; +use std::io::Write; +use std::os::unix::fs::PermissionsExt; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + pub version: u32, + pub repository: PathBuf, + pub subdir: PathBuf, + #[serde(default)] + pub exclude: Vec, + #[serde(default = "interval")] + pub scan_interval_secs: u64, + #[serde(default)] + pub entries: Vec, +} + +fn interval() -> u64 { + 30 +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Entry { + pub id: u64, + pub source: PathBuf, + pub target: PathBuf, + pub directory: bool, + pub enabled: bool, + pub delete: bool, + #[serde(default)] + pub exclude: Vec, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct State { + pub owned: BTreeMap, + pub files: BTreeMap, + pub conflicts: BTreeMap, + pub last_sync: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Baseline { + pub entry: u64, + pub fingerprint: String, +} + +#[derive(Clone, Debug)] +pub struct Store { + pub root: PathBuf, +} + +impl Store { + pub fn new(root: PathBuf) -> Result { + fs::create_dir_all(&root)?; + fs::set_permissions(&root, fs::Permissions::from_mode(0o700))?; + Ok(Self { + root: fs::canonicalize(root)?, + }) + } + + pub fn lock(&self) -> Result { + let file = self.lock_file("operation.lock")?; + fs2::FileExt::lock_exclusive(&file)?; + Ok(file) + } + + pub fn lock_file(&self, name: &str) -> Result { + Ok(OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(self.root.join(name))?) + } + + pub fn config(&self) -> Result { + let text = fs::read_to_string(self.root.join("config.toml")) + .context("not initialized; run filetrail init ")?; + let config: Config = toml::from_str(&text).context("invalid config.toml")?; + config.validate()?; + self.validate_layout(&config)?; + Ok(config) + } + + pub fn save_config(&self, config: &Config) -> Result<()> { + config.validate()?; + self.validate_layout(config)?; + atomic_write( + &self.root.join("config.toml"), + toml::to_string_pretty(config)?.as_bytes(), + ) + } + + pub fn state(&self) -> Result { + crate::state::load(&self.root) + } + + fn validate_layout(&self, config: &Config) -> Result<()> { + if self.root.starts_with(&config.repository) || config.repository.starts_with(&self.root) { + bail!("repository and data directory must not overlap"); + } + for entry in &config.entries { + if entry.source.starts_with(&self.root) || self.root.starts_with(&entry.source) { + bail!("source and data directory must not overlap"); + } + } + Ok(()) + } + + pub fn save_state(&self, state: &State) -> Result<()> { + crate::state::save(&self.root, state) + } +} + +impl Config { + pub fn new(repository: PathBuf, subdir: PathBuf) -> Result { + let config = Self { + version: 1, + repository, + subdir: relative(&subdir)?, + exclude: vec![], + scan_interval_secs: interval(), + entries: vec![], + }; + config.validate()?; + Ok(config) + } + + pub fn validate(&self) -> Result<()> { + if self.version != 1 || self.scan_interval_secs == 0 { + bail!("unsupported config version or zero scan interval"); + } + if !self.repository.is_absolute() { + bail!("repository must be absolute"); + } + relative(&self.subdir)?; + crate::sync::exclusions(&self.exclude)?; + for (i, entry) in self.entries.iter().enumerate() { + let normalized_target = relative(&entry.target)?; + if normalized_target.as_os_str().is_empty() + || !entry.source.is_absolute() + || entry + .source + .components() + .any(|part| matches!(part, Component::ParentDir)) + { + bail!("invalid source or target for entry {}", entry.id); + } + crate::sync::key(&entry.source)?; + crate::sync::key(&entry.target)?; + crate::sync::exclusions(&entry.exclude)?; + if self.repository.starts_with(&entry.source) + || entry.source.starts_with(&self.repository) + { + bail!( + "source and repository must not overlap: {}", + entry.source.display() + ); + } + for other in &self.entries[..i] { + let target_folded = + PathBuf::from(normalized_target.to_string_lossy().to_lowercase()); + let other_folded = + PathBuf::from(relative(&other.target)?.to_string_lossy().to_lowercase()); + if entry.id == other.id + || target_folded.starts_with(&other_folded) + || other_folded.starts_with(&target_folded) + || entry.source.starts_with(&other.source) + || other.source.starts_with(&entry.source) + { + bail!( + "overlapping mappings or duplicate ID: {} and {}", + other.id, + entry.id + ); + } + } + } + Ok(()) + } + + pub fn destination(&self, entry: &Entry) -> PathBuf { + self.subdir + .join(&entry.target) + .components() + .filter(|part| !matches!(part, Component::CurDir)) + .collect() + } +} + +pub fn relative(path: &Path) -> Result { + let mut clean = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => (), + Component::Normal(name) if !name.to_string_lossy().eq_ignore_ascii_case(".git") => { + clean.push(name) + } + _ => bail!( + "target must be a repository-relative path without '..' or '.git': {}", + path.display() + ), + } + } + Ok(clean) +} + +pub fn expand(path: &Path, base: &Path) -> Result { + let expanded = if path == Path::new("~") { + dirs::home_dir().context("cannot determine home directory")? + } else if let Ok(tail) = path.strip_prefix("~") { + dirs::home_dir() + .context("cannot determine home directory")? + .join(tail) + } else if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + }; + // Resolve parent symlinks, but preserve a leaf symlink as a link to copy. + let parent = expanded.parent().context("source must have a parent")?; + Ok(fs::canonicalize(parent)?.join( + expanded + .file_name() + .context("source cannot be filesystem root")?, + )) +} + +/// Map Home files relative to Home, and other files relative to the filesystem root. +pub fn default_target(source: &Path, home: &Path) -> Result { + if !source.is_absolute() || !home.is_absolute() { + bail!("source and Home paths must be absolute"); + } + let target = source + .strip_prefix(home) + .or_else(|_| source.strip_prefix("/"))?; + relative(target) +} + +pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().context("missing parent")?; + fs::create_dir_all(parent)?; + let mut file = tempfile::NamedTempFile::new_in(parent)?; + file.write_all(bytes)?; + file.as_file().sync_all()?; + file.persist(path).map_err(|error| error.error)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + use std::path::PathBuf; + + use super::default_target; + use super::relative; + + #[test] + fn default_targets_preserve_home_relative_and_external_absolute_paths() { + let home = Path::new("/home/alice"); + for (source, expected) in [ + ("/home/alice/.zshrc", ".zshrc"), + ("/home/alice/.config/nvim", ".config/nvim"), + ("/opt/scripts/build.sh", "opt/scripts/build.sh"), + ("/opt/scripts", "opt/scripts"), + ("/home/alice-other/settings", "home/alice-other/settings"), + ] { + assert_eq!( + default_target(Path::new(source), home).unwrap(), + PathBuf::from(expected) + ); + } + assert!(default_target(Path::new("relative"), home).is_err()); + assert!(default_target(Path::new("/opt/../escape"), home).is_err()); + assert!(default_target(Path::new("/opt/.git/config"), home).is_err()); + } + + #[test] + fn rejects_escaping_and_git_targets() { + for path in [ + "../escape", + "/absolute", + "foo/../../bad", + ".git/config", + "foo/.GIT/config", + ] { + assert!(relative(Path::new(path)).is_err(), "{path}"); + } + assert_eq!( + relative(Path::new("./macos/.config")).unwrap(), + PathBuf::from("macos/.config") + ); + assert_eq!(relative(Path::new(".")).unwrap(), PathBuf::new()); + } +} diff --git a/src/daemon.rs b/src/daemon.rs new file mode 100644 index 0000000..7f52fd9 --- /dev/null +++ b/src/daemon.rs @@ -0,0 +1,267 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::BufRead; +use std::io::BufReader; +use std::io::Write; +use std::os::unix::net::UnixListener; +use std::os::unix::net::UnixStream; +use std::os::unix::process::CommandExt; +use std::process::Command; +use std::process::Stdio; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; +use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use notify::RecursiveMode; +use notify::Watcher; + +use crate::config::Config; +use crate::config::Store; + +pub fn request(store: &Store, command: &str) -> Result { + let mut stream = UnixStream::connect(store.root.join("daemon.sock")) + .context("daemon is not running; use filetrail daemon start")?; + stream.set_read_timeout(Some(Duration::from_secs(30)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + writeln!(stream, "{command}")?; + let mut result = String::new(); + BufReader::new(stream).read_line(&mut result)?; + if result.is_empty() { + bail!("daemon disconnected without responding"); + } + Ok(result.trim_end().to_owned()) +} + +pub fn start(store: &Store, poll: bool) -> Result { + if let Ok(status) = request(store, "status") { + return Ok(status); + } + store.config()?; + let output = fs::OpenOptions::new() + .create(true) + .append(true) + .open(store.root.join("daemon-output.log"))?; + let mut command = Command::new(std::env::current_exe()?); + command + .arg("--data-dir") + .arg(&store.root) + .args(["daemon", "run"]); + if poll { + command.arg("--poll"); + } + let mut child = command + .process_group(0) + .stdin(Stdio::null()) + .stdout(output.try_clone()?) + .stderr(output) + .spawn()?; + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Ok(status) = request(store, "status") { + return Ok(status); + } + if let Some(status) = child.try_wait()? { + bail!( + "daemon exited ({status}); inspect {}", + store.root.join("daemon-output.log").display() + ); + } + thread::sleep(Duration::from_millis(50)); + } + child.kill()?; + child.wait()?; + bail!("daemon did not become ready within 10 seconds") +} + +pub fn stop(store: &Store) -> Result { + let response = request(store, "stop")?; + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let lock = store.lock_file("daemon.lock")?; + if fs2::FileExt::try_lock_exclusive(&lock).is_ok() { + return Ok(response); + } + if Instant::now() >= deadline { + bail!("daemon is still stopping"); + } + thread::sleep(Duration::from_millis(50)); + } +} + +pub fn log(store: &Store, message: &str) -> Result<()> { + let path = store.root.join("filetrail.log"); + if fs::metadata(&path).is_ok_and(|metadata| metadata.len() > 2 * 1024 * 1024) { + fs::rename(&path, store.root.join("filetrail.log.1"))?; + } + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + writeln!( + file, + "{} {message}", + SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + )?; + Ok(()) +} + +fn watcher( + config: &Config, + tx: mpsc::SyncSender<()>, + store: &Store, +) -> Result { + let mut watcher = notify::recommended_watcher(move |_: notify::Result| { + let _ = tx.try_send(()); + })?; + let mut roots = BTreeMap::new(); + for entry in config.entries.iter().filter(|entry| entry.enabled) { + if entry.directory && entry.source.is_dir() { + roots.insert(entry.source.clone(), RecursiveMode::Recursive); + } + // Watch parents so atomic replacement and recreation do not lose the subscription. + if let Some(parent) = entry.source.parent() { + roots + .entry(parent.to_path_buf()) + .or_insert(RecursiveMode::NonRecursive); + } + } + for (path, mode) in roots { + if let Err(error) = watcher.watch(&path, mode) { + log( + store, + &format!( + "watch failed for {}: {error}; periodic scans remain active", + path.display() + ), + )?; + } + } + Ok(watcher) +} + +pub fn run(store: &Store, poll: bool) -> Result<()> { + let singleton = store.lock_file("daemon.lock")?; + fs2::FileExt::try_lock_exclusive(&singleton).context("another daemon is already running")?; + let socket = store.root.join("daemon.sock"); + if socket.exists() { + fs::remove_file(&socket)?; + } + let listener = UnixListener::bind(&socket).context( + "cannot bind daemon socket; choose a shorter --data-dir if its path is too long", + )?; + listener.set_nonblocking(true)?; + let stopped = Arc::new(AtomicBool::new(false)); + let signal = Arc::clone(&stopped); + ctrlc::set_handler(move || signal.store(true, Ordering::SeqCst))?; + let mut config = store.config()?; + let (tx, rx) = mpsc::sync_channel(1); + let mut active_watcher = if poll { + None + } else { + Some(watcher(&config, tx.clone(), store)?) + }; + let mut config_text = fs::read(store.root.join("config.toml"))?; + let mut config_check = Instant::now(); + let mut last_scan = Instant::now(); + let mut dirty = Some(Instant::now()); + let mut latest_event = Instant::now(); + let mut paused = false; + log(store, "daemon started")?; + while !stopped.load(Ordering::SeqCst) { + match listener.accept() { + Ok((mut stream, _)) => { + stream.set_read_timeout(Some(Duration::from_millis(500)))?; + stream.set_write_timeout(Some(Duration::from_secs(1)))?; + let mut input = String::new(); + if BufReader::new(&stream).read_line(&mut input).is_ok() { + let response = match input.trim() { + "status" => format!( + "running pid={} paused={paused} mode={}", + std::process::id(), + if poll { "poll" } else { "native+periodic" } + ), + "pause" => { + paused = true; + "paused".into() + } + "resume" => { + paused = false; + dirty = Some(Instant::now()); + "resumed".into() + } + "stop" => { + stopped.store(true, Ordering::SeqCst); + "stopped".into() + } + _ => "unknown command".into(), + }; + let _ = writeln!(stream, "{response}"); + } + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => (), + Err(error) => return Err(error.into()), + } + if stopped.load(Ordering::SeqCst) { + break; + } + if rx.try_recv().is_ok() { + dirty.get_or_insert_with(Instant::now); + latest_event = Instant::now(); + } + if config_check.elapsed() >= Duration::from_secs(1) { + config_check = Instant::now(); + if let Ok(bytes) = fs::read(store.root.join("config.toml")) + && bytes != config_text + { + match store.config() { + Ok(updated) => { + config = updated; + active_watcher = if poll { + None + } else { + Some(watcher(&config, tx.clone(), store)?) + }; + config_text = bytes; + dirty.get_or_insert_with(Instant::now); + log(store, "configuration reloaded")?; + } + Err(error) => { + log(store, &format!("invalid configuration: {error:#}"))?; + config_text = bytes; + } + } + } + } + let due = last_scan.elapsed() >= Duration::from_secs(config.scan_interval_secs); + let settled = dirty.is_some_and(|first| { + latest_event.elapsed() >= Duration::from_millis(350) + || first.elapsed() >= Duration::from_secs(2) + }); + if !paused && (due || settled) { + match crate::sync::run(store, false, None) { + Ok(report) => { + if !report.text().is_empty() { + log(store, &report.text())?; + } + } + Err(error) => log(store, &format!("sync failed: {error:#}"))?, + } + dirty = None; + last_scan = Instant::now(); + } + thread::sleep(Duration::from_millis(50)); + } + drop(active_watcher); + fs::remove_file(socket)?; + log(store, "daemon stopped")?; + Ok(()) +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..42a0898 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,334 @@ +use std::path::Path; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use git2::DiffFormat; +use git2::DiffOptions; +use git2::Repository; +use git2::RepositoryState; +use git2::Status; +use git2::StatusOptions; + +use crate::config::Config; +use crate::config::State; +use crate::config::Store; + +pub fn open(config: &Config) -> Result { + let repository = Repository::open(&config.repository)?; + if repository.is_bare() || repository.workdir() != Some(config.repository.as_path()) { + bail!("target must be the root of a non-bare Git repository"); + } + Ok(repository) +} + +pub fn ensure_idle(repository: &Repository) -> Result<()> { + if repository.state() != RepositoryState::Clean || repository.index()?.has_conflicts() { + bail!("repository has an ongoing Git operation or unresolved conflicts"); + } + Ok(()) +} + +#[derive(Clone, Debug)] +pub struct Change { + pub path: String, + pub status: Status, +} + +pub fn changes(repository: &Repository) -> Result> { + let mut options = StatusOptions::new(); + options + .include_untracked(true) + .recurse_untracked_dirs(true) + .renames_head_to_index(false) + .renames_index_to_workdir(false); + let statuses = repository.statuses(Some(&mut options))?; + let mut changes = statuses + .iter() + .map(|entry| { + Ok(Change { + path: entry.path().context("Git path is not UTF-8")?.to_owned(), + status: entry.status(), + }) + }) + .collect::>>()?; + changes.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(changes) +} + +fn managed(config: &Config, state: &State, path: &str) -> bool { + state.owned.get(path).is_some_and(|id| { + config + .entries + .iter() + .any(|entry| entry.id == *id && Path::new(path).starts_with(config.destination(entry))) + }) +} + +fn code(status: Status, index: bool) -> char { + let (new, modified, deleted, renamed, kind) = if index { + ( + Status::INDEX_NEW, + Status::INDEX_MODIFIED, + Status::INDEX_DELETED, + Status::INDEX_RENAMED, + Status::INDEX_TYPECHANGE, + ) + } else { + ( + Status::WT_NEW, + Status::WT_MODIFIED, + Status::WT_DELETED, + Status::WT_RENAMED, + Status::WT_TYPECHANGE, + ) + }; + if status.is_conflicted() { + 'U' + } else if status.contains(new) { + 'A' + } else if status.contains(deleted) { + 'D' + } else if status.contains(renamed) { + 'R' + } else if status.contains(kind) { + 'T' + } else if status.contains(modified) { + 'M' + } else { + ' ' + } +} + +pub fn status(store: &Store) -> Result { + let _lock = store.lock()?; + let config = store.config()?; + let state = store.state()?; + let repository = open(&config)?; + let mut lines = vec![ + format!("Repository: {}", config.repository.display()), + format!( + "Subdirectory: {}", + if config.subdir.as_os_str().is_empty() { + ".".to_owned() + } else { + config.subdir.display().to_string() + } + ), + format!("Last sync (Unix seconds): {:?}", state.last_sync), + ]; + for change in changes(&repository)? { + lines.push(format!( + "{}{} [{}] {}", + code(change.status, true), + code(change.status, false), + if managed(&config, &state, &change.path) { + "managed" + } else { + "other" + }, + change.path + )); + } + for path in state.owned.keys() { + if managed(&config, &state, path) && repository.is_path_ignored(Path::new(path))? { + lines.push(format!("!! [ignored] {path}")); + } + } + for (path, reason) in state.conflicts { + lines.push(format!("conflict {path}: {reason}")); + } + Ok(lines.join("\n")) +} + +pub fn diff(store: &Store, paths: &[String]) -> Result { + let _lock = store.lock()?; + let config = store.config()?; + let repository = open(&config)?; + let head = match repository.head() { + Ok(head) => Some(head.peel_to_tree()?), + Err(error) + if matches!( + error.code(), + git2::ErrorCode::UnbornBranch | git2::ErrorCode::NotFound + ) => + { + None + } + Err(error) => return Err(error.into()), + }; + let options = || -> Result { + let mut options = DiffOptions::new(); + options + .include_untracked(true) + .recurse_untracked_dirs(true) + .show_untracked_content(true); + options.disable_pathspec_match(true); + for path in paths { + options.pathspec(crate::config::relative(Path::new(path))?); + } + Ok(options) + }; + let staged = repository.diff_tree_to_index(head.as_ref(), None, Some(&mut options()?))?; + let worktree = repository.diff_index_to_workdir(None, Some(&mut options()?))?; + let mut output = Vec::new(); + for (label, mut diff) in [ + ("Staged changes", staged), + ("Working tree changes", worktree), + ] { + diff.find_similar(None)?; + if diff.deltas().len() == 0 { + continue; + } + output.extend_from_slice(format!("{label}\n").as_bytes()); + diff.print(DiffFormat::Patch, |_, _, line| { + if matches!(line.origin(), '+' | '-' | ' ') { + output.push(line.origin() as u8); + } + output.extend_from_slice(line.content()); + true + })?; + } + Ok(String::from_utf8_lossy(&output).into_owned()) +} + +pub fn default_message(changes: &[Change]) -> String { + let mut added = 0; + let mut modified = 0; + let mut deleted = 0; + let mut details = Vec::new(); + for change in changes { + let action = if change.status.contains(Status::WT_NEW) { + added += 1; + "add" + } else if change.status.contains(Status::WT_DELETED) { + deleted += 1; + "delete" + } else { + modified += 1; + "modify" + }; + // Debug quoting prevents filenames containing line breaks from forging message lines. + details.push(format!("{action} {:?}", change.path)); + } + details.sort(); + format!( + "filetrail: sync {} files (+{added} ~{modified} -{deleted})\n\n{}", + changes.len(), + details.join("\n") + ) +} + +pub fn commit(store: &Store, message: Option<&str>, paths: &[String]) -> Result { + let _lock = store.lock()?; + let config = store.config()?; + let state = store.state()?; + let repository = open(&config)?; + ensure_idle(&repository)?; + let all = changes(&repository)?; + let staged = Status::INDEX_NEW + | Status::INDEX_MODIFIED + | Status::INDEX_DELETED + | Status::INDEX_RENAMED + | Status::INDEX_TYPECHANGE; + if all.iter().any(|change| change.status.intersects(staged)) { + bail!( + "repository already contains staged changes; commit or unstage them first (Filetrail leaves the index untouched)" + ); + } + let paths = paths + .iter() + .map(|path| crate::config::relative(Path::new(path))) + .collect::>>()?; + let selected = all + .into_iter() + .filter(|change| { + managed(&config, &state, &change.path) + && (paths.is_empty() + || paths + .iter() + .any(|path| Path::new(&change.path).starts_with(path))) + }) + .collect::>(); + if selected.is_empty() { + bail!("no managed changes to commit; run filetrail sync/status first"); + } + for change in &selected { + if state.conflicts.contains_key(&change.path) { + bail!("unresolved synchronization conflict: {}", change.path); + } + crate::sync::safe_destination(&config.repository, Path::new(&change.path))?; + } + let message = match message { + Some(message) if message.trim().is_empty() => bail!("commit message cannot be empty"), + Some(message) => message.to_owned(), + None => default_message(&selected), + }; + let signature = repository + .signature() + .context("configure Git user.name and user.email before committing")?; + let parent = match repository.head() { + Ok(head) => Some(head.peel_to_commit()?), + Err(error) + if matches!( + error.code(), + git2::ErrorCode::UnbornBranch | git2::ErrorCode::NotFound + ) => + { + None + } + Err(error) => return Err(error.into()), + }; + let mut index = repository.index()?; + for change in &selected { + if change.status.contains(Status::WT_DELETED) { + index.remove_path(Path::new(&change.path))?; + } else { + index.add_path(Path::new(&change.path))?; + } + } + let tree = repository.find_tree(index.write_tree()?)?; + let parents = parent.iter().collect::>(); + let oid = repository.commit( + Some("HEAD"), + &signature, + &signature, + &message, + &tree, + &parents, + )?; + index + .write() + .context("commit created, but index update failed; inspect Git status before continuing")?; + Ok(format!("{oid}\n{message}")) +} + +#[cfg(test)] +mod tests { + use git2::Status; + + use super::Change; + use super::default_message; + + #[test] + fn automatic_message_lists_all_changes_deterministically() { + let message = default_message(&[ + Change { + path: "z".into(), + status: Status::WT_DELETED, + }, + Change { + path: "a".into(), + status: Status::WT_NEW, + }, + Change { + path: "m\nname".into(), + status: Status::WT_MODIFIED, + }, + ]); + assert_eq!( + message, + "filetrail: sync 3 files (+1 ~1 -1)\n\nadd \"a\"\ndelete \"z\"\nmodify \"m\\nname\"" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..afde14b --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,12 @@ +#![forbid(unsafe_code)] + +#[cfg(not(unix))] +compile_error!("Filetrail currently supports macOS and Linux only"); + +pub mod config; +pub mod daemon; +pub mod git; +pub mod manifest; +pub mod service; +mod state; +pub mod sync; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..10f1730 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,489 @@ +#![forbid(unsafe_code)] + +use std::fs; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use clap::CommandFactory; +use clap::Parser; +use clap::Subcommand; +use clap_complete::Shell; +use filetrail::config::Config; +use filetrail::config::Entry; +use filetrail::config::Store; + +#[derive(Parser)] +#[command( + version, + about = "Watch files, sync into Git, and commit on your terms" +)] +struct Cli { + /// Store configuration, mappings, synchronization state, sockets, and logs here [default: $HOME/.filetrail]. + #[arg(long, global = true)] + data_dir: Option, + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Set the target repository and its optional platform subdirectory. + Init { + repository: PathBuf, + /// Repository-relative destination root, e.g. macos or linux. + #[arg(long, default_value = ".")] + subdir: PathBuf, + }, + /// Add a source and immediately synchronize existing files. + Add { + #[arg(required_unless_present = "from", conflicts_with = "from")] + source: Option, + /// Override the default Home-relative or root-relative destination. + #[arg(long, conflicts_with = "from")] + to: Option, + /// Import source [target] lines separated by spaces; quote paths containing spaces. + #[arg(long)] + from: Option, + /// Propagate source deletions for files previously synchronized. + #[arg(long)] + delete: bool, + /// Exclude a glob relative to each source root; may be repeated. + #[arg(long)] + exclude: Vec, + }, + /// List source mappings and IDs. + List, + /// Stop tracking an entry, keeping its destination files. + Remove { + id: u64, + }, + Enable { + id: u64, + }, + Disable { + id: u64, + }, + /// Synchronize now, even if automatic synchronization is paused. + Sync { + #[arg(long)] + dry_run: bool, + }, + #[command(subcommand)] + Daemon(DaemonCommands), + #[command(subcommand)] + Service(ServiceCommands), + /// Pause automatic synchronization after any current operation finishes. + Pause, + /// Resume automatic synchronization and catch up with source changes. + Resume, + /// Show repository changes, ownership, conflicts, and daemon status. + Status, + /// Show staged and working tree diffs, including untracked file contents. + Diff { + paths: Vec, + }, + /// Commit managed changes; generates a filetrail: message by default. + Commit { + #[arg(short, long)] + message: Option, + paths: Vec, + }, + Conflicts, + /// Resolve one conflict by replacing its target with the source version. + Resolve { + path: PathBuf, + #[arg(long, required = true)] + use_source: bool, + }, + Logs { + #[arg(long)] + follow: bool, + }, + /// Validate configuration, Git state, source availability, and mappings. + Doctor, + /// Generate shell completion definitions. + Completions { + shell: Shell, + }, +} + +#[derive(Subcommand)] +enum DaemonCommands { + Start { + #[arg(long)] + poll: bool, + }, + Stop, + Restart { + #[arg(long)] + poll: bool, + }, + Status, + /// Run in the foreground (also used by launchd/systemd). + Run { + #[arg(long)] + poll: bool, + }, +} + +#[derive(Subcommand)] +enum ServiceCommands { + Install, + Uninstall, + /// Print the service definition without installing it. + Show, +} + +fn main() { + if let Err(error) = execute(Cli::parse()) { + eprintln!("error: {error:#}"); + std::process::exit(1); + } +} + +fn execute(cli: Cli) -> Result<()> { + if let Commands::Completions { shell } = cli.command { + clap_complete::generate( + shell, + &mut Cli::command(), + "filetrail", + &mut std::io::stdout(), + ); + return Ok(()); + } + let store = Store::new(data_root(cli.data_dir)?)?; + match cli.command { + Commands::Init { repository, subdir } => { + let _lock = store.lock()?; + if store.root.join("config.toml").exists() { + bail!("already initialized; edit config.toml or use a different --data-dir"); + } + let subdir = filetrail::config::relative(&subdir)?; + let repository = if repository.starts_with("~") { + dirs::home_dir() + .context("cannot determine home directory")? + .join(repository.strip_prefix("~")?) + } else { + repository + }; + fs::create_dir_all(&repository)?; + let repository = fs::canonicalize(repository)?; + if repository.starts_with(&store.root) || store.root.starts_with(&repository) { + bail!("repository and data directory must not overlap"); + } + let config = Config::new(repository.clone(), subdir)?; + let repo = if repository.join(".git").exists() { + git2::Repository::open(&repository)? + } else { + git2::Repository::init(&repository)? + }; + drop(repo); + filetrail::git::open(&config)?; + filetrail::sync::safe_destination( + &repository, + &config.subdir.join(".filetrail-check"), + )?; + store.save_config(&config)?; + println!( + "Initialized {} (subdirectory: {})\nData directory: {}\nConfig: {}", + repository.display(), + if config.subdir.as_os_str().is_empty() { + ".".to_owned() + } else { + config.subdir.display().to_string() + }, + store.root.display(), + store.root.join("config.toml").display() + ); + } + Commands::Add { + source, + to, + from, + delete, + exclude, + } => { + { + let _lock = store.lock()?; + let mut config = store.config()?; + let state = store.state()?; + let mut next = config + .entries + .iter() + .map(|entry| entry.id) + .chain(state.owned.values().copied()) + .max() + .unwrap_or(0) + + 1; + let cwd = std::env::current_dir()?; + let mut sources = Vec::new(); + if let Some(list) = from { + let list = filetrail::config::expand(&list, &cwd)?; + let base = list.parent().context("list has no parent")?; + for (line_number, line) in fs::read_to_string(&list)?.lines().enumerate() { + let Some((source, target)) = filetrail::manifest::parse_line(line) + .with_context(|| format!("{}:{}", list.display(), line_number + 1))? + else { + continue; + }; + sources.push(( + filetrail::config::expand(&source, base).with_context(|| { + format!("{}:{}", list.display(), line_number + 1) + })?, + target, + )); + } + } else { + sources.push(( + filetrail::config::expand(&source.context("missing source")?, &cwd)?, + to, + )); + } + if sources.is_empty() { + bail!("source list contains no entries"); + } + for (source, target) in sources { + if source.starts_with(&store.root) || store.root.starts_with(&source) { + bail!("source and data directory must not overlap"); + } + filetrail::sync::key(&source)?; + let metadata = fs::symlink_metadata(&source)?; + if !metadata.is_file() + && !metadata.is_dir() + && !metadata.file_type().is_symlink() + { + bail!("source must be a regular file, directory, or symlink"); + } + let target = match target { + Some(target) => filetrail::config::relative(&target)?, + None => { + let home = fs::canonicalize( + dirs::home_dir().context("cannot determine home directory")?, + )?; + filetrail::config::default_target(&source, &home)? + } + }; + config.entries.push(Entry { + id: next, + source, + target, + directory: metadata.is_dir(), + enabled: true, + delete, + exclude: exclude.clone(), + }); + next += 1; + } + store.save_config(&config)?; + } + print_report(filetrail::sync::run(&store, false, None)?)?; + } + Commands::List => { + let _lock = store.lock()?; + let config = store.config()?; + for entry in &config.entries { + println!( + "{} [{}] {} -> {} (delete={})", + entry.id, + if entry.enabled { "enabled" } else { "disabled" }, + entry.source.display(), + config.destination(entry).display(), + entry.delete + ); + } + } + Commands::Remove { id } | Commands::Enable { id } | Commands::Disable { id } => { + let _lock = store.lock()?; + let mut config = store.config()?; + let entry = config + .entries + .iter_mut() + .find(|entry| entry.id == id) + .context("unknown entry ID")?; + match cli.command { + Commands::Remove { .. } => { + config.entries.retain(|entry| entry.id != id); + let mut state = store.state()?; + state + .conflicts + .retain(|path, _| state.owned.get(path) != Some(&id)); + store.save_state(&state)?; + } + Commands::Enable { .. } => entry.enabled = true, + _ => entry.enabled = false, + } + store.save_config(&config)?; + } + Commands::Sync { dry_run } => print_report(filetrail::sync::run(&store, dry_run, None)?)?, + Commands::Daemon(command) => match command { + DaemonCommands::Start { poll } => { + println!("{}", filetrail::daemon::start(&store, poll)?) + } + DaemonCommands::Stop => println!("{}", filetrail::daemon::stop(&store)?), + DaemonCommands::Restart { poll } => { + if filetrail::daemon::request(&store, "status").is_ok() { + filetrail::daemon::stop(&store)?; + } + println!("{}", filetrail::daemon::start(&store, poll)?); + } + DaemonCommands::Status => println!("{}", filetrail::daemon::request(&store, "status")?), + DaemonCommands::Run { poll } => filetrail::daemon::run(&store, poll)?, + }, + Commands::Service(command) => println!( + "{}", + match command { + ServiceCommands::Install => filetrail::service::install(&store)?, + ServiceCommands::Uninstall => filetrail::service::uninstall(&store)?, + ServiceCommands::Show => filetrail::service::render( + &store, + std::env::current_exe()? + .to_str() + .context("invalid executable path")?, + cfg!(target_os = "macos") + ), + } + ), + Commands::Pause => println!("{}", filetrail::daemon::request(&store, "pause")?), + Commands::Resume => println!("{}", filetrail::daemon::request(&store, "resume")?), + Commands::Status => { + println!("{}", filetrail::git::status(&store)?); + println!( + "Daemon: {}", + filetrail::daemon::request(&store, "status").unwrap_or_else(|_| "stopped".into()) + ); + } + Commands::Diff { paths } => print!("{}", filetrail::git::diff(&store, &paths)?), + Commands::Commit { message, paths } => println!( + "{}", + filetrail::git::commit(&store, message.as_deref(), &paths)? + ), + Commands::Conflicts => { + let _lock = store.lock()?; + for (path, reason) in store.state()?.conflicts { + println!("{path}: {reason}"); + } + } + Commands::Resolve { path, .. } => { + let path = filetrail::config::relative(&path)?; + { + let _lock = store.lock()?; + if !store + .state()? + .conflicts + .contains_key(&filetrail::sync::key(&path)?) + { + bail!( + "no recorded conflict for {}; run sync first", + path.display() + ); + } + } + print_report(filetrail::sync::run(&store, false, Some(&path))?)?; + } + Commands::Logs { follow } => { + let path = store.root.join("filetrail.log"); + let mut printed = 0; + loop { + match fs::read(&path) { + Ok(bytes) => { + if bytes.len() < printed { + printed = 0; + } + use std::io::Write; + std::io::stdout().write_all(&bytes[printed..])?; + std::io::stdout().flush()?; + printed = bytes.len(); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => (), + Err(error) => return Err(error.into()), + } + if !follow { + break; + } + thread::sleep(Duration::from_millis(500)); + } + } + Commands::Doctor => { + let _lock = store.lock()?; + let config = store.config()?; + let repo = filetrail::git::open(&config)?; + filetrail::git::ensure_idle(&repo)?; + for entry in &config.entries { + fs::symlink_metadata(&entry.source) + .with_context(|| format!("source unavailable: {}", entry.source.display()))?; + filetrail::sync::safe_destination(&config.repository, &config.destination(entry))?; + } + store.state()?; + if repo.signature().is_err() { + println!("Git identity missing: set user.name and user.email before committing"); + } + println!("Configuration, repository, source paths, and state are valid"); + } + Commands::Completions { .. } => unreachable!(), + } + Ok(()) +} + +fn print_report(report: filetrail::sync::Report) -> Result<()> { + if report.actions.is_empty() && report.errors.is_empty() { + println!("Up to date"); + } else { + println!("{}", report.text()); + } + if !report.errors.is_empty() { + bail!( + "synchronization completed with {} error(s); see above", + report.errors.len() + ); + } + Ok(()) +} + +fn data_root(override_dir: Option) -> Result { + match override_dir { + Some(directory) => Ok(directory), + None => Ok(dirs::home_dir() + .context("cannot determine home directory; specify --data-dir")? + .join(".filetrail")), + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use clap::Parser; + + use super::Cli; + use super::data_root; + + #[test] + fn data_directory_defaults_to_dotfile_in_home() { + let expected = dirs::home_dir().unwrap().join(".filetrail"); + assert_eq!(data_root(None).unwrap(), expected); + let cli = Cli::try_parse_from(["filetrail", "status"]).unwrap(); + assert_eq!(data_root(cli.data_dir).unwrap(), expected); + } + + #[test] + fn data_directory_override_is_global_and_old_flag_is_removed() { + for arguments in [ + ["filetrail", "--data-dir", "/tmp/filetrail-test", "status"], + ["filetrail", "status", "--data-dir", "/tmp/filetrail-test"], + ] { + let cli = Cli::try_parse_from(arguments).unwrap(); + assert_eq!( + data_root(cli.data_dir).unwrap(), + PathBuf::from("/tmp/filetrail-test") + ); + } + assert!( + Cli::try_parse_from(["filetrail", "--config-dir", "/tmp/filetrail-test", "status"]) + .is_err() + ); + } +} diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..fcc8147 --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,83 @@ +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; + +/// Parse one list entry as source [target], using quotes for whitespace in paths. +/// This only tokenizes text: it never invokes a shell or expands variables. +pub fn parse_line(line: &str) -> Result)>> { + let words = shlex::split(line).context("invalid quoting or trailing escape in file list")?; + if words.is_empty() { + return Ok(None); + } + if words.len() > 2 { + bail!("expected source [target]; quote paths containing spaces"); + } + if words + .iter() + .any(|word| word.is_empty() || word.contains('\0')) + { + bail!("source and target must be nonempty paths without NUL characters"); + } + Ok(Some(( + PathBuf::from(&words[0]), + words.get(1).map(PathBuf::from), + ))) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::parse_line; + + #[test] + fn parses_optional_targets_quotes_escapes_and_comments() { + for (line, source, target) in [ + ("~/.zshrc", "~/.zshrc", None), + (" /opt/scripts scripts ", "/opt/scripts", Some("scripts")), + ( + "\"My Notes\" 'backup notes'", + "My Notes", + Some("backup notes"), + ), + ( + "My\\ Notes backup\\ notes", + "My Notes", + Some("backup notes"), + ), + ("\"file\\\"name\" target", "file\"name", Some("target")), + ("'hash#file' target # comment", "hash#file", Some("target")), + ( + "\"$HOME/$(command)\" target", + "$HOME/$(command)", + Some("target"), + ), + ("source\ttarget", "source", Some("target")), + ] { + assert_eq!( + parse_line(line).unwrap(), + Some((PathBuf::from(source), target.map(PathBuf::from))), + "{line}" + ); + } + assert_eq!(parse_line(" # comment").unwrap(), None); + assert_eq!(parse_line(" ").unwrap(), None); + } + + #[test] + fn rejects_ambiguous_and_malformed_entries() { + for line in [ + "a b c", + "\"unterminated", + "'unterminated", + "source \\", + "\"\" target", + "source ''", + "source\0", + ] { + assert!(parse_line(line).is_err(), "{line:?}"); + } + } +} diff --git a/src/service.rs b/src/service.rs new file mode 100644 index 0000000..d684d1c --- /dev/null +++ b/src/service.rs @@ -0,0 +1,195 @@ +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; + +use crate::config::Store; + +fn identity(store: &Store) -> String { + format!( + "filetrail-{}", + &blake3::hash(store.root.as_os_str().as_encoded_bytes()).to_hex()[..12] + ) +} + +pub fn render(store: &Store, executable: &str, macos: bool) -> String { + if macos { + let escape = |value: &str| { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") + }; + format!( + "\n\n\nLabel{}\nProgramArguments{}--data-dir{}daemonrun\nRunAtLoad\nKeepAlive\nStandardErrorPath{}\n\n", + identity(store), + escape(executable), + escape(&store.root.to_string_lossy()), + escape(&store.root.join("service-error.log").to_string_lossy()) + ) + } else { + let quote = |value: &str| { + format!( + "\"{}\"", + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('%', "%%") + .replace('$', "$$") + .replace('\n', "\\n") + ) + }; + format!( + "[Unit]\nDescription=Filetrail file synchronization\n\n[Service]\nType=simple\nExecStart={} --data-dir {} daemon run\nRestart=on-failure\nRestartSec=3\n\n[Install]\nWantedBy=default.target\n", + quote(executable), + quote(&store.root.to_string_lossy()) + ) + } +} + +fn location(store: &Store) -> Result { + let home = dirs::home_dir().context("cannot determine home directory")?; + Ok(if cfg!(target_os = "macos") { + home.join("Library/LaunchAgents") + .join(format!("{}.plist", identity(store))) + } else { + dirs::config_dir() + .context("cannot determine systemd user service directory")? + .join("systemd/user") + .join(format!("{}.service", identity(store))) + }) +} + +fn execute(program: &str, args: &[&str]) -> Result<()> { + let status = Command::new(program).args(args).status()?; + if !status.success() { + bail!("{program} {} failed: {status}", args.join(" ")); + } + Ok(()) +} + +fn domain() -> Result { + let output = Command::new("id").arg("-u").output()?; + if !output.status.success() { + bail!("cannot determine user ID"); + } + Ok(format!("gui/{}", String::from_utf8(output.stdout)?.trim())) +} + +pub fn install(store: &Store) -> Result { + store.config()?; + let location = location(store)?; + if location.exists() { + bail!( + "service already installed at {}; uninstall it first", + location.display() + ); + } + if crate::daemon::request(store, "status").is_ok() { + crate::daemon::stop(store)?; + } + let executable = std::env::current_exe()?; + crate::config::atomic_write( + &location, + render( + store, + executable + .to_str() + .context("executable path is not UTF-8")?, + cfg!(target_os = "macos"), + ) + .as_bytes(), + )?; + if cfg!(target_os = "macos") { + execute( + "launchctl", + &[ + "bootstrap", + &domain()?, + location.to_str().context("invalid service path")?, + ], + )?; + } else { + execute("systemctl", &["--user", "daemon-reload"])?; + execute( + "systemctl", + &[ + "--user", + "enable", + "--now", + &format!("{}.service", identity(store)), + ], + )?; + } + Ok(format!("installed {}", location.display())) +} + +pub fn uninstall(store: &Store) -> Result { + let location = location(store)?; + if !location.exists() { + bail!("service is not installed"); + } + if cfg!(target_os = "macos") { + execute( + "launchctl", + &[ + "bootout", + &domain()?, + location.to_str().context("invalid service path")?, + ], + )?; + } else { + execute( + "systemctl", + &[ + "--user", + "disable", + "--now", + &format!("{}.service", identity(store)), + ], + )?; + } + fs::remove_file(&location)?; + if !cfg!(target_os = "macos") { + execute("systemctl", &["--user", "daemon-reload"])?; + } + Ok(format!("removed {}", location.display())) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::render; + use crate::config::Store; + + #[test] + fn templates_quote_paths() { + let store = Store { + root: PathBuf::from("/tmp/a & b/%x"), + }; + assert!(render(&store, "/a & b/filetrail", true).contains("/a & b/filetrail")); + assert!(render(&store, "/a b/filetrail", false).contains("ExecStart=\"/a b/filetrail\"")); + assert!(render(&store, "/filetrail", false).contains("%%x")); + } + + #[test] + fn services_pass_the_data_directory_to_the_daemon() { + let store = Store { + root: PathBuf::from("/tmp/filetrail-data"), + }; + assert!( + render(&store, "/filetrail", true) + .contains("--data-dir/tmp/filetrail-data") + ); + assert!( + render(&store, "/filetrail", false) + .contains("--data-dir \"/tmp/filetrail-data\" daemon run") + ); + } +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..53e8a57 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,208 @@ +use std::fs; +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use rusqlite::Connection; +use rusqlite::OpenFlags; +use rusqlite::TransactionBehavior; +use rusqlite::params; + +use crate::config::Baseline; +use crate::config::State; + +const APPLICATION_ID: i64 = 0x4654_524c; +const SCHEMA_VERSION: i64 = 1; +const DATABASE: &str = "state.db"; + +pub fn load(root: &Path) -> Result { + let path = root.join(DATABASE); + if !exists(&path)? { + // Read-only operations and dry runs must not create the database. + return Ok(State::default()); + } + let mut connection = open(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + let transaction = connection.transaction()?; + let state = read(&transaction) + .context("cannot read state.db; refusing to discard synchronization history")?; + transaction.commit()?; + Ok(state) +} + +pub fn save(root: &Path, state: &State) -> Result<()> { + let path = root.join(DATABASE); + if exists(&path)? { + let mut connection = open(&path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; + return write(&mut connection, state); + } + // Publish a new database only after its schema and initial state are complete. + let temporary = tempfile::NamedTempFile::new_in(root)?; + let mut connection = open(temporary.path(), OpenFlags::SQLITE_OPEN_READ_WRITE)?; + initialize(&mut connection)?; + write(&mut connection, state)?; + connection.close().map_err(|(_, error)| error)?; + temporary.as_file().sync_all()?; + temporary + .persist_noclobber(&path) + .map_err(|error| error.error)?; + fs::File::open(root)?.sync_all()?; + Ok(()) +} + +fn exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +fn open(path: &Path, flags: OpenFlags) -> Result { + let connection = Connection::open_with_flags(path, flags) + .with_context(|| format!("cannot open {}", path.display()))?; + connection.busy_timeout(Duration::from_secs(5))?; + Ok(connection) +} + +fn initialize(connection: &mut Connection) -> Result<()> { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute_batch( + "CREATE TABLE ownership ( + path TEXT PRIMARY KEY NOT NULL, + entry_id INTEGER NOT NULL CHECK (entry_id >= 0) + ) STRICT; + CREATE TABLE baselines ( + path TEXT PRIMARY KEY NOT NULL, + entry_id INTEGER NOT NULL CHECK (entry_id >= 0), + fingerprint TEXT NOT NULL + ) STRICT; + CREATE TABLE conflicts ( + path TEXT PRIMARY KEY NOT NULL, + reason TEXT NOT NULL + ) STRICT; + CREATE TABLE sync_metadata ( + id INTEGER PRIMARY KEY CHECK (id = 1), + last_sync INTEGER CHECK (last_sync >= 0) + ) STRICT; + INSERT INTO sync_metadata (id, last_sync) VALUES (1, NULL);", + )?; + transaction.pragma_update(None, "application_id", APPLICATION_ID)?; + transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?; + transaction.commit()?; + Ok(()) +} + +fn read(connection: &Connection) -> Result { + let application: i64 = + connection.pragma_query_value(None, "application_id", |row| row.get(0))?; + let version: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; + if application != APPLICATION_ID || version != SCHEMA_VERSION { + bail!("unrecognized state database or unsupported schema version {version}"); + } + let mut state = State::default(); + let mut statement = connection.prepare("SELECT path, entry_id FROM ownership")?; + for row in statement.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })? { + let (path, id) = row?; + state.owned.insert(path, u64::try_from(id)?); + } + let mut statement = connection.prepare("SELECT path, entry_id, fingerprint FROM baselines")?; + for row in statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + )) + })? { + let (path, entry, fingerprint) = row?; + state.files.insert( + path, + Baseline { + entry: u64::try_from(entry)?, + fingerprint, + }, + ); + } + let mut statement = connection.prepare("SELECT path, reason FROM conflicts")?; + for row in statement.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? { + let (path, reason) = row?; + state.conflicts.insert(path, reason); + } + let last_sync: Option = connection.query_row( + "SELECT last_sync FROM sync_metadata WHERE id = 1", + [], + |row| row.get(0), + )?; + state.last_sync = last_sync.map(u64::try_from).transpose()?; + Ok(state) +} + +fn write(connection: &mut Connection, state: &State) -> Result<()> { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let previous = read(&transaction)?; + { + let mut remove = transaction.prepare("DELETE FROM ownership WHERE path = ?1")?; + let mut upsert = transaction.prepare("INSERT INTO ownership (path, entry_id) VALUES (?1, ?2) ON CONFLICT(path) DO UPDATE SET entry_id = excluded.entry_id")?; + for path in previous + .owned + .keys() + .filter(|path| !state.owned.contains_key(*path)) + { + remove.execute([path])?; + } + for (path, id) in &state.owned { + if previous.owned.get(path) != Some(id) { + upsert.execute(params![path, i64::try_from(*id)?])?; + } + } + } + { + let mut remove = transaction.prepare("DELETE FROM baselines WHERE path = ?1")?; + let mut upsert = transaction.prepare("INSERT INTO baselines (path, entry_id, fingerprint) VALUES (?1, ?2, ?3) ON CONFLICT(path) DO UPDATE SET entry_id = excluded.entry_id, fingerprint = excluded.fingerprint")?; + for path in previous + .files + .keys() + .filter(|path| !state.files.contains_key(*path)) + { + remove.execute([path])?; + } + for (path, baseline) in &state.files { + if previous.files.get(path) != Some(baseline) { + upsert.execute(params![ + path, + i64::try_from(baseline.entry)?, + baseline.fingerprint + ])?; + } + } + } + { + let mut remove = transaction.prepare("DELETE FROM conflicts WHERE path = ?1")?; + let mut upsert = transaction.prepare("INSERT INTO conflicts (path, reason) VALUES (?1, ?2) ON CONFLICT(path) DO UPDATE SET reason = excluded.reason")?; + for path in previous + .conflicts + .keys() + .filter(|path| !state.conflicts.contains_key(*path)) + { + remove.execute([path])?; + } + for (path, reason) in &state.conflicts { + if previous.conflicts.get(path) != Some(reason) { + upsert.execute(params![path, reason])?; + } + } + } + if previous.last_sync != state.last_sync { + transaction.execute( + "UPDATE sync_metadata SET last_sync = ?1 WHERE id = 1", + [state.last_sync.map(i64::try_from).transpose()?], + )?; + } + transaction.commit()?; + Ok(()) +} diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..5d10196 --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,417 @@ +use std::collections::BTreeSet; +use std::fs; +use std::io::Read; +use std::os::unix::fs::MetadataExt; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::fs::symlink; +use std::path::Path; +use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use globset::Glob; +use globset::GlobSet; +use globset::GlobSetBuilder; +use walkdir::WalkDir; + +use crate::config::Baseline; +use crate::config::Config; +use crate::config::Entry; +use crate::config::State; +use crate::config::Store; + +#[derive(Default, Debug)] +pub struct Report { + pub actions: Vec, + pub errors: Vec, +} + +impl Report { + pub fn text(&self) -> String { + self.actions + .iter() + .chain(&self.errors) + .cloned() + .collect::>() + .join("\n") + } +} + +pub fn exclusions(patterns: &[String]) -> Result { + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { + builder.add(Glob::new(pattern)?); + } + Ok(builder.build()?) +} + +pub fn run(store: &Store, dry_run: bool, overwrite: Option<&Path>) -> Result { + let _lock = store.lock()?; + let config = store.config()?; + let repo = crate::git::open(&config)?; + crate::git::ensure_idle(&repo)?; + let mut state = store.state()?; + let mut report = Report::default(); + for entry in config.entries.iter().filter(|entry| entry.enabled) { + if let Err(error) = sync_entry(&config, entry, &mut state, &mut report, dry_run, overwrite) + { + report + .errors + .push(format!("error [{}]: {error:#}", entry.id)); + } + } + if !dry_run { + state.last_sync = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()); + store.save_state(&state)?; + } + Ok(report) +} + +fn sync_entry( + config: &Config, + entry: &Entry, + state: &mut State, + report: &mut Report, + dry: bool, + overwrite: Option<&Path>, +) -> Result<()> { + let metadata = match fs::symlink_metadata(&entry.source) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // A missing root directory may be an unmounted volume: never mass-delete it. + if entry.directory || !entry.source.parent().is_some_and(Path::is_dir) { + bail!( + "source unavailable; retaining destination: {}", + entry.source.display() + ); + } + return handle_missing( + config, + entry, + state, + report, + &BTreeSet::new(), + dry, + overwrite, + ); + } + Err(error) => return Err(error.into()), + }; + if metadata.is_dir() != entry.directory { + bail!("source type changed; remove and re-add this entry"); + } + let patterns = config + .exclude + .iter() + .chain(&entry.exclude) + .cloned() + .collect::>(); + let ignored = exclusions(&patterns)?; + let mut seen = BTreeSet::new(); + let mut scan_failed = false; + let walker = WalkDir::new(&entry.source) + .follow_links(false) + .into_iter() + .filter_entry(|item| { + if item + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case(".git") + { + return false; + } + let relative = item + .path() + .strip_prefix(&entry.source) + .unwrap_or(item.path()); + let relative = if relative.as_os_str().is_empty() && !entry.directory { + Path::new(item.file_name()) + } else { + relative + }; + !ignored.is_match(relative) + }); + for item in walker { + let item = match item { + Ok(item) => item, + Err(error) => { + scan_failed = true; + report.errors.push(format!("scan error: {error}")); + continue; + } + }; + let relative = item.path().strip_prefix(&entry.source)?; + let mut target = config.destination(entry); + if !relative.as_os_str().is_empty() { + target.push(relative); + } + let key = key(&target)?; + seen.insert(key.clone()); + if item.file_type().is_dir() { + // Checking a hypothetical child also validates the directory itself. + if let Err(error) = (|| -> Result<()> { + safe_destination(&config.repository, &target.join(".filetrail-check"))?; + let destination = config.repository.join(&target); + if !destination.exists() { + report.actions.push(format!("mkdir {key}")); + if !dry { + fs::create_dir_all(destination)?; + } + } + Ok(()) + })() { + scan_failed = true; + report + .errors + .push(format!("directory conflict {key}: {error:#}")); + } + continue; + } + if !item.file_type().is_file() && !item.file_type().is_symlink() { + report + .errors + .push(format!("skipped special file: {}", item.path().display())); + continue; + } + let result = sync_file( + config, + entry, + state, + &mut report.actions, + item.path(), + &target, + dry, + overwrite == Some(target.as_path()), + ); + if let Err(error) = result { + scan_failed = true; + state.conflicts.insert(key.clone(), format!("{error:#}")); + report.errors.push(format!("conflict {key}: {error:#}")); + } + } + if !scan_failed { + handle_missing(config, entry, state, report, &seen, dry, overwrite)?; + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn sync_file( + config: &Config, + entry: &Entry, + state: &mut State, + actions: &mut Vec, + source: &Path, + target: &Path, + dry: bool, + overwrite: bool, +) -> Result<()> { + let destination = safe_destination(&config.repository, target)?; + let name = key(target)?; + let source_hash = fingerprint(source)?.context("source disappeared")?; + let target_hash = fingerprint(&destination)?; + if target_hash.as_ref() == Some(&source_hash) { + if !dry { + state.owned.insert(name.clone(), entry.id); + state.files.insert( + name.clone(), + Baseline { + entry: entry.id, + fingerprint: source_hash, + }, + ); + state.conflicts.remove(&name); + } + return Ok(()); + } + if !overwrite { + match state.files.get(&name) { + Some(baseline) if target_hash.as_ref() != Some(&baseline.fingerprint) => bail!( + "destination changed outside Filetrail; use resolve --use-source to overwrite" + ), + None if target_hash.is_some() => { + bail!("destination already exists with different content") + } + _ => (), + } + } + actions.push(format!( + "{} {name}", + if target_hash.is_some() { + "update" + } else { + "add" + } + )); + if !dry { + copy_atomic(source, &destination, &source_hash)?; + state.owned.insert(name.clone(), entry.id); + state.files.insert( + name.clone(), + Baseline { + entry: entry.id, + fingerprint: source_hash, + }, + ); + state.conflicts.remove(&name); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn handle_missing( + config: &Config, + entry: &Entry, + state: &mut State, + report: &mut Report, + seen: &BTreeSet, + dry: bool, + overwrite: Option<&Path>, +) -> Result<()> { + let patterns = config + .exclude + .iter() + .chain(&entry.exclude) + .cloned() + .collect::>(); + let ignored = exclusions(&patterns)?; + let missing = state + .files + .iter() + .filter(|(name, baseline)| baseline.entry == entry.id && !seen.contains(*name)) + .map(|(name, baseline)| (name.clone(), baseline.clone())) + .collect::>(); + for (name, baseline) in missing { + let target = Path::new(&name); + // Removing an exclusion or changing a mapping must never turn excluded paths into deletions. + let Ok(relative) = target.strip_prefix(config.destination(entry)) else { + continue; + }; + let excluded = if relative.as_os_str().is_empty() && !entry.directory { + ignored.is_match(Path::new( + entry.source.file_name().context("source has no filename")?, + )) + } else { + relative.ancestors().any(|path| ignored.is_match(path)) + }; + if excluded { + continue; + } + if !entry.delete { + report + .actions + .push(format!("retain {name} (source removed; deletion disabled)")); + continue; + } + let result = (|| -> Result<()> { + let destination = safe_destination(&config.repository, target)?; + let current = fingerprint(&destination)?; + if current.is_some() + && current.as_ref() != Some(&baseline.fingerprint) + && overwrite != Some(target) + { + bail!("destination changed outside Filetrail; refusing deletion"); + } + report.actions.push(format!("delete {name}")); + if !dry { + if current.is_some() { + fs::remove_file(destination)?; + } + state.files.remove(&name); + state.conflicts.remove(&name); + } + Ok(()) + })(); + if let Err(error) = result { + state.conflicts.insert(name.clone(), error.to_string()); + report.errors.push(format!("conflict {name}: {error:#}")); + } + } + Ok(()) +} + +pub fn key(path: &Path) -> Result { + Ok(path + .to_str() + .context("non-UTF-8 paths are not supported")? + .to_owned()) +} + +pub fn safe_destination(repository: &Path, relative: &Path) -> Result { + let relative = crate::config::relative(relative)?; + let mut current = repository.to_path_buf(); + if let Some(parent) = relative.parent() { + for part in parent.components() { + current.push(part); + match fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() || !meta.is_dir() => bail!( + "destination ancestor is not a real directory: {}", + current.display() + ), + Ok(_) => (), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => (), + Err(error) => return Err(error.into()), + } + } + } + Ok(repository.join(relative)) +} + +pub fn fingerprint(path: &Path) -> Result> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let mut hash = blake3::Hasher::new(); + if metadata.file_type().is_symlink() { + hash.update(b"symlink\0"); + hash.update(fs::read_link(path)?.as_os_str().as_encoded_bytes()); + } else if metadata.is_file() { + hash.update(b"file\0"); + hash.update(&(metadata.mode() & 0o777).to_le_bytes()); + let mut file = fs::File::open(path)?; + let mut buffer = [0; 65536]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + } else { + bail!("not a regular file or symlink: {}", path.display()); + } + Ok(Some(hash.finalize().to_hex().to_string())) +} + +fn copy_atomic(source: &Path, destination: &Path, expected: &str) -> Result<()> { + let parent = destination.parent().context("missing destination parent")?; + fs::create_dir_all(parent)?; + let temporary = tempfile::NamedTempFile::new_in(parent)?; + let temporary = temporary.into_temp_path(); + let metadata = fs::symlink_metadata(source)?; + if metadata.file_type().is_symlink() { + fs::remove_file(&temporary)?; + symlink(fs::read_link(source)?, &temporary)?; + } else { + fs::copy(source, &temporary)?; + fs::set_permissions( + &temporary, + fs::Permissions::from_mode(metadata.mode() & 0o777), + )?; + fs::File::open(&temporary)?.sync_all()?; + } + if fingerprint(&temporary)?.as_deref() != Some(expected) + || fingerprint(source)?.as_deref() != Some(expected) + { + bail!("source changed during copy; will retry on the next scan"); + } + temporary + .persist(destination) + .map_err(|error| error.error)?; + Ok(()) +} diff --git a/tests/state.rs b/tests/state.rs new file mode 100644 index 0000000..a19be83 --- /dev/null +++ b/tests/state.rs @@ -0,0 +1,125 @@ +use std::fs; + +use filetrail::config::Baseline; +use filetrail::config::State; +use filetrail::config::Store; +use rusqlite::Connection; + +fn sample() -> State { + let mut state = State::default(); + state.owned.insert("config/a'\"\nfile".into(), 7); + state.owned.insert("config/deleted".into(), 8); + state.files.insert( + "config/a'\"\nfile".into(), + Baseline { + entry: 7, + fingerprint: "content fingerprint".into(), + }, + ); + state + .conflicts + .insert("unowned".into(), "target already exists".into()); + state.last_sync = Some(123456789); + state +} + +#[test] +fn database_roundtrip_reopen_and_removal_preserve_ownership() { + let temporary = tempfile::tempdir().unwrap(); + let store = Store::new(temporary.path().join("data")).unwrap(); + assert_eq!(store.state().unwrap(), State::default()); + assert!(!store.root.join("state.db").exists()); + let state = sample(); + store.save_state(&state).unwrap(); + assert!( + fs::read(store.root.join("state.db")) + .unwrap() + .starts_with(b"SQLite format 3\0") + ); + let reopened = Store::new(store.root.clone()).unwrap(); + assert_eq!(reopened.state().unwrap(), state); + let mut updated = state.clone(); + updated.files.clear(); + updated.conflicts.clear(); + updated.owned.remove("config/deleted"); + updated.last_sync = None; + reopened.save_state(&updated).unwrap(); + assert_eq!(store.state().unwrap(), updated); + assert!( + store + .state() + .unwrap() + .owned + .contains_key("config/a'\"\nfile") + ); +} + +#[test] +fn failed_update_rolls_back_all_tables() { + let temporary = tempfile::tempdir().unwrap(); + let store = Store::new(temporary.path().join("data")).unwrap(); + let original = sample(); + store.save_state(&original).unwrap(); + let mut invalid = State::default(); + invalid.owned.insert("new".into(), 10); + invalid + .conflicts + .insert("different".into(), "changed".into()); + // SQLite cannot represent this value. Earlier table updates must also roll back. + invalid.last_sync = Some(u64::MAX); + assert!(store.save_state(&invalid).is_err()); + assert_eq!(store.state().unwrap(), original); +} + +#[test] +fn failed_initial_write_does_not_publish_partial_database() { + let temporary = tempfile::tempdir().unwrap(); + let store = Store::new(temporary.path().join("data")).unwrap(); + let mut state = sample(); + state.last_sync = Some(u64::MAX); + assert!(store.save_state(&state).is_err()); + assert!(!store.root.join("state.db").exists()); + state.last_sync = Some(10); + store.save_state(&state).unwrap(); + assert_eq!(store.state().unwrap(), state); +} + +#[test] +fn unsupported_schema_is_not_overwritten() { + let temporary = tempfile::tempdir().unwrap(); + let store = Store::new(temporary.path().join("data")).unwrap(); + store.save_state(&sample()).unwrap(); + let path = store.root.join("state.db"); + let connection = Connection::open(&path).unwrap(); + connection.pragma_update(None, "user_version", 999).unwrap(); + drop(connection); + let before = fs::read(&path).unwrap(); + assert!(store.state().is_err()); + assert!(store.save_state(&State::default()).is_err()); + assert_eq!(fs::read(path).unwrap(), before); +} + +#[test] +fn unchanged_baselines_are_not_rewritten() { + let temporary = tempfile::tempdir().unwrap(); + let store = Store::new(temporary.path().join("data")).unwrap(); + let mut state = sample(); + store.save_state(&state).unwrap(); + let connection = Connection::open(store.root.join("state.db")).unwrap(); + connection.execute_batch("CREATE TRIGGER reject_update BEFORE UPDATE ON baselines BEGIN SELECT RAISE(ABORT, 'unchanged baseline was rewritten'); END;").unwrap(); + drop(connection); + state.last_sync = Some(123456790); + store.save_state(&state).unwrap(); + assert_eq!(store.state().unwrap(), state); +} + +#[test] +fn database_corruption_blocks_reads_and_writes() { + let temporary = tempfile::tempdir().unwrap(); + let store = Store::new(temporary.path().join("data")).unwrap(); + let path = store.root.join("state.db"); + fs::write(&path, "corrupted database").unwrap(); + assert!(store.state().is_err()); + assert!(store.save_state(&sample()).is_err()); + assert_eq!(fs::read_to_string(path).unwrap(), "corrupted database"); +} diff --git a/tests/workflow.rs b/tests/workflow.rs new file mode 100644 index 0000000..63f5d9d --- /dev/null +++ b/tests/workflow.rs @@ -0,0 +1,730 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::fs::symlink; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::time::Duration; +use std::time::Instant; + +use filetrail::config::Config; +use filetrail::config::Entry; +use filetrail::config::Store; +use git2::Repository; +use tempfile::TempDir; + +struct Fixture { + _temp: TempDir, + store: Store, + source: PathBuf, + repository: PathBuf, +} + +impl Fixture { + fn new(subdir: &str, delete: bool) -> Self { + let temp = tempfile::Builder::new() + .prefix("ft-") + .tempdir_in("/tmp") + .unwrap(); + let source = temp.path().join("source"); + fs::create_dir(&source).unwrap(); + let repository = temp.path().join("repo"); + let repo = Repository::init(&repository).unwrap(); + let mut gitconfig = repo.config().unwrap(); + gitconfig.set_str("user.name", "Filetrail Test").unwrap(); + gitconfig + .set_str("user.email", "filetrail@example.invalid") + .unwrap(); + let store = Store::new(temp.path().join("state")).unwrap(); + let mut config = + Config::new(fs::canonicalize(&repository).unwrap(), subdir.into()).unwrap(); + config.scan_interval_secs = 1; + config.entries.push(Entry { + id: 1, + source: fs::canonicalize(&source).unwrap(), + target: "config".into(), + directory: true, + enabled: true, + delete, + exclude: vec![], + }); + store.save_config(&config).unwrap(); + Self { + _temp: temp, + store, + source, + repository, + } + } + + fn write(&self, name: &str, content: &str) { + let path = self.source.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + fn target(&self, name: &str) -> PathBuf { + let config = self.store.config().unwrap(); + self.repository + .join(&config.subdir) + .join("config") + .join(name) + } + + fn sync(&self) { + let report = filetrail::sync::run(&self.store, false, None).unwrap(); + assert!(report.errors.is_empty(), "{}", report.text()); + } + + fn cli(&self, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_filetrail")) + .arg("--data-dir") + .arg(&self.store.root) + .args(args) + .output() + .unwrap() + } +} + +#[test] +fn platform_subdir_recursive_sync_and_idempotence() { + let f = Fixture::new("macos", false); + f.write("nvim/init.lua", "return {}\n"); + f.sync(); + assert_eq!( + fs::read_to_string(f.target("nvim/init.lua")).unwrap(), + "return {}\n" + ); + assert!(!f.repository.join("config").exists()); + assert!( + filetrail::sync::run(&f.store, false, None) + .unwrap() + .actions + .is_empty() + ); + f.write("nvim/init.lua", "return { updated = true }\n"); + f.sync(); + assert!( + fs::read_to_string(f.target("nvim/init.lua")) + .unwrap() + .contains("updated") + ); +} + +#[test] +fn dry_run_does_not_write_files_or_state() { + let f = Fixture::new("", false); + f.write("a", "one"); + let report = filetrail::sync::run(&f.store, true, None).unwrap(); + assert!(report.actions.iter().any(|line| line == "add config/a")); + assert!(!f.target("a").exists()); + assert!(!f.store.root.join("state.db").exists()); +} + +#[test] +fn first_sync_and_external_edits_are_protected_and_resolvable() { + let f = Fixture::new("linux", false); + f.write("a", "source"); + fs::create_dir_all(f.target("")).unwrap(); + fs::write(f.target("a"), "existing").unwrap(); + let report = filetrail::sync::run(&f.store, false, None).unwrap(); + assert_eq!(report.errors.len(), 1); + assert_eq!(fs::read_to_string(f.target("a")).unwrap(), "existing"); + assert!( + filetrail::sync::run(&f.store, false, Some(Path::new("linux/config/a"))) + .unwrap() + .errors + .is_empty() + ); + fs::write(f.target("a"), "manual edit").unwrap(); + f.write("a", "new source"); + assert_eq!( + filetrail::sync::run(&f.store, false, None) + .unwrap() + .errors + .len(), + 1 + ); + assert_eq!(fs::read_to_string(f.target("a")).unwrap(), "manual edit"); +} + +#[test] +fn deletion_is_opt_in_and_never_deletes_unowned_files() { + for delete in [false, true] { + let f = Fixture::new("", delete); + f.write("a", "one"); + f.sync(); + fs::write(f.target("manual"), "keep").unwrap(); + fs::remove_file(f.source.join("a")).unwrap(); + f.sync(); + assert_eq!(f.target("a").exists(), !delete); + assert!(f.target("manual").exists()); + } +} + +#[test] +fn missing_source_directory_never_causes_mass_deletion() { + let f = Fixture::new("", true); + f.write("a", "one"); + f.sync(); + fs::rename(&f.source, f.source.with_extension("offline")).unwrap(); + assert!( + !filetrail::sync::run(&f.store, false, None) + .unwrap() + .errors + .is_empty() + ); + assert!(f.target("a").exists()); +} + +#[test] +fn ignored_files_and_nested_git_are_skipped() { + let f = Fixture::new("", true); + let mut config = f.store.config().unwrap(); + config.exclude = vec!["**/*.tmp".into(), "cache".into()]; + f.store.save_config(&config).unwrap(); + f.write("keep", "yes"); + f.write("a.tmp", "no"); + f.write("cache/data", "no"); + f.write("nested/.git/config", "no"); + f.sync(); + assert!(f.target("keep").exists()); + assert!(!f.target("a.tmp").exists()); + assert!(!f.target("cache/data").exists()); + assert!(!f.target("nested/.git/config").exists()); +} + +#[test] +fn newly_excluded_files_are_not_deleted() { + let f = Fixture::new("", true); + f.write("cache/a", "keep"); + f.sync(); + let mut config = f.store.config().unwrap(); + config.exclude = vec!["cache".into()]; + f.store.save_config(&config).unwrap(); + f.sync(); + assert!(f.target("cache/a").exists()); +} + +#[test] +fn symlinks_and_executable_permissions_are_preserved() { + let f = Fixture::new("", false); + f.write("script", "#!/bin/sh\n"); + fs::set_permissions(f.source.join("script"), fs::Permissions::from_mode(0o755)).unwrap(); + symlink("missing", f.source.join("link")).unwrap(); + f.sync(); + assert_eq!( + fs::read_link(f.target("link")).unwrap(), + PathBuf::from("missing") + ); + assert_eq!( + fs::metadata(f.target("script")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o755 + ); +} + +#[test] +fn destination_symlink_ancestors_cannot_escape_repository() { + let f = Fixture::new("", false); + f.write("a", "one"); + let outside = f._temp.path().join("outside"); + fs::create_dir(&outside).unwrap(); + symlink(&outside, f.repository.join("config")).unwrap(); + assert!( + !filetrail::sync::run(&f.store, false, None) + .unwrap() + .errors + .is_empty() + ); + assert!(!outside.join("a").exists()); +} + +#[test] +fn default_commit_message_and_untracked_diff_include_files() { + let f = Fixture::new("macos", true); + f.write("a", "hello\n"); + f.sync(); + fs::write(f.repository.join("unrelated"), "do not commit").unwrap(); + let diff = filetrail::git::diff(&f.store, &[]).unwrap(); + assert!(diff.contains("+hello"), "{diff}"); + filetrail::git::commit(&f.store, None, &[]).unwrap(); + let repo = Repository::open(&f.repository).unwrap(); + let commit = repo.head().unwrap().peel_to_commit().unwrap(); + assert!(commit.message().unwrap().starts_with("filetrail: ")); + assert!(commit.message().unwrap().contains("macos/config/a")); + assert!( + commit + .tree() + .unwrap() + .get_path(Path::new("unrelated")) + .is_err() + ); + f.write("a", "updated\n"); + f.write("b", "new\n"); + f.sync(); + filetrail::git::commit(&f.store, Some("custom message"), &["macos/config/a".into()]).unwrap(); + let commit = repo.head().unwrap().peel_to_commit().unwrap(); + assert_eq!(commit.message().unwrap(), "custom message"); + assert!( + commit + .tree() + .unwrap() + .get_path(Path::new("macos/config/b")) + .is_err() + ); + fs::remove_file(f.source.join("a")).unwrap(); + f.sync(); + filetrail::git::commit(&f.store, None, &[]).unwrap(); + assert!( + repo.head() + .unwrap() + .peel_to_commit() + .unwrap() + .message() + .unwrap() + .contains("delete \"macos/config/a\"") + ); +} + +#[test] +fn preexisting_staging_is_not_modified() { + let f = Fixture::new("", false); + f.write("a", "hello"); + f.sync(); + fs::write(f.repository.join("unrelated"), "manual").unwrap(); + let repo = Repository::open(&f.repository).unwrap(); + let mut index = repo.index().unwrap(); + index.add_path(Path::new("unrelated")).unwrap(); + index.write().unwrap(); + let before = fs::read(repo.path().join("index")).unwrap(); + assert!(filetrail::git::commit(&f.store, None, &[]).is_err()); + assert_eq!(fs::read(repo.path().join("index")).unwrap(), before); + assert!(repo.head().is_err()); +} + +#[test] +fn unresolved_git_operation_blocks_sync_and_commit() { + let f = Fixture::new("", false); + f.write("a", "hello"); + fs::write( + f.repository.join(".git/MERGE_HEAD"), + "0000000000000000000000000000000000000000\n", + ) + .unwrap(); + assert!(filetrail::sync::run(&f.store, false, None).is_err()); + assert!(!f.target("a").exists()); + assert!(filetrail::git::commit(&f.store, None, &[]).is_err()); +} + +#[test] +fn invalid_configuration_does_not_replace_existing_config() { + let f = Fixture::new("", false); + let mut config = f.store.config().unwrap(); + let before = fs::read(f.store.root.join("config.toml")).unwrap(); + let mut duplicate = config.entries[0].clone(); + duplicate.id = 2; + config.entries.push(duplicate); + assert!(f.store.save_config(&config).is_err()); + assert_eq!(fs::read(f.store.root.join("config.toml")).unwrap(), before); +} + +#[test] +fn corrupt_state_is_not_silently_reset() { + let f = Fixture::new("", false); + f.write("a", "hello"); + fs::write(f.store.root.join("state.db"), "broken").unwrap(); + assert!(filetrail::sync::run(&f.store, false, None).is_err()); + assert!(!f.target("a").exists()); +} + +fn wait_until(mut condition: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(10); + while !condition() { + assert!(Instant::now() < deadline, "condition timed out"); + std::thread::sleep(Duration::from_millis(50)); + } +} + +struct ChildGuard(std::process::Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[test] +fn daemon_watches_atomic_saves_pause_resume_and_config_reload() { + let f = Fixture::new("", false); + let mut config = f.store.config().unwrap(); + config.scan_interval_secs = 3600; + f.store.save_config(&config).unwrap(); + f.write("a", "initial"); + let child = Command::new(env!("CARGO_BIN_EXE_filetrail")) + .arg("--data-dir") + .arg(&f.store.root) + .args(["daemon", "run"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .unwrap(); + let mut child = ChildGuard(child); + wait_until(|| f.target("a").exists()); + f.write("a", "native event"); + wait_until(|| fs::read_to_string(f.target("a")).is_ok_and(|content| content == "native event")); + let paused = f.cli(&["pause"]); + assert!( + paused.status.success(), + "{}", + String::from_utf8_lossy(&paused.stderr) + ); + f.write("replacement", "replacement"); + fs::rename(f.source.join("replacement"), f.source.join("a")).unwrap(); + std::thread::sleep(Duration::from_millis(1200)); + assert_eq!(fs::read_to_string(f.target("a")).unwrap(), "native event"); + assert!(f.cli(&["resume"]).status.success()); + wait_until(|| fs::read_to_string(f.target("a")).is_ok_and(|content| content == "replacement")); + assert!(f.cli(&["disable", "1"]).status.success()); + f.write("b", "later"); + std::thread::sleep(Duration::from_millis(1200)); + assert!(!f.target("b").exists()); + assert!(f.cli(&["enable", "1"]).status.success()); + wait_until(|| f.target("b").exists()); + assert!(f.cli(&["daemon", "stop"]).status.success()); + assert!(child.0.wait().unwrap().success()); + assert!(!f.store.root.join("daemon.sock").exists()); +} + +#[test] +fn cli_init_subdir_and_list_import() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("state"); + let repo = temp.path().join("repo"); + let run = |args: &[&str]| { + Command::new(env!("CARGO_BIN_EXE_filetrail")) + .arg("--data-dir") + .arg(&config) + .args(args) + .output() + .unwrap() + }; + let init = run(&["init", repo.to_str().unwrap(), "--subdir", "linux"]); + assert!( + init.status.success(), + "{}", + String::from_utf8_lossy(&init.stderr) + ); + fs::write(temp.path().join("one"), "1").unwrap(); + fs::write(temp.path().join("two"), "2").unwrap(); + let list = temp.path().join("files.txt"); + fs::write(&list, "# relative to this file\n\none first\ntwo second\n").unwrap(); + let add = run(&["add", "--from", list.to_str().unwrap()]); + assert!( + add.status.success(), + "{}", + String::from_utf8_lossy(&add.stderr) + ); + assert_eq!(fs::read_to_string(repo.join("linux/first")).unwrap(), "1"); + assert_eq!(fs::read_to_string(repo.join("linux/second")).unwrap(), "2"); + let repository = Repository::open(&repo).unwrap(); + repository + .config() + .unwrap() + .set_str("user.name", "Test") + .unwrap(); + repository + .config() + .unwrap() + .set_str("user.email", "test@example.invalid") + .unwrap(); + let commit = run(&["commit"]); + assert!( + commit.status.success(), + "{}", + String::from_utf8_lossy(&commit.stderr) + ); + let message = repository + .head() + .unwrap() + .peel_to_commit() + .unwrap() + .message() + .unwrap() + .to_owned(); + assert!(message.contains("add \"linux/first\""), "{message}"); + assert!(message.contains("add \"linux/second\""), "{message}"); + assert!(!run(&["init", repo.to_str().unwrap()]).status.success()); + assert!(run(&["remove", "1"]).status.success()); + assert!(repo.join("linux/first").exists()); +} + +#[test] +fn list_import_supports_spaces_quoted_paths_and_literal_variables() { + let f = Fixture::new("macos", false); + let mut config = f.store.config().unwrap(); + config.entries.clear(); + f.store.save_config(&config).unwrap(); + f.write("notes one", "notes\n"); + f.write("scripts two/build.sh", "build\n"); + f.write("$literal", "literal\n"); + let list = f._temp.path().join("files.txt"); + fs::write(&list, "# quoted sources and targets\n\"source/notes one\" \"notes copy\"\n'source/scripts two' 'scripts copy' # directory\n'source/$literal' 'literal/$name'\n").unwrap(); + let output = f.cli(&["add", "--from", list.to_str().unwrap()]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + for (path, expected) in [ + ("notes copy", "notes\n"), + ("scripts copy/build.sh", "build\n"), + ("literal/$name", "literal\n"), + ] { + assert_eq!( + fs::read_to_string(f.repository.join("macos").join(path)).unwrap(), + expected + ); + } +} + +#[test] +fn malformed_list_reports_line_number_without_partial_import() { + let f = Fixture::new("", false); + let mut config = f.store.config().unwrap(); + config.entries.clear(); + f.store.save_config(&config).unwrap(); + f.write("a", "first entry"); + let before = fs::read(f.store.root.join("config.toml")).unwrap(); + let list = f._temp.path().join("files.txt"); + for invalid in ["unquoted path target", "\"unclosed"] { + fs::write(&list, format!("source/a first\n{invalid}\n")).unwrap(); + let output = f.cli(&["add", "--from", list.to_str().unwrap()]); + assert!(!output.status.success()); + let error = String::from_utf8_lossy(&output.stderr); + assert!(error.contains("files.txt:2"), "{error}"); + assert_eq!(fs::read(f.store.root.join("config.toml")).unwrap(), before); + assert!(!f.repository.join("first").exists()); + assert!(!f.store.root.join("state.db").exists()); + } +} + +#[test] +fn dry_run_preserves_existing_sqlite_state() { + let f = Fixture::new("", false); + f.write("a", "before"); + f.sync(); + let before = fs::read(f.store.root.join("state.db")).unwrap(); + f.write("a", "after"); + let report = filetrail::sync::run(&f.store, true, None).unwrap(); + assert!( + report + .actions + .iter() + .any(|action| action == "update config/a") + ); + assert_eq!(fs::read(f.store.root.join("state.db")).unwrap(), before); + assert_eq!(fs::read_to_string(f.target("a")).unwrap(), "before"); +} + +#[test] +fn single_file_ownership_modification_and_deletion() { + let f = Fixture::new("macos", true); + f.write("shellrc", "original\n"); + let mut config = f.store.config().unwrap(); + config.entries[0].source.push("shellrc"); + config.entries[0].directory = false; + config.entries[0].target = ".zshrc".into(); + f.store.save_config(&config).unwrap(); + f.sync(); + assert!(f.store.state().unwrap().owned.contains_key("macos/.zshrc")); + filetrail::git::commit(&f.store, None, &[]).unwrap(); + f.write("shellrc", "changed\n"); + f.sync(); + assert!( + filetrail::git::diff(&f.store, &["macos".into()]) + .unwrap() + .contains("+changed") + ); + fs::remove_file(f.source.join("shellrc")).unwrap(); + f.sync(); + assert!(!f.repository.join("macos/.zshrc").exists()); + let message = filetrail::git::commit(&f.store, None, &[]).unwrap(); + assert!(message.contains("delete \"macos/.zshrc\""), "{message}"); +} + +#[test] +fn external_sources_default_to_absolute_hierarchy_for_cli_and_lists() { + for subdir in ["", "macos"] { + for import_list in [false, true] { + let f = Fixture::new(subdir, false); + let mut config = f.store.config().unwrap(); + config.entries.clear(); + f.store.save_config(&config).unwrap(); + f.write("one", "single file\n"); + f.write("tools/build.sh", "directory child\n"); + if import_list { + let list = f._temp.path().join("sources.txt"); + fs::write(&list, "source/one\nsource/tools\n").unwrap(); + let result = f.cli(&["add", "--from", list.to_str().unwrap()]); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + } else { + for source in [f.source.join("one"), f.source.join("tools")] { + let result = f.cli(&["add", source.to_str().unwrap()]); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + } + } + let source = fs::canonicalize(&f.source).unwrap(); + let target = f + .repository + .join(subdir) + .join(source.strip_prefix("/").unwrap()); + assert_eq!( + fs::read_to_string(target.join("one")).unwrap(), + "single file\n" + ); + assert_eq!( + fs::read_to_string(target.join("tools/build.sh")).unwrap(), + "directory child\n" + ); + let config = f.store.config().unwrap(); + assert_eq!(config.entries.len(), 2); + for entry in &config.entries { + assert_eq!(entry.target, entry.source.strip_prefix("/").unwrap()); + } + let commit = filetrail::git::commit(&f.store, None, &[]).unwrap(); + assert!(commit.contains("sync 2 files"), "{commit}"); + } + } +} + +#[test] +fn empty_directories_are_copied_but_not_committed() { + let f = Fixture::new("", false); + fs::create_dir(f.source.join("empty")).unwrap(); + f.sync(); + assert!(f.target("empty").is_dir()); + assert!(filetrail::git::commit(&f.store, None, &[]).is_err()); +} + +#[test] +fn target_deletion_and_conflicted_deletion_are_protected() { + let f = Fixture::new("", true); + f.write("a", "initial"); + f.write("b", "initial"); + f.sync(); + fs::remove_file(f.target("a")).unwrap(); + fs::write(f.target("b"), "external edit").unwrap(); + fs::remove_file(f.source.join("b")).unwrap(); + assert!( + !filetrail::sync::run(&f.store, false, None) + .unwrap() + .errors + .is_empty() + ); + assert!(!f.target("a").exists()); + assert_eq!(fs::read_to_string(f.target("b")).unwrap(), "external edit"); + filetrail::sync::run(&f.store, false, Some(Path::new("config/a"))).unwrap(); + assert!(f.store.state().unwrap().conflicts.contains_key("config/b")); + assert_eq!(fs::read_to_string(f.target("b")).unwrap(), "external edit"); +} + +#[test] +fn source_exclusions_do_not_turn_a_single_file_into_a_deletion() { + let f = Fixture::new("", true); + f.write("a.tmp", "keep"); + let mut config = f.store.config().unwrap(); + config.entries[0].source.push("a.tmp"); + config.entries[0].directory = false; + config.entries[0].target = "renamed".into(); + f.store.save_config(&config).unwrap(); + f.sync(); + config.entries[0].exclude = vec!["*.tmp".into()]; + f.store.save_config(&config).unwrap(); + f.sync(); + assert!(f.repository.join("renamed").exists()); +} + +#[test] +fn target_gitignore_does_not_force_add_ignored_files() { + let f = Fixture::new("", false); + fs::write(f.repository.join(".gitignore"), "config/ignored\n").unwrap(); + f.write("ignored", "value"); + f.sync(); + assert!( + filetrail::git::status(&f.store) + .unwrap() + .contains("[ignored] config/ignored") + ); + assert!(filetrail::git::commit(&f.store, None, &[]).is_err()); +} + +#[test] +fn case_insensitive_target_collisions_are_rejected() { + let f = Fixture::new("", false); + let mut config = f.store.config().unwrap(); + let mut other = config.entries[0].clone(); + other.id = 2; + other.source = f.source.with_extension("other"); + other.target = "CONFIG/child".into(); + config.entries.push(other); + assert!(f.store.save_config(&config).is_err()); +} + +struct DaemonGuard(Store); + +impl Drop for DaemonGuard { + fn drop(&mut self) { + let _ = filetrail::daemon::stop(&self.0); + } +} + +#[test] +fn background_start_polling_and_singleton() { + let f = Fixture::new("", false); + f.write("a", "initial"); + let started = f.cli(&["daemon", "start", "--poll"]); + assert!( + started.status.success(), + "{}", + String::from_utf8_lossy(&started.stderr) + ); + let _guard = DaemonGuard(f.store.clone()); + let status = String::from_utf8(started.stdout).unwrap(); + assert!(status.contains("mode=poll")); + assert_eq!( + String::from_utf8(f.cli(&["daemon", "start"]).stdout).unwrap(), + status + ); + assert!(!f.cli(&["daemon", "run"]).status.success()); + wait_until(|| f.target("a").exists()); + for name in [ + "config.toml", + "state.db", + "operation.lock", + "daemon.lock", + "daemon.sock", + "daemon-output.log", + "filetrail.log", + ] { + wait_until(|| f.store.root.join(name).exists()); + } + f.write("a", "polled change"); + wait_until(|| { + fs::read_to_string(f.target("a")).is_ok_and(|content| content == "polled change") + }); + assert!(f.cli(&["daemon", "stop"]).status.success()); + assert!(!f.cli(&["daemon", "status"]).status.success()); +}