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()); +}