diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d56def..1871ee4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,13 @@ name: ci on: [push, pull_request] jobs: + msrv: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.77.0 + - run: cargo test --all --locked + - run: cargo install --path . --locked rust: runs-on: ubuntu-latest steps: @@ -11,6 +18,37 @@ jobs: - run: cargo fmt --all --check - run: cargo clippy --all-targets -- -D warnings - run: cargo test --all + windows-data: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --all --locked + release-build: + name: release build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + archive_ext: tar.gz + - target: aarch64-apple-darwin + os: macos-latest + archive_ext: tar.gz + - target: x86_64-apple-darwin + os: macos-latest + archive_ext: tar.gz + - target: x86_64-pc-windows-msvc + os: windows-latest + archive_ext: zip + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install release target + run: rustup target add ${{ matrix.target }} + - run: cargo build --release --locked --target ${{ matrix.target }} python-parity: runs-on: ubuntu-latest steps: diff --git a/Cargo.lock b/Cargo.lock index e0291a3..56c0654 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,9 +22,9 @@ dependencies = [ [[package]] name = "anstream" -version = "1.0.0" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -43,9 +43,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "1.0.0" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] @@ -115,9 +115,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.6.1" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive", @@ -125,9 +125,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", @@ -137,9 +137,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck", "proc-macro2", @@ -149,9 +149,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.1.0" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "colorchoice" @@ -218,17 +218,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "displaydoc" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "errno" version = "0.3.14" @@ -321,13 +310,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", + "wasip2", ] [[package]] @@ -354,107 +344,14 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -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" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" dependencies = [ - "icu_normalizer", - "icu_properties", + "unicode-bidi", + "unicode-normalization", ] [[package]] @@ -486,9 +383,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.28.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ "cc", "pkg-config", @@ -501,12 +398,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - [[package]] name = "log" version = "0.4.33" @@ -559,15 +450,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -588,9 +470,9 @@ dependencies = [ [[package]] name = "r-efi" -version = "6.0.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "redox_users" @@ -619,9 +501,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.31.0" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ "bitflags", "fallible-iterator", @@ -751,12 +633,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[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" @@ -780,17 +656,6 @@ dependencies = [ "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", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -798,7 +663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -825,27 +690,47 @@ dependencies = [ ] [[package]] -name = "tinystr" -version = "0.8.3" +name = "tinyvec" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ - "displaydoc", - "zerovec", + "tinyvec_macros", ] +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -870,22 +755,15 @@ dependencies = [ [[package]] name = "url" -version = "2.5.8" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" 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" @@ -910,6 +788,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -1083,33 +970,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[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" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "zerocopy" @@ -1131,65 +995,11 @@ dependencies = [ "syn", ] -[[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", - "synstructure", -] - [[package]] name = "zeroize" -version = "1.9.0" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index 689d028..2178c6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "fojin-cli" version = "0.2.1" edition = "2021" -rust-version = "1.74" +rust-version = "1.77" description = "fojin 跨藏对读 CLI —— 离线查询佛典汉文的梵/巴/藏平行 (offline, no login)" license = "MIT OR Apache-2.0" repository = "https://github.com/xr843/fojin-cli" @@ -20,8 +20,8 @@ name = "fojin" path = "src/main.rs" [dependencies] -clap = { version = "4", features = ["derive"] } -rusqlite = { version = "0.31", features = ["bundled"] } +clap = { version = "=4.5.53", features = ["derive"] } +rusqlite = { version = "=0.32.1", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" directories = "5" diff --git a/README.md b/README.md index 4bf5fe0..528f194 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ $ fojin parallel "色即是空" 通过 [crates.io](https://crates.io/crates/fojin-cli) 安装(命令为 `fojin`): ```bash -cargo install fojin-cli +cargo install fojin-cli --locked ``` 没有 Rust 环境?一行脚本自动安装对应平台的预编译二进制(Linux x64 / macOS ARM+Intel): @@ -39,7 +39,7 @@ curl -fsSL https://raw.githubusercontent.com/xr843/fojin-cli/master/install.sh | 也可从 [Releases](https://github.com/xr843/fojin-cli/releases/latest) 手动下载各平台二进制(含 Windows x64 zip),或从源码安装: ```bash -cargo install --git https://github.com/xr843/fojin-cli +cargo install --git https://github.com/xr843/fojin-cli --locked ``` 首次运行 `fojin parallel` 会自动下载对齐数据集(约 183 MB,带进度显示,见下方「数据集」),之后完全离线。 @@ -109,6 +109,7 @@ fojin cite T0251 # 按编号列出一部经的对齐,经文顺序;--jua fojin data status # 本地数据状态(位置/大小/版本/行数统计) fojin data clean # 删除本地数据,释放 561 MB fojin data update # 重新下载数据(覆盖本地) +fojin data verify # 校验版本、SQLite 与 FTS 完整性 ``` `texts` 与 `cite` 支持与 `parallel` 一致的 `--json` / `--data-dir` / `--offline`; @@ -184,11 +185,12 @@ fojin parallel "<汉文短语>" --json --offline **fojin-cli** is an offline command-line tool: give it a Chinese Buddhist canonical passage, it returns the aligned Sanskrit/Tibetan parallels — from a local SQLite, in ~2 ms, fully offline after a one-time 183 MB data download. Single binary, no account, deterministic output. ```bash -cargo install fojin-cli # or: curl -fsSL https://raw.githubusercontent.com/xr843/fojin-cli/master/install.sh | sh +cargo install fojin-cli --locked # or: curl -fsSL https://raw.githubusercontent.com/xr843/fojin-cli/master/install.sh | sh fojin parallel "色即是空" # Sanskrit + Tibetan parallels with Taishō source refs fojin texts "心经" # fuzzy title search → Taishō numbers fojin cite T0251 # browse one text's alignments in canonical order fojin data status # local dataset stats +fojin data verify # verify version, SQLite, and FTS integrity ``` - **Input**: Chinese only (traditional/simplified folded, punctuation ignored); literal substring matching over normalized text. 2-to-12-character phrases work best. diff --git a/examples/claude/README.md b/examples/claude/README.md index e5aaf28..df55bdf 100644 --- a/examples/claude/README.md +++ b/examples/claude/README.md @@ -7,7 +7,7 @@ 2. **`CLAUDE-snippet.md`** — 内容粘贴进你项目的 `CLAUDE.md`,让 Claude 在任何任务中 知道本机有 fojin-cli 可用、何时该用它(以及何时不该——语义检索/巴利/翻译请走在线 API)。 -前提:`fojin` 已安装且在 PATH 中(`cargo install fojin-cli` 或仓库根目录的 `install.sh`)。 +前提:`fojin` 已安装且在 PATH 中(`cargo install fojin-cli --locked` 或仓库根目录的 `install.sh`)。 建议先跑一次 `fojin data status` 确认数据就绪,agent 会话中即可全程 `--offline`。 其他 agent 框架(LangChain、OpenAI function calling 等)同理:fojin-cli 的 diff --git a/install.sh b/install.sh index 798245a..e1d4c40 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ case "$os" in Linux) case "$arch" in x86_64) target="x86_64-unknown-linux-gnu" ;; - *) die "暂无 Linux/$arch 预编译二进制,请改用: cargo install fojin-cli" ;; + *) die "暂无 Linux/$arch 预编译二进制,请改用: cargo install fojin-cli --locked" ;; esac ;; Darwin) case "$arch" in @@ -34,7 +34,7 @@ case "$os" in MINGW*|MSYS*|CYGWIN*) die "Windows 请从 Releases 页下载 zip: https://github.com/$REPO/releases/latest" ;; *) - die "暂不支持 $os,请改用: cargo install fojin-cli" ;; + die "暂不支持 $os,请改用: cargo install fojin-cli --locked" ;; esac # Resolve version: newest v* tag (the repo also publishes data-v* releases, diff --git a/src/cli.rs b/src/cli.rs index 15a6e8d..9a80688 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -54,7 +54,7 @@ pub enum Command { #[arg(long)] offline: bool, }, - /// 本地数据管理:状态 / 清理 / 重新下载 + /// 本地数据管理:状态 / 校验 / 清理 / 重新下载 Data { #[command(subcommand)] action: DataAction, @@ -135,6 +135,15 @@ pub enum DataAction { #[arg(long)] data_dir: Option, }, + /// 校验本地数据版本 / 规范 / SQLite 与 FTS 完整性(不触发下载) + Verify { + /// 机器可读 JSON 输出 + #[arg(long)] + json: bool, + /// 指定数据目录(覆盖默认缓存) + #[arg(long)] + data_dir: Option, + }, } pub fn compute_output( @@ -190,16 +199,7 @@ pub fn run() -> Result { } let preflight = normalize::normalize(raw.trim(), &normalize::NormMap::new()); normalize::validate_query_length(&preflight)?; - let path = data::resolve_data_path(data_dir)?; - data::ensure_data( - &path, - offline, - &data::DataSource { - url: DATA_URL, - sha256: DATA_SHA256, - }, - )?; - let conn = data::open_db(&path)?; + let conn = open_ensured(data_dir, offline)?; let langs: Option> = lang.map(|l| { l.split(',') .map(|s| s.trim().to_string()) @@ -288,7 +288,7 @@ fn open_ensured(data_dir: Option, offline: bool) -> Result sha256: DATA_SHA256, }, )?; - data::open_db(&path) + data::open_compatible_db(&path) } fn run_data(action: DataAction) -> Result { @@ -352,12 +352,8 @@ fn run_data(action: DataAction) -> Result { } DataAction::Update { data_dir } => { let path = data::resolve_data_path(data_dir)?; - if path.exists() { - std::fs::remove_file(&path)?; - } - data::ensure_data( + data::update_data( &path, - false, &data::DataSource { url: DATA_URL, sha256: DATA_SHA256, @@ -366,5 +362,29 @@ fn run_data(action: DataAction) -> Result { println!("数据已更新: {}", path.display()); Ok(0) } + DataAction::Verify { json, data_dir } => { + let path = data::resolve_data_path(data_dir)?; + if !path.exists() { + anyhow::bail!( + "本地数据不存在: {}。请先运行 `fojin data update`。", + path.display() + ); + } + let compatibility = data::verify_dataset_file(&path)?; + if json { + let v = serde_json::json!({ + "ok": true, + "version": compatibility.version, + "norm_ruleset": compatibility.norm_ruleset, + }); + println!("{}", serde_json::to_string_pretty(&v)?); + } else { + println!( + "数据校验通过 version={} norm_ruleset={}", + compatibility.version, compatibility.norm_ruleset + ); + } + Ok(0) + } } } diff --git a/src/data.rs b/src/data.rs index be44f35..868828d 100644 --- a/src/data.rs +++ b/src/data.rs @@ -1,6 +1,8 @@ use anyhow::{anyhow, Context, Result}; -use std::io::Read; +use rusqlite::{OpenFlags, OptionalExtension}; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; /// Connect timeout for the data download: fails fast if the release host is @@ -10,6 +12,10 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); /// so this is generous for a slow-but-alive connection while still /// guaranteeing the CLI can never hang indefinitely. const READ_TIMEOUT: Duration = Duration::from_secs(900); +static CANDIDATE_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +pub const EXPECTED_DATA_VERSION: &str = "v1"; +pub const EXPECTED_NORM_RULESET: &str = "t2s-char-1to1-v1"; pub struct DataSource<'a> { pub url: &'a str, @@ -111,6 +117,36 @@ pub fn ensure_data(path: &Path, offline: bool, source: &DataSource) -> Result<() if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).context("创建缓存目录失败")?; } + let raw = download_and_unpack(path, source)?; + write_atomic(path, &raw) +} + +/// Download a replacement dataset to a sibling candidate, validate it, then +/// replace the live dataset. Failures before replacement leave `path` intact. +pub fn update_data(path: &Path, source: &DataSource) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).context("创建缓存目录失败")?; + } + + let raw = download_and_unpack(path, source)?; + let (candidate, mut candidate_file) = create_candidate(path)?; + let write_result = candidate_file + .write_all(&raw) + .with_context(|| format!("写入候选数据失败: {}", candidate.display())); + drop(candidate_file); + if let Err(error) = write_result { + return Err(cleanup_candidate_error(&candidate, error)); + } + + let validation_result = verify_dataset_file(&candidate).map(|_| ()); + if let Err(error) = validation_result { + return Err(cleanup_candidate_error(&candidate, error)); + } + + finish_replacement(&candidate, replace_with_candidate(path, &candidate)) +} + +fn download_and_unpack(path: &Path, source: &DataSource) -> Result> { let gz = http_get(source.url).map_err(|e| { anyhow!( "{e:#}\n请手动下载:\n {}\n解压后放到: {}", @@ -125,11 +161,261 @@ pub fn ensure_data(path: &Path, offline: bool, source: &DataSource) -> Result<() path.display() )); } - let raw = gunzip(&gz)?; - write_atomic(path, &raw)?; + gunzip(&gz) +} + +fn sibling_path(path: &Path, suffix: &str) -> Result { + let file_name = path + .file_name() + .ok_or_else(|| anyhow!("数据路径没有文件名: {}", path.display()))?; + let mut sibling = file_name.to_os_string(); + sibling.push(suffix); + Ok(path.with_file_name(sibling)) +} + +fn create_candidate(path: &Path) -> Result<(PathBuf, std::fs::File)> { + loop { + let sequence = CANDIDATE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let suffix = format!(".candidate.{}.{sequence}", std::process::id()); + let candidate = sibling_path(path, &suffix)?; + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&candidate) + { + Ok(file) => return Ok((candidate, file)), + Err(ref error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(error) + .with_context(|| format!("创建候选数据失败: {}", candidate.display())) + } + } + } +} + +fn cleanup_candidate_error(candidate: &Path, error: anyhow::Error) -> anyhow::Error { + match remove_candidate_artifacts(candidate) { + Ok(()) => error, + Err(cleanup_error) => error.context(format!("清理候选数据失败: {cleanup_error}")), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CandidateCleanupPolicy { + Remove, + #[cfg(any(test, windows))] + Preserve, +} + +#[derive(Debug)] +struct ReplacementFailure { + error: anyhow::Error, + cleanup: CandidateCleanupPolicy, +} + +impl ReplacementFailure { + fn remove(error: anyhow::Error) -> Self { + Self { + error, + cleanup: CandidateCleanupPolicy::Remove, + } + } + + #[cfg(any(test, windows))] + fn preserve(error: anyhow::Error) -> Self { + Self { + error, + cleanup: CandidateCleanupPolicy::Preserve, + } + } +} + +type ReplacementResult = std::result::Result<(), ReplacementFailure>; + +fn finish_replacement(candidate: &Path, result: ReplacementResult) -> Result<()> { + match result { + Ok(()) => Ok(()), + Err(failure) => match failure.cleanup { + CandidateCleanupPolicy::Remove => { + Err(cleanup_candidate_error(candidate, failure.error)) + } + #[cfg(any(test, windows))] + CandidateCleanupPolicy::Preserve => Err(failure.error.context(format!( + "validated candidate preserved at `{}`", + candidate.display() + ))), + }, + } +} + +fn remove_candidate_artifacts(candidate: &Path) -> Result<()> { + for suffix in ["", "-journal", "-shm", "-wal"] { + let artifact = if suffix.is_empty() { + candidate.to_path_buf() + } else { + sibling_path(candidate, suffix)? + }; + match std::fs::remove_file(&artifact) { + Ok(()) => {} + Err(ref error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("删除候选数据失败: {}", artifact.display())) + } + } + } Ok(()) } +#[cfg(not(windows))] +fn replace_with_candidate(path: &Path, candidate: &Path) -> ReplacementResult { + // The candidate is a sibling, so rename atomically replaces the live path + // on Unix without first removing or moving it away. + std::fs::rename(candidate, path) + .with_context(|| { + format!( + "替换数据文件失败: {} -> {}", + candidate.display(), + path.display() + ) + }) + .map_err(ReplacementFailure::remove) +} + +#[cfg(windows)] +fn replace_with_candidate(path: &Path, candidate: &Path) -> ReplacementResult { + match std::fs::metadata(path) { + Ok(_) => {} + Err(ref error) if error.kind() == std::io::ErrorKind::NotFound => { + return std::fs::rename(candidate, path) + .with_context(|| { + format!( + "替换数据文件失败: {} -> {}", + candidate.display(), + path.display() + ) + }) + .map_err(ReplacementFailure::remove) + } + Err(error) => { + return Err(ReplacementFailure::remove( + anyhow::Error::new(error) + .context(format!("检查现有数据文件失败: {}", path.display())), + )) + } + } + + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + use std::ptr; + + #[link(name = "Kernel32")] + extern "system" { + fn ReplaceFileW( + replaced_file_name: *const u16, + replacement_file_name: *const u16, + backup_file_name: *const u16, + replace_flags: u32, + exclude: *mut c_void, + reserved: *mut c_void, + ) -> i32; + } + + fn wide_path(path: &Path) -> Result> { + let mut encoded: Vec = path.as_os_str().encode_wide().collect(); + if encoded.contains(&0) { + return Err(anyhow!("数据路径包含空字符: {}", path.display())); + } + encoded.push(0); + Ok(encoded) + } + + let replaced = wide_path(path).map_err(ReplacementFailure::remove)?; + let replacement = wide_path(candidate).map_err(ReplacementFailure::remove)?; + // SAFETY: both path buffers are NUL-terminated UTF-16 and remain alive for + // the call; optional and reserved pointers are null as ReplaceFileW requires. + let replaced_ok = unsafe { + ReplaceFileW( + replaced.as_ptr(), + replacement.as_ptr(), + ptr::null(), + 0, + ptr::null_mut(), + ptr::null_mut(), + ) + }; + if replaced_ok != 0 { + return Ok(()); + } + + let native_error = std::io::Error::last_os_error(); + handle_windows_replace_failure(path, candidate, native_error, |from, to| { + std::fs::rename(from, to) + }) +} + +#[cfg(windows)] +fn handle_windows_replace_failure( + path: &Path, + candidate: &Path, + native_error: std::io::Error, + recover: F, +) -> ReplacementResult +where + F: FnOnce(&Path, &Path) -> std::io::Result<()>, +{ + const ERROR_UNABLE_TO_REMOVE_REPLACED: i32 = 1175; + const ERROR_UNABLE_TO_MOVE_REPLACEMENT: i32 = 1176; + const ERROR_UNABLE_TO_MOVE_REPLACEMENT_2: i32 = 1177; + + match native_error.raw_os_error() { + Some(ERROR_UNABLE_TO_MOVE_REPLACEMENT | ERROR_UNABLE_TO_MOVE_REPLACEMENT_2) => { + match recover(candidate, path) { + Ok(()) => Ok(()), + Err(recovery_error) => Err(ReplacementFailure::preserve(anyhow!( + "ReplaceFileW failed for replaced `{}` and replacement `{}` with {}; recovery rename `{} -> {}` failed with {}", + path.display(), + candidate.display(), + describe_windows_error(&native_error), + candidate.display(), + path.display(), + describe_windows_error(&recovery_error) + ))), + } + } + Some(ERROR_UNABLE_TO_REMOVE_REPLACED) => Err(ReplacementFailure::remove( + windows_replace_error(path, candidate, &native_error), + )), + _ => Err(ReplacementFailure::remove(windows_replace_error( + path, + candidate, + &native_error, + ))), + } +} + +#[cfg(windows)] +fn windows_replace_error( + path: &Path, + candidate: &Path, + native_error: &std::io::Error, +) -> anyhow::Error { + anyhow!( + "ReplaceFileW failed for replaced `{}` and replacement `{}` with {}", + path.display(), + candidate.display(), + describe_windows_error(native_error) + ) +} + +#[cfg(windows)] +fn describe_windows_error(error: &std::io::Error) -> String { + match error.raw_os_error() { + Some(code) => format!("Windows error {code}: {error}"), + None => error.to_string(), + } +} + fn http_get(url: &str) -> Result> { let agent = ureq::AgentBuilder::new() .timeout_connect(CONNECT_TIMEOUT) @@ -162,6 +448,111 @@ pub fn open_db(path: &Path) -> Result { rusqlite::Connection::open(path).with_context(|| format!("打开数据失败: {}", path.display())) } +pub fn open_read_only_db(path: &Path) -> Result { + rusqlite::Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .with_context(|| format!("打开数据失败: {}", path.display())) +} + +#[derive(Debug, serde::Serialize)] +pub struct DatasetCompatibility { + pub version: String, + pub norm_ruleset: String, +} + +pub fn validate_compatibility(conn: &rusqlite::Connection) -> Result { + require_schema(conn, "meta", "SELECT key, value FROM meta LIMIT 0")?; + require_schema( + conn, + "parallels", + "SELECT id, zh_text, zh_norm, foreign_lang, foreign_text, confidence, cbeta_id, title_zh, \ + juan_num FROM parallels LIMIT 0", + )?; + require_schema( + conn, + "parallels_fts", + "SELECT rowid, zh_norm FROM parallels_fts LIMIT 0", + )?; + require_trigram_fts(conn)?; + require_schema( + conn, + "norm_map", + "SELECT from_char, to_char FROM norm_map LIMIT 0", + )?; + + Ok(DatasetCompatibility { + version: require_expected_meta(conn, "version", EXPECTED_DATA_VERSION)?, + norm_ruleset: require_expected_meta(conn, "norm_ruleset", EXPECTED_NORM_RULESET)?, + }) +} + +pub fn verify_dataset(conn: &rusqlite::Connection) -> Result { + let compatibility = validate_compatibility(conn)?; + let mut stmt = conn.prepare("PRAGMA quick_check").map_err(|e| { + anyhow!( + "dataset incompatibility: could not run PRAGMA quick_check: {e}. Run `fojin data update`." + ) + })?; + let diagnostics = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|e| { + anyhow!( + "dataset incompatibility: could not read PRAGMA quick_check output: {e}. Run `fojin data update`." + ) + })? + .collect::>>() + .map_err(|e| { + anyhow!( + "dataset incompatibility: could not collect PRAGMA quick_check output: {e}. Run `fojin data update`." + ) + })?; + + if diagnostics.as_slice() == ["ok"] { + return Ok(compatibility); + } + + let summary = if diagnostics.is_empty() { + "no diagnostics returned".to_string() + } else { + diagnostics.join("; ") + }; + Err(anyhow!( + "dataset incompatibility: PRAGMA quick_check failed: {summary}. Run `fojin data update`." + )) +} + +pub fn verify_dataset_file(path: &Path) -> Result { + let compatibility = { + let conn = open_read_only_db(path)?; + verify_dataset(&conn)? + }; + let conn = rusqlite::Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE) + .map_err(|error| { + anyhow!( + "dataset incompatibility: could not open existing dataset for FTS5 integrity-check: {error}. Run `fojin data update`." + ) + })?; + verify_fts_content_integrity(&conn)?; + Ok(compatibility) +} + +fn verify_fts_content_integrity(conn: &rusqlite::Connection) -> Result<()> { + const FTS_CONTENT_INTEGRITY_CHECK: &str = + "INSERT INTO parallels_fts(parallels_fts, rank) VALUES('integrity-check', 1)"; + conn.execute(FTS_CONTENT_INTEGRITY_CHECK, []) + .map(|_| ()) + .map_err(|error| { + anyhow!( + "dataset incompatibility: FTS5 integrity-check failed: {error}. Run `fojin data update`." + ) + }) +} + +pub fn open_compatible_db(path: &Path) -> Result { + let conn = open_read_only_db(path)?; + validate_compatibility(&conn)?; + Ok(conn) +} + #[derive(Debug, serde::Serialize)] pub struct DatasetStats { pub version: Option, @@ -205,3 +596,203 @@ pub fn dataset_stats(conn: &rusqlite::Connection) -> Result { texts, }) } + +fn require_schema(conn: &rusqlite::Connection, name: &str, sql: &str) -> Result<()> { + conn.prepare(sql).map(|_| ()).map_err(|e| { + anyhow!( + "dataset incompatibility: required schema `{name}` is missing or invalid: {e}. Run `fojin data update`." + ) + }) +} + +fn require_trigram_fts(conn: &rusqlite::Connection) -> Result<()> { + let sql: String = conn + .query_row( + "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'parallels_fts'", + [], + |row| row.get(0), + ) + .map_err(|e| { + anyhow!( + "dataset incompatibility: required schema `parallels_fts` has no table declaration: {e}. Run `fojin data update`." + ) + })?; + let declaration: String = sql + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .flat_map(char::to_lowercase) + .collect(); + const EXPECTED_DECLARATION: &str = "createvirtualtableparallels_ftsusingfts5(zh_norm,content='parallels',content_rowid='id',tokenize='trigram')"; + if declaration != EXPECTED_DECLARATION { + return Err(anyhow!( + "dataset incompatibility: required schema `parallels_fts` does not match the required FTS5 trigram declaration. Run `fojin data update`." + )); + } + + let _: Option = conn + .query_row( + "SELECT 1 FROM parallels_fts WHERE parallels_fts MATCH ?1 LIMIT 1", + ["x"], + |row| row.get(0), + ) + .optional() + .map_err(|e| { + anyhow!( + "dataset incompatibility: required schema `parallels_fts` does not support FTS5 MATCH: {e}. Run `fojin data update`." + ) + })?; + Ok(()) +} + +fn require_expected_meta(conn: &rusqlite::Connection, key: &str, expected: &str) -> Result { + let got = conn + .query_row("SELECT value FROM meta WHERE key=?1", [key], |row| row.get::<_, String>(0)) + .optional() + .map_err(|e| { + anyhow!( + "dataset incompatibility: could not read meta `{key}`: {e}. Run `fojin data update`." + ) + })? + .ok_or_else(|| { + anyhow!( + "dataset incompatibility: required meta `{key}` is missing. Run `fojin data update`." + ) + })?; + + if got != expected { + return Err(anyhow!( + "dataset incompatibility: meta `{key}` expected `{expected}` but found `{got}`. Run `fojin data update`." + )); + } + + Ok(got) +} + +#[cfg(test)] +mod replacement_cleanup_tests { + use super::*; + + #[test] + fn preserve_policy_keeps_validated_candidate_and_reports_path() { + let dir = tempfile::tempdir().unwrap(); + let candidate = dir.path().join("data.sqlite.candidate.1.1"); + std::fs::write(&candidate, b"validated dataset").unwrap(); + + let error = finish_replacement( + &candidate, + Err(ReplacementFailure::preserve(anyhow!( + "replacement recovery failed" + ))), + ) + .unwrap_err(); + + assert_eq!(std::fs::read(&candidate).unwrap(), b"validated dataset"); + let detail = format!("{error:#}"); + assert!(detail.contains("validated candidate preserved"), "{detail}"); + assert!( + detail.contains(candidate.to_string_lossy().as_ref()), + "{detail}" + ); + assert!(detail.contains("replacement recovery failed"), "{detail}"); + } +} + +#[cfg(all(test, windows))] +mod windows_replace_failure_tests { + use super::*; + use std::cell::Cell; + use std::io; + + const LIVE: &str = r"C:\cache\data.sqlite"; + const CANDIDATE: &str = r"C:\cache\data.sqlite.candidate.1.1"; + + #[test] + fn error_1175_returns_native_failure_without_recovery() { + assert_names_untouched_error_skips_recovery(1175); + } + + #[test] + fn other_error_returns_native_failure_without_recovery() { + assert_names_untouched_error_skips_recovery(87); + } + + #[test] + fn error_1176_recovers_missing_live_path() { + assert_partial_failure_recovers(1176); + } + + #[test] + fn error_1177_recovers_missing_live_path() { + assert_partial_failure_recovers(1177); + } + + #[test] + fn error_1176_preserves_native_and_recovery_failures() { + assert_partial_failure_preserves_both_errors(1176); + } + + #[test] + fn error_1177_preserves_native_and_recovery_failures() { + assert_partial_failure_preserves_both_errors(1177); + } + + fn assert_names_untouched_error_skips_recovery(code: i32) { + let recovery_called = Cell::new(false); + let failure = handle_windows_replace_failure( + Path::new(LIVE), + Path::new(CANDIDATE), + io::Error::from_raw_os_error(code), + |_, _| { + recovery_called.set(true); + Ok(()) + }, + ) + .unwrap_err(); + + assert!(!recovery_called.get()); + assert_eq!(failure.cleanup, CandidateCleanupPolicy::Remove); + let detail = format!("{:#}", failure.error); + assert!( + detail.contains(&format!("Windows error {code}")), + "{detail}" + ); + } + + fn assert_partial_failure_recovers(code: i32) { + let recovery_called = Cell::new(false); + handle_windows_replace_failure( + Path::new(LIVE), + Path::new(CANDIDATE), + io::Error::from_raw_os_error(code), + |from, to| { + recovery_called.set(true); + assert_eq!(from, Path::new(CANDIDATE)); + assert_eq!(to, Path::new(LIVE)); + Ok(()) + }, + ) + .unwrap(); + + assert!(recovery_called.get()); + } + + fn assert_partial_failure_preserves_both_errors(code: i32) { + let failure = handle_windows_replace_failure( + Path::new(LIVE), + Path::new(CANDIDATE), + io::Error::from_raw_os_error(code), + |_, _| Err(io::Error::from_raw_os_error(5)), + ) + .unwrap_err(); + + assert_eq!(failure.cleanup, CandidateCleanupPolicy::Preserve); + let detail = format!("{:#}", failure.error); + assert!( + detail.contains(&format!("Windows error {code}")), + "{detail}" + ); + assert!(detail.contains("Windows error 5"), "{detail}"); + assert!(detail.contains(CANDIDATE), "{detail}"); + assert!(detail.contains(LIVE), "{detail}"); + } +} diff --git a/tests/command.rs b/tests/command.rs index 86d9c03..cdff623 100644 --- a/tests/command.rs +++ b/tests/command.rs @@ -5,6 +5,13 @@ fn write_offline_db(dir: &std::path::Path) { let db_path = dir.join("data.sqlite"); let conn = Connection::open(db_path).unwrap(); init_schema(&conn).unwrap(); + for (k, v) in [("version", "v1"), ("norm_ruleset", "t2s-char-1to1-v1")] { + conn.execute( + "INSERT INTO meta(key,value) VALUES (?1,?2)", + rusqlite::params![k, v], + ) + .unwrap(); + } } #[test] @@ -81,9 +88,17 @@ fn zero_limit_is_rejected_during_clap_parsing_before_data_access() { } fn write_fixture_db(dir: &std::path::Path) { + write_fixture_db_with_fts(dir, true); +} + +fn write_fixture_db_with_fts(dir: &std::path::Path, rebuild_fts: bool) { let conn = Connection::open(dir.join("data.sqlite")).unwrap(); init_schema(&conn).unwrap(); - for (k, v) in [("version", "v1"), ("license", "CC BY-SA 4.0")] { + for (k, v) in [ + ("version", "v1"), + ("norm_ruleset", "t2s-char-1to1-v1"), + ("license", "CC BY-SA 4.0"), + ] { conn.execute( "INSERT INTO meta(key,value) VALUES (?1,?2)", rusqlite::params![k, v], @@ -136,6 +151,19 @@ fn write_fixture_db(dir: &std::path::Path) { ) .unwrap(); } + if rebuild_fts { + conn.execute( + "INSERT INTO parallels_fts(parallels_fts) VALUES('rebuild')", + [], + ) + .unwrap(); + } else { + conn.execute( + "INSERT INTO parallels_fts(parallels_fts) VALUES('delete-all')", + [], + ) + .unwrap(); + } } fn run_fojin(args: &[&str], dir: &std::path::Path) -> std::process::Output { @@ -159,6 +187,99 @@ fn data_status_json_reports_dataset() { assert!(stdout.contains("\"exists\": true"), "got: {stdout}"); } +#[test] +fn data_verify_json_reports_exact_compatibility_shape() { + let dir = tempfile::tempdir().unwrap(); + write_fixture_db(dir.path()); + + let out = run_fojin(&["data", "verify", "--json"], dir.path()); + + assert_eq!(out.status.code(), Some(0)); + let stdout = String::from_utf8(out.stdout).unwrap(); + let got: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!( + got, + serde_json::json!({ + "ok": true, + "version": "v1", + "norm_ruleset": "t2s-char-1to1-v1" + }) + ); +} + +#[test] +fn data_verify_human_reports_compatibility_summary() { + let dir = tempfile::tempdir().unwrap(); + write_fixture_db(dir.path()); + + let out = run_fojin(&["data", "verify"], dir.path()); + + assert_eq!(out.status.code(), Some(0)); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.starts_with("数据校验通过"), "got: {stdout}"); + assert!(stdout.contains("v1"), "got: {stdout}"); + assert!(stdout.contains("t2s-char-1to1-v1"), "got: {stdout}"); +} + +#[test] +fn data_verify_rejects_empty_external_content_fts_index() { + let dir = tempfile::tempdir().unwrap(); + write_fixture_db_with_fts(dir.path(), false); + + let out = run_fojin(&["data", "verify"], dir.path()); + + assert_eq!(out.status.code(), Some(1)); + let stdout = String::from_utf8(out.stdout).unwrap(); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(stdout.trim().is_empty(), "got stdout: {stdout}"); + assert!(stderr.contains("FTS5 integrity-check"), "got: {stderr}"); +} + +#[test] +fn data_verify_does_not_modify_database_bytes() { + let dir = tempfile::tempdir().unwrap(); + write_fixture_db(dir.path()); + let path = dir.path().join("data.sqlite"); + let before = std::fs::read(&path).unwrap(); + + let out = run_fojin(&["data", "verify"], dir.path()); + + assert_eq!(out.status.code(), Some(0)); + assert_eq!(std::fs::read(path).unwrap(), before); +} + +#[test] +fn data_verify_missing_data_exits_one_without_creating_file() { + let dir = tempfile::tempdir().unwrap(); + let data_path = dir.path().join("data.sqlite"); + + let out = run_fojin(&["data", "verify"], dir.path()); + + assert_eq!(out.status.code(), Some(1)); + assert!( + !data_path.exists(), + "data verify must not create data.sqlite" + ); +} + +#[test] +fn incompatible_version_is_rejected_before_parallel_query() { + let dir = tempfile::tempdir().unwrap(); + write_fixture_db(dir.path()); + let conn = Connection::open(dir.path().join("data.sqlite")).unwrap(); + conn.execute("UPDATE meta SET value='v0' WHERE key='version'", []) + .unwrap(); + + let out = run_fojin(&["parallel", "色即是空", "--offline"], dir.path()); + + assert_eq!(out.status.code(), Some(1)); + let stdout = String::from_utf8(out.stdout).unwrap(); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(stdout.trim().is_empty(), "got stdout: {stdout}"); + assert!(stderr.contains("dataset incompatibility"), "got: {stderr}"); + assert!(stderr.contains("version"), "got: {stderr}"); +} + #[test] fn data_status_reports_missing_data_without_downloading() { let dir = tempfile::tempdir().unwrap(); diff --git a/tests/data.rs b/tests/data.rs index ec9dfb6..27adee2 100644 --- a/tests/data.rs +++ b/tests/data.rs @@ -1,4 +1,8 @@ -use fojin_cli::data::{ensure_data, gunzip, resolve_data_path, verify_sha256, DataSource}; +use fojin_cli::data::{ + ensure_data, gunzip, open_compatible_db, open_read_only_db, resolve_data_path, update_data, + validate_compatibility, verify_dataset, verify_sha256, DataSource, EXPECTED_DATA_VERSION, + EXPECTED_NORM_RULESET, +}; use std::io::Write; use std::path::PathBuf; @@ -174,3 +178,666 @@ fn dataset_stats_reads_meta_and_counts() { vec![("bo".to_string(), 1), ("sa".to_string(), 2)] ); } + +#[test] +fn open_read_only_db_rejects_create_table() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.sqlite"); + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute("CREATE TABLE existing (id INTEGER)", []) + .unwrap(); + drop(conn); + + let conn = open_read_only_db(&path).unwrap(); + let err = conn + .execute("CREATE TABLE forbidden (id INTEGER)", []) + .unwrap_err(); + + assert_eq!(err.sqlite_error_code(), Some(rusqlite::ErrorCode::ReadOnly)); +} + +#[test] +fn compatibility_accepts_expected_dataset_metadata() { + let conn = compatible_conn(); + let got = validate_compatibility(&conn).unwrap(); + assert_eq!(got.version, EXPECTED_DATA_VERSION); + assert_eq!(got.norm_ruleset, EXPECTED_NORM_RULESET); + assert_eq!( + serde_json::to_value(&got).unwrap(), + serde_json::json!({ + "version": "v1", + "norm_ruleset": "t2s-char-1to1-v1" + }) + ); +} + +#[test] +fn compatibility_rejects_wrong_version() { + let conn = compatible_conn(); + conn.execute("UPDATE meta SET value='v0' WHERE key='version'", []) + .unwrap(); + + let err = validate_compatibility(&conn).unwrap_err().to_string(); + assert!(err.contains("version"), "got: {err}"); + assert!(err.contains(EXPECTED_DATA_VERSION), "got: {err}"); + assert!(err.contains("fojin data update"), "got: {err}"); +} + +#[test] +fn compatibility_rejects_wrong_norm_ruleset() { + let conn = compatible_conn(); + conn.execute( + "UPDATE meta SET value='legacy-rules' WHERE key='norm_ruleset'", + [], + ) + .unwrap(); + + let err = validate_compatibility(&conn).unwrap_err().to_string(); + assert!(err.contains("norm_ruleset"), "got: {err}"); + assert!(err.contains(EXPECTED_NORM_RULESET), "got: {err}"); + assert!(err.contains("fojin data update"), "got: {err}"); +} + +#[test] +fn compatibility_rejects_missing_required_schema() { + let conn = compatible_conn(); + conn.execute("DROP TABLE norm_map", []).unwrap(); + + let err = validate_compatibility(&conn).unwrap_err().to_string(); + assert!(err.contains("norm_map"), "got: {err}"); + assert!(err.contains("fojin data update"), "got: {err}"); +} + +#[test] +fn compatibility_rejects_missing_parallels_fts() { + let conn = compatible_conn(); + conn.execute("DROP TABLE parallels_fts", []).unwrap(); + + let err = validate_compatibility(&conn).unwrap_err().to_string(); + assert!(err.contains("parallels_fts"), "got: {err}"); + assert!(err.contains("fojin data update"), "got: {err}"); +} + +#[test] +fn compatibility_rejects_parallels_fts_lookalike_table() { + let conn = compatible_conn(); + conn.execute("DROP TABLE parallels_fts", []).unwrap(); + conn.execute("CREATE TABLE parallels_fts (zh_norm TEXT NOT NULL)", []) + .unwrap(); + + let err = validate_compatibility(&conn).unwrap_err().to_string(); + + assert!(err.contains("parallels_fts"), "got: {err}"); + assert!(err.contains("fojin data update"), "got: {err}"); +} + +#[test] +fn compatibility_rejects_non_trigram_parallels_fts() { + let conn = compatible_conn(); + conn.execute("DROP TABLE parallels_fts", []).unwrap(); + conn.execute( + "CREATE VIRTUAL TABLE parallels_fts USING fts5(zh_norm, content='parallels', content_rowid='id')", + [], + ) + .unwrap(); + + let err = validate_compatibility(&conn).unwrap_err().to_string(); + + assert!(err.contains("parallels_fts"), "got: {err}"); + assert!(err.contains("fojin data update"), "got: {err}"); +} + +#[test] +fn compatibility_rejects_default_tokenizer_fts_spoofing_trigram_declaration() { + let conn = compatible_conn(); + conn.execute("DROP TABLE parallels_fts", []).unwrap(); + conn.execute( + "CREATE VIRTUAL TABLE parallels_fts USING fts5( + zh_norm, + \"tokenize='trigram'\", + content='parallels', + content_rowid='id' + )", + [], + ) + .unwrap(); + + let err = validate_compatibility(&conn).unwrap_err().to_string(); + + assert!(err.contains("parallels_fts"), "got: {err}"); + assert!(err.contains("fojin data update"), "got: {err}"); +} + +#[test] +fn compatibility_rejects_parallels_fts_view_lookalike() { + let conn = compatible_conn(); + conn.execute("DROP TABLE parallels_fts", []).unwrap(); + conn.execute( + "CREATE VIEW parallels_fts AS SELECT id AS rowid, zh_norm FROM parallels", + [], + ) + .unwrap(); + + let err = validate_compatibility(&conn).unwrap_err().to_string(); + + assert!(err.contains("parallels_fts"), "got: {err}"); + assert!(err.contains("fojin data update"), "got: {err}"); +} + +#[test] +fn compatibility_rejects_missing_query_required_parallels_column() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE norm_map (from_char TEXT PRIMARY KEY, to_char TEXT NOT NULL); + CREATE TABLE parallels ( + id INTEGER PRIMARY KEY, + zh_text TEXT NOT NULL, + zh_norm TEXT NOT NULL, + foreign_lang TEXT NOT NULL, + foreign_text TEXT NOT NULL, + cbeta_id TEXT, + title_zh TEXT, + juan_num INTEGER + ); + CREATE VIRTUAL TABLE parallels_fts USING fts5( + zh_norm, + content='parallels', + content_rowid='id', + tokenize='trigram' + );", + ) + .unwrap(); + insert_compat_meta(&conn); + + let err = validate_compatibility(&conn).unwrap_err().to_string(); + assert!(err.contains("parallels"), "got: {err}"); + assert!(err.contains("fojin data update"), "got: {err}"); +} + +#[test] +fn compatibility_open_compatible_db_checks_file_before_returning_connection() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.sqlite"); + let conn = rusqlite::Connection::open(&path).unwrap(); + fojin_cli::schema::init_schema(&conn).unwrap(); + insert_compat_meta(&conn); + drop(conn); + + let conn = open_compatible_db(&path).unwrap(); + let got = validate_compatibility(&conn).unwrap(); + assert_eq!(got.version, EXPECTED_DATA_VERSION); + assert_eq!(got.norm_ruleset, EXPECTED_NORM_RULESET); +} + +#[test] +fn verify_dataset_runs_quick_check_on_compatible_db() { + let conn = compatible_conn(); + let got = verify_dataset(&conn).unwrap(); + assert_eq!(got.version, EXPECTED_DATA_VERSION); + assert_eq!(got.norm_ruleset, EXPECTED_NORM_RULESET); +} + +#[test] +fn verify_dataset_accepts_compatible_read_only_db() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.sqlite"); + let conn = rusqlite::Connection::open(&path).unwrap(); + fojin_cli::schema::init_schema(&conn).unwrap(); + insert_compat_meta(&conn); + drop(conn); + + let conn = open_read_only_db(&path).unwrap(); + let got = verify_dataset(&conn).unwrap(); + + assert_eq!(got.version, EXPECTED_DATA_VERSION); + assert_eq!(got.norm_ruleset, EXPECTED_NORM_RULESET); +} + +#[test] +fn verify_dataset_rejects_quick_check_diagnostics() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.sqlite"); + let conn = rusqlite::Connection::open(&path).unwrap(); + fojin_cli::schema::init_schema(&conn).unwrap(); + insert_compat_meta(&conn); + conn.execute("CREATE TABLE quick_check_probe (value TEXT)", []) + .unwrap(); + let meta_rootpage: i64 = conn + .query_row( + "SELECT rootpage FROM sqlite_schema WHERE name = 'meta'", + [], + |row| row.get(0), + ) + .unwrap(); + conn.execute_batch("PRAGMA writable_schema = ON").unwrap(); + conn.execute( + "UPDATE sqlite_schema SET rootpage = ?1 WHERE name = 'quick_check_probe'", + [meta_rootpage], + ) + .unwrap(); + conn.execute_batch("PRAGMA writable_schema = OFF").unwrap(); + drop(conn); + + let conn = open_read_only_db(&path).unwrap(); + let err = verify_dataset(&conn).unwrap_err().to_string(); + + assert!(err.contains("PRAGMA quick_check failed"), "got: {err}"); + assert!(err.contains("2nd reference to page"), "got: {err}"); +} + +#[test] +fn update_data_preserves_live_dataset_when_candidate_fails_compatibility() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.sqlite"); + std::fs::write(&path, b"live dataset").unwrap(); + + let source_path = dir.path().join("candidate.sqlite"); + let conn = rusqlite::Connection::open(&source_path).unwrap(); + fojin_cli::schema::init_schema(&conn).unwrap(); + insert_compat_meta(&conn); + conn.execute("UPDATE meta SET value = 'v0' WHERE key = 'version'", []) + .unwrap(); + drop(conn); + + let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + enc.write_all(&std::fs::read(&source_path).unwrap()) + .unwrap(); + let gz = enc.finish().unwrap(); + std::fs::remove_file(&source_path).unwrap(); + + use sha2::{Digest, Sha256}; + let mut hash = Sha256::new(); + hash.update(&gz); + let sha: String = hash + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0u8; 4096]; + let _ = std::io::Read::read(&mut stream, &mut request); + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + gz.len() + ); + stream.write_all(head.as_bytes()).unwrap(); + stream.write_all(&gz).unwrap(); + }); + let source_url = format!("http://127.0.0.1:{port}/data.gz"); + let source = DataSource { + url: &source_url, + sha256: &sha, + }; + + let err = update_data(&path, &source).unwrap_err().to_string(); + server.join().unwrap(); + + assert!(err.contains("dataset incompatibility"), "got: {err}"); + assert_eq!(std::fs::read(&path).unwrap(), b"live dataset"); + let entries: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(entries, vec![std::ffi::OsString::from("data.sqlite")]); +} + +#[test] +fn update_data_replaces_valid_existing_dataset() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.sqlite"); + std::fs::write(&path, b"old live dataset").unwrap(); + let gz = gzip_bytes(&replacement_database_bytes()); + let sha = sha256_hex(&gz); + + serve_update(&path, gz, &sha).unwrap(); + + assert_replacement_marker(&path); + assert_no_candidate_artifacts(&path); +} + +#[test] +fn update_data_installs_valid_dataset_when_target_is_absent() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.sqlite"); + let gz = gzip_bytes(&replacement_database_bytes()); + let sha = sha256_hex(&gz); + + serve_update(&path, gz, &sha).unwrap(); + + assert_replacement_marker(&path); + assert_no_candidate_artifacts(&path); +} + +#[test] +fn update_data_preserves_live_dataset_on_download_failure() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.sqlite"); + std::fs::write(&path, b"old live dataset").unwrap(); + + let err = serve_update_response( + &path, + "500 Internal Server Error", + b"download failed".to_vec(), + "unused", + ) + .unwrap_err() + .to_string(); + + assert!(err.contains("下载失败"), "got: {err}"); + assert_eq!(std::fs::read(&path).unwrap(), b"old live dataset"); + assert_no_candidate_artifacts(&path); +} + +#[test] +fn update_data_preserves_live_dataset_on_checksum_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.sqlite"); + std::fs::write(&path, b"old live dataset").unwrap(); + let gz = gzip_bytes(&replacement_database_bytes()); + + let err = serve_update(&path, gz, &"0".repeat(64)) + .unwrap_err() + .to_string(); + + assert!(err.contains("sha256"), "got: {err}"); + assert_eq!(std::fs::read(&path).unwrap(), b"old live dataset"); + assert_no_candidate_artifacts(&path); +} + +#[test] +fn update_data_preserves_live_dataset_on_decompression_failure() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.sqlite"); + std::fs::write(&path, b"old live dataset").unwrap(); + let invalid_gzip = b"not gzip data".to_vec(); + let sha = sha256_hex(&invalid_gzip); + + let err = serve_update(&path, invalid_gzip, &sha) + .unwrap_err() + .to_string(); + + assert!(err.contains("解压 gzip 失败"), "got: {err}"); + assert_eq!(std::fs::read(&path).unwrap(), b"old live dataset"); + assert_no_candidate_artifacts(&path); +} + +#[test] +fn update_data_preserves_live_dataset_when_candidate_is_corrupt() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.sqlite"); + std::fs::write(&path, b"old live dataset").unwrap(); + let database = compatible_database_bytes(|conn| { + conn.execute("CREATE TABLE quick_check_probe (value TEXT)", []) + .unwrap(); + let meta_rootpage: i64 = conn + .query_row( + "SELECT rootpage FROM sqlite_schema WHERE name = 'meta'", + [], + |row| row.get(0), + ) + .unwrap(); + conn.execute_batch("PRAGMA writable_schema = ON").unwrap(); + conn.execute( + "UPDATE sqlite_schema SET rootpage = ?1 WHERE name = 'quick_check_probe'", + [meta_rootpage], + ) + .unwrap(); + conn.execute_batch("PRAGMA writable_schema = OFF").unwrap(); + }); + let gz = gzip_bytes(&database); + let sha = sha256_hex(&gz); + + let err = serve_update(&path, gz, &sha).unwrap_err().to_string(); + + assert!(err.contains("PRAGMA quick_check failed"), "got: {err}"); + assert_eq!(std::fs::read(&path).unwrap(), b"old live dataset"); + assert_no_candidate_artifacts(&path); +} + +#[test] +fn update_data_rejects_candidate_with_empty_external_content_fts_index() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.sqlite"); + std::fs::write(&path, b"old live dataset").unwrap(); + let database = compatible_database_bytes(|conn| { + conn.execute( + "INSERT INTO parallels(zh_text, zh_norm, foreign_lang, foreign_text) \ + VALUES ('色即是空', '色即是空', 'sa', 'rupam sunyata')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO parallels_fts(parallels_fts) VALUES('delete-all')", + [], + ) + .unwrap(); + }); + let gz = gzip_bytes(&database); + let sha = sha256_hex(&gz); + + let err = serve_update(&path, gz, &sha).unwrap_err().to_string(); + + assert!(err.contains("FTS5 integrity-check"), "got: {err}"); + assert_eq!(std::fs::read(&path).unwrap(), b"old live dataset"); + assert_no_candidate_artifacts(&path); +} + +#[test] +fn update_data_preserves_foreign_candidate_artifacts_and_cleans_its_own() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.sqlite"); + std::fs::write(&path, b"old live dataset").unwrap(); + let foreign_candidate = sibling_path(&path, ".candidate.999999.42"); + for suffix in ["", "-journal", "-shm", "-wal"] { + std::fs::write(sibling_path(&foreign_candidate, suffix), b"foreign").unwrap(); + } + let database = compatible_database_bytes(|conn| { + conn.execute("UPDATE meta SET value = 'v0' WHERE key = 'version'", []) + .unwrap(); + }); + let gz = gzip_bytes(&database); + let sha = sha256_hex(&gz); + + serve_update(&path, gz, &sha).unwrap_err(); + + assert_eq!(std::fs::read(&path).unwrap(), b"old live dataset"); + for suffix in ["", "-journal", "-shm", "-wal"] { + assert_eq!( + std::fs::read(sibling_path(&foreign_candidate, suffix)).unwrap(), + b"foreign" + ); + } + let candidate_prefix = format!("{}.candidate.", path.file_name().unwrap().to_string_lossy()); + let remaining: std::collections::BTreeSet<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .filter(|name| name.to_string_lossy().starts_with(&candidate_prefix)) + .collect(); + let expected: std::collections::BTreeSet<_> = ["", "-journal", "-shm", "-wal"] + .into_iter() + .map(|suffix| { + sibling_path(&foreign_candidate, suffix) + .file_name() + .unwrap() + .to_os_string() + }) + .collect(); + assert_eq!(remaining, expected); +} + +#[test] +fn update_data_replaces_live_dataset_without_backup_path_dependency() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.sqlite"); + std::fs::write(&path, b"old live dataset").unwrap(); + let backup_sentinel = dir.path().join("data.sqlite.backup"); + std::fs::create_dir(&backup_sentinel).unwrap(); + + let database = compatible_database_bytes(|conn| { + conn.execute( + "INSERT INTO meta(key, value) VALUES ('marker', 'replacement')", + [], + ) + .unwrap(); + }); + let gz = gzip_bytes(&database); + let sha = sha256_hex(&gz); + + serve_update(&path, gz, &sha).unwrap(); + + let conn = open_read_only_db(&path).unwrap(); + verify_dataset(&conn).unwrap(); + let marker: String = conn + .query_row("SELECT value FROM meta WHERE key = 'marker'", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(marker, "replacement"); + assert!(backup_sentinel.is_dir()); + assert_no_candidate_artifacts(&path); +} + +fn replacement_database_bytes() -> Vec { + compatible_database_bytes(|conn| { + conn.execute( + "INSERT INTO meta(key, value) VALUES ('marker', 'replacement')", + [], + ) + .unwrap(); + }) +} + +fn assert_replacement_marker(path: &std::path::Path) { + let conn = open_read_only_db(path).unwrap(); + verify_dataset(&conn).unwrap(); + let marker: String = conn + .query_row("SELECT value FROM meta WHERE key = 'marker'", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(marker, "replacement"); +} + +fn assert_no_candidate_artifacts(path: &std::path::Path) { + let candidate = sibling_path(path, ".candidate"); + for suffix in ["", "-journal", "-shm", "-wal"] { + let artifact = sibling_path(&candidate, suffix); + assert!( + !artifact.exists(), + "artifact remains: {}", + artifact.display() + ); + } + assert_no_owned_candidate_artifacts(path); +} + +fn assert_no_owned_candidate_artifacts(path: &std::path::Path) { + let mut prefix = path.file_name().unwrap().to_os_string(); + prefix.push(".candidate."); + let prefix = prefix.to_string_lossy(); + let owned: Vec<_> = std::fs::read_dir(path.parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .filter(|name| name.to_string_lossy().starts_with(prefix.as_ref())) + .collect(); + assert!( + owned.is_empty(), + "owned candidate artifacts remain: {owned:?}" + ); +} + +fn sibling_path(path: &std::path::Path, suffix: &str) -> PathBuf { + let mut name = path.file_name().unwrap().to_os_string(); + name.push(suffix); + path.with_file_name(name) +} + +fn compatible_database_bytes(configure: impl FnOnce(&rusqlite::Connection)) -> Vec { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("candidate.sqlite"); + let conn = rusqlite::Connection::open(&path).unwrap(); + fojin_cli::schema::init_schema(&conn).unwrap(); + insert_compat_meta(&conn); + configure(&conn); + drop(conn); + std::fs::read(path).unwrap() +} + +fn gzip_bytes(bytes: &[u8]) -> Vec { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() +} + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut hash = Sha256::new(); + hash.update(bytes); + hash.finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn serve_update( + path: &std::path::Path, + body: Vec, + expected_sha256: &str, +) -> anyhow::Result<()> { + serve_update_response(path, "200 OK", body, expected_sha256) +} + +fn serve_update_response( + path: &std::path::Path, + status: &'static str, + body: Vec, + expected_sha256: &str, +) -> anyhow::Result<()> { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0u8; 4096]; + let _ = std::io::Read::read(&mut stream, &mut request); + let head = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len(), + ); + stream.write_all(head.as_bytes()).unwrap(); + let _ = stream.write_all(&body); + }); + let source_url = format!("http://127.0.0.1:{port}/data.gz"); + let source = DataSource { + url: &source_url, + sha256: expected_sha256, + }; + + let result = update_data(path, &source); + server.join().unwrap(); + result +} + +fn compatible_conn() -> rusqlite::Connection { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + fojin_cli::schema::init_schema(&conn).unwrap(); + insert_compat_meta(&conn); + conn +} + +fn insert_compat_meta(conn: &rusqlite::Connection) { + for (key, value) in [ + ("version", EXPECTED_DATA_VERSION), + ("norm_ruleset", EXPECTED_NORM_RULESET), + ] { + conn.execute( + "INSERT INTO meta(key,value) VALUES (?1,?2)", + rusqlite::params![key, value], + ) + .unwrap(); + } +}