diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a663a4a..3a43c15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,10 @@ jobs: - uses: taiki-e/install-action@cargo-deny - uses: taiki-e/install-action@typos + - uses: actions/setup-node@v4 + with: + node-version: '22.12' + - uses: oven-sh/setup-bun@v2 with: bun-version: latest diff --git a/Cargo.lock b/Cargo.lock index b7ff4cf..1716a82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -133,6 +142,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -246,7 +264,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 8.0.0", "num-rational", "v_frame", ] @@ -260,6 +278,30 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "base64" version = "0.21.7" @@ -823,38 +865,14 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", + "darling_core", + "darling_macro", ] [[package]] @@ -870,24 +888,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.117", -] - [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", "syn 2.0.117", ] @@ -1022,6 +1029,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -1188,6 +1204,20 @@ dependencies = [ "hashbrown 0.13.2", ] +[[package]] +name = "fast_image_resize" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12dd43e5011e8d8411a3215a0d57a2ec5c68282fb90eb5d7221fab0113442174" +dependencies = [ + "bytemuck", + "cfg-if", + "document-features", + "image", + "num-traits", + "thiserror 2.0.18", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1520,6 +1550,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "geo-types" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +dependencies = [ + "approx", + "num-traits", + "serde", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1577,6 +1618,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gio" version = "0.18.4" @@ -1948,12 +1995,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1961,9 +2009,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1974,9 +2022,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1988,15 +2036,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -2008,15 +2056,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -2062,9 +2110,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -2080,8 +2128,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.15", + "zune-core", + "zune-jpeg", ] [[package]] @@ -2193,12 +2241,28 @@ dependencies = [ "serde", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "iso6709parse" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5090db9c6a716d1f4eeb729957e889e9c28156061c825cbccd44950cf0f3c66" +dependencies = [ + "geo-types", + "nom 7.1.3", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2325,15 +2389,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "kamadak-exif" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077" -dependencies = [ - "mutate_once", -] - [[package]] name = "keyboard-types" version = "0.7.0" @@ -2445,6 +2500,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.16" @@ -2477,6 +2538,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2583,6 +2650,36 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "mime" version = "0.3.17" @@ -2599,6 +2696,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2623,9 +2726,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.11" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -2680,12 +2783,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "mutate_once" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" - [[package]] name = "ndk" version = "0.9.0" @@ -2749,6 +2846,16 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -2758,6 +2865,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom-exif" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e78a8215f056e78a6887b4872e61bab5690dec4648b37396b6721be6f89c4ea" +dependencies = [ + "bytes", + "chrono", + "iso6709parse", + "nom 7.1.3", + "regex", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -2877,6 +2999,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3035,6 +3158,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3053,6 +3185,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "pango" version = "0.18.3" @@ -3113,12 +3251,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" -[[package]] -name = "path-slash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42" - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3129,14 +3261,13 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" name = "perima" version = "0.6.4" dependencies = [ - "anyhow", "chrono", "clap", "ctrlc", "directories", - "dunce", "image", "insta", + "miette", "nix", "perima-core", "perima-db", @@ -3162,6 +3293,7 @@ dependencies = [ "blake3", "proptest", "serde", + "tempfile", "thiserror 2.0.18", "unicode-normalization", "uuid", @@ -3189,8 +3321,8 @@ version = "0.6.4" dependencies = [ "chrono", "directories", - "dunce", "image", + "miette", "perima-core", "perima-db", "perima-fs", @@ -3220,7 +3352,6 @@ dependencies = [ "file-id", "notify", "notify-debouncer-full", - "path-slash", "perima-core", "sysinfo", "tempfile", @@ -3249,11 +3380,13 @@ name = "perima-media" version = "0.6.4" dependencies = [ "blake3", + "chrono", + "fast_image_resize", "image", - "kamadak-exif", "mime_guess", "mp4", "mp4parse", + "nom-exif", "perima-core", "tempfile", "thiserror 2.0.18", @@ -3350,7 +3483,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" dependencies = [ "phf_shared 0.10.0", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -3360,7 +3493,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -3647,9 +3780,9 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" [[package]] name = "qoi" @@ -3718,9 +3851,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3858,9 +3991,9 @@ dependencies = [ [[package]] name = "ravif" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" dependencies = [ "avif-serialize", "imgref", @@ -4098,6 +4231,12 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -4354,9 +4493,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.17.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ "base64 0.22.1", "chrono", @@ -4373,11 +4512,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.17.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling 0.21.3", + "darling", "proc-macro2", "quote", "syn 2.0.117", @@ -4618,9 +4757,9 @@ dependencies = [ [[package]] name = "sqlite-wasm-rs" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36" dependencies = [ "cc", "js-sys", @@ -4695,6 +4834,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "swift-rs" version = "1.0.7" @@ -5080,7 +5240,7 @@ version = "2.0.0-rc.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e23657d20f2b5508d5eca5ee6bb98d77e4c13127b9049e102b5db6a63bc73665" dependencies = [ - "darling 0.23.0", + "darling", "heck 0.5.0", "proc-macro2", "quote", @@ -5170,6 +5330,26 @@ dependencies = [ "utf-8", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5221,16 +5401,16 @@ dependencies = [ [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", "half", "quick-error 2.0.1", "weezl", - "zune-jpeg 0.4.21", + "zune-jpeg", ] [[package]] @@ -5291,9 +5471,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -5430,7 +5610,7 @@ dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.1", + "winnow 1.0.2", ] [[package]] @@ -5439,7 +5619,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.1", + "winnow 1.0.2", ] [[package]] @@ -5609,9 +5789,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unarray" @@ -5672,6 +5852,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -5687,6 +5873,18 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -5738,9 +5936,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -5845,11 +6043,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen 0.46.0", + "wit-bindgen 0.57.1", ] [[package]] @@ -5975,9 +6173,9 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ "phf 0.13.1", "phf_codegen 0.13.1", @@ -6567,9 +6765,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ "memchr", ] @@ -6584,12 +6782,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -6599,6 +6791,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -6858,12 +7056,6 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - [[package]] name = "zune-core" version = "0.5.1" @@ -6879,20 +7071,11 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core 0.5.1", + "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index 6acbcd7..79c589f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ print_stderr = "warn" # propagate correctly via the workspace graph. perima-media = { path = "crates/media", version = "0.6.3" } anyhow = "1" +miette = { version = "7", features = ["fancy"] } thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -63,16 +64,19 @@ notify = "8.2" notify-debouncer-full = "0.7" sysinfo = "0.38" directories = "6" +# unicode-normalization: KEPT only for tag NFC normalization in crates/core/src/tag.rs. +# DO NOT use in path code — path equivalence is handled via fs::canonicalize round-trip +# (see crates/core/tests/props_path_nfc_equivalence.rs). Strategy review tracked in +# GH issue #112. unicode-normalization = "0.1" -path-slash = "0.2" dunce = "1" file-id = "0.2" -tauri = { version = "2", features = ["protocol-asset"] } +tauri = { version = "~2.10", features = ["protocol-asset"] } tauri-specta = { version = "=2.0.0-rc.24", features = ["derive", "typescript"] } specta = "=2.0.0-rc.24" specta-typescript = "0.0.11" -tauri-build = "2" -tauri-plugin-dialog = "2" +tauri-build = "~2.5" +tauri-plugin-dialog = "~2.7" clap = { version = "4", features = ["derive"] } tempfile = "3" insta = { version = "1", features = ["yaml", "filters"] } @@ -81,7 +85,7 @@ rayon = "1" ctrlc = { version = "3", features = ["termination"] } chrono = { version = "0.4", features = ["serde"] } image = "0.25" -kamadak-exif = "0.5" +nom-exif = "2.7" mp4parse = "0.17" mime_guess = "2" diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock index 33c7138..9d7860b 100644 --- a/apps/desktop/bun.lock +++ b/apps/desktop/bun.lock @@ -12,16 +12,22 @@ "react-dom": "^19", }, "devDependencies": { + "@babel/core": "^7", + "@babel/plugin-syntax-jsx": "^7", + "@babel/preset-typescript": "^7", "@evilmartians/lefthook": "^2.1.6", + "@rolldown/plugin-babel": "^0.2.3", "@tailwindcss/vite": "^4", "@tauri-apps/cli": "^2", "@testing-library/jest-dom": "^6", "@testing-library/react": "^16", + "@types/babel__core": "^7", "@types/react": "^19", "@types/react-dom": "^19", "@typescript-eslint/eslint-plugin": "^8.58.2", "@typescript-eslint/parser": "^8.58.2", - "@vitejs/plugin-react": "^4", + "@vitejs/plugin-react": "^6", + "babel-plugin-react-compiler": "=1.0.0", "eslint": "^9", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", @@ -30,8 +36,8 @@ "jsdom": "^26", "tailwindcss": "^4", "typescript": "^5", - "vite": "^6", - "vitest": "^3", + "vite": "^8", + "vitest": "^3.2", }, }, }, @@ -48,16 +54,28 @@ "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="], + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], @@ -68,9 +86,15 @@ "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], @@ -90,6 +114,12 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -184,13 +214,49 @@ "@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.17.1", "", { "dependencies": { "@microsoft/tsdoc": "0.15.1", "ajv": "~8.12.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="], + + "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.3", "", { "dependencies": { "picomatch": "^4.0.4" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], @@ -306,6 +372,8 @@ "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -348,7 +416,7 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], @@ -402,6 +470,8 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g=="], @@ -834,8 +904,6 @@ "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], - "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], @@ -850,6 +918,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rolldown": ["rolldown@1.0.0-rc.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="], + "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], @@ -950,6 +1020,8 @@ "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], @@ -968,7 +1040,7 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + "vite": ["vite@8.0.9", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="], "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], @@ -1058,8 +1130,14 @@ "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "vite-node/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + + "vitest/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + "@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], diff --git a/apps/desktop/eslint.config.js b/apps/desktop/eslint.config.js index a58843d..c8a6a59 100644 --- a/apps/desktop/eslint.config.js +++ b/apps/desktop/eslint.config.js @@ -103,7 +103,7 @@ export default [ // WHY: react-hooks v7 adds many new experimental rules. The classic two // (rules-of-hooks, exhaustive-deps) are the critical ones; downgrade the // rest to warn until we've audited the codebase against v7's semantics. - // Follow-up: https://github.com/utof/perima (file issue for each warn below) + // Follow-up: see GH issue #116 for the audit plan. "react-hooks/static-components": "warn", "react-hooks/use-memo": "warn", "react-hooks/void-use-memo": "warn", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bacdd5b..039bee4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -3,6 +3,7 @@ "private": true, "version": "0.6.3", "type": "module", + "packageManager": "bun@1.3.11", "scripts": { "dev": "vite", "build": "tsc -b && vite build", @@ -11,6 +12,9 @@ "preview": "vite preview", "tauri": "tauri" }, + "engines": { + "node": ">=20.19 <21 || >=22.12" + }, "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", @@ -24,11 +28,17 @@ "@tauri-apps/cli": "^2", "@testing-library/jest-dom": "^6", "@testing-library/react": "^16", + "@types/babel__core": "^7", "@types/react": "^19", "@types/react-dom": "^19", "@typescript-eslint/eslint-plugin": "^8.58.2", "@typescript-eslint/parser": "^8.58.2", - "@vitejs/plugin-react": "^4", + "@babel/core": "^7", + "@babel/plugin-syntax-jsx": "^7", + "@babel/preset-typescript": "^7", + "@rolldown/plugin-babel": "^0.2.3", + "@vitejs/plugin-react": "^6", + "babel-plugin-react-compiler": "=1.0.0", "eslint": "^9", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", @@ -37,7 +47,7 @@ "jsdom": "^26", "tailwindcss": "^4", "typescript": "^5", - "vite": "^6", - "vitest": "^3" + "vite": "^8", + "vitest": "^3.2" } } diff --git a/apps/desktop/src/__tests__/compiler-canary.test.ts b/apps/desktop/src/__tests__/compiler-canary.test.ts new file mode 100644 index 0000000..f5b2a32 --- /dev/null +++ b/apps/desktop/src/__tests__/compiler-canary.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import * as babel from "@babel/core"; +// WHY: @babel/plugin-syntax-jsx is needed so preset-typescript (which handles +// TypeScript stripping) doesn't reject JSX tokens — the Compiler itself requires +// real JSX in the source to recognise a React component and apply memoization. +import babelPluginSyntaxJsx from "@babel/plugin-syntax-jsx"; + +// WHY: Spec-mandated Compiler-activation canary. Runs babel-plugin-react-compiler +// directly in `annotation` mode on a known-good snippet, bypasses the Vite build +// pipeline entirely, and asserts the transformed source imports from +// `react/compiler-runtime` (React 19's built-in runtime path). +// +// This is stable against minifier renames (which is why the spec rejects +// bundle-grep as the primary verification). +// +// WHY "use memo" inside the function body (not at file level): the React Compiler +// treats "use memo" as a function-level directive (like "use strict") — placing +// it at the program level is parsed but silently ignored by the Compiler's +// annotation-mode opt-in logic. File-level opt-in requires `compilationMode: "all"`. +const CANARY_SOURCE = ` + import { useState } from "react"; + export function Canary() { + "use memo"; + const [n, setN] = useState(0); + return
{n}
; + } +`; + +describe("react-compiler transform", () => { + it("emits react/compiler-runtime import for a 'use memo' annotated component", async () => { + const result = await babel.transformAsync(CANARY_SOURCE, { + filename: "canary.tsx", + presets: [ + ["@babel/preset-typescript", { allExtensions: true, isTSX: true }], + ], + plugins: [ + babelPluginSyntaxJsx, + [ + "babel-plugin-react-compiler", + { + // WHY: keep in sync with vite.config.ts's reactCompilerPreset target option; + // a silent drift would assert the wrong runtime import path. + target: "19", + compilationMode: "annotation", + }, + ], + ], + babelrc: false, + configFile: false, + }); + expect(result?.code).toMatch(/from ["']react\/compiler-runtime["']/); + }); +}); diff --git a/apps/desktop/src/vendor.d.ts b/apps/desktop/src/vendor.d.ts new file mode 100644 index 0000000..84d61de --- /dev/null +++ b/apps/desktop/src/vendor.d.ts @@ -0,0 +1,10 @@ +// WHY: @babel/plugin-syntax-jsx ships no TypeScript declarations and no +// @types/babel__plugin-syntax-jsx exists on DefinitelyTyped. Declaring the +// module here satisfies tsc without pinning to `any` across the whole project. +// The plugin is used only in compiler-canary.test.ts as a Babel plugin handle; +// typed as `object` to match @types/babel__core's PluginTarget union, which +// is what babel.transformAsync's `plugins` array ultimately accepts. +declare module "@babel/plugin-syntax-jsx" { + const plugin: object; + export default plugin; +} diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index e463676..9f3ccc2 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,10 +1,29 @@ import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; +import react, { reactCompilerPreset } from "@vitejs/plugin-react"; +import babel from "@rolldown/plugin-babel"; import tailwindcss from "@tailwindcss/vite"; // WHY: Tailwind 4 uses the Vite plugin (not PostCSS) — no tailwind.config.js needed. +// WHY: React Compiler 1.0 runs via a separate `@rolldown/plugin-babel` pass +// configured with `reactCompilerPreset()` exported by @vitejs/plugin-react. +// This is the canonical wiring for plugin-react v6 (see the v6 README). +// Compiler auto-memoizes components; DO NOT add manual useMemo/useCallback +// for new code. See AGENTS.md "Frontend / React Compiler" pin. +// WHY: target: '19' is pinned explicitly even though '19' is the current +// default — relying on the implicit default is a silent-failure hazard. +// A future react-compiler minor could flip the default, switching our +// runtime emit from `react/compiler-runtime` (React 19 built-in) to +// `react-compiler-runtime` (17/18 polyfill) and defeating the Step 4 +// grep canary. Explicit pin costs one line and eliminates that class. export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [ + react(), + // WHY: panicThreshold defaults to `none` — Compiler silently skips bailout + // components rather than failing the build; acceptable for a greenfield + // frontend where the component surface is fully under our control. + babel({ presets: [reactCompilerPreset({ target: "19" })] }), + tailwindcss(), + ], server: { port: 5173, // WHY: Fail fast if port is taken; Tauri dev expects exactly 5173. @@ -18,8 +37,8 @@ export default defineConfig({ environment: "jsdom", setupFiles: ["src/__tests__/setup.ts"], globals: true, - // WHY: tsc composite mode emits to dist-types; exclude it so vitest - // doesn't double-run the compiled JS alongside the original TSX. + // WHY: tsc composite mode emits to dist-types; exclude it so vitest doesn't + // double-run the compiled JS alongside the original TSX. exclude: ["dist-types/**", "node_modules/**"], include: ["src/**/*.{test,spec}.{ts,tsx}"], }, diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 900b416..1c314cc 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -22,14 +22,13 @@ serde_json.workspace = true serde.workspace = true clap.workspace = true -anyhow.workspace = true +miette.workspace = true tracing.workspace = true tracing-subscriber.workspace = true directories.workspace = true ctrlc.workspace = true rayon.workspace = true uuid.workspace = true -dunce.workspace = true tokio.workspace = true tokio-util.workspace = true diff --git a/crates/cli/src/cmd/metadata.rs b/crates/cli/src/cmd/metadata.rs index 5c50850..8a1e0d4 100644 --- a/crates/cli/src/cmd/metadata.rs +++ b/crates/cli/src/cmd/metadata.rs @@ -54,7 +54,8 @@ pub(crate) async fn run( args: &MetadataArgs, ) -> Result<(), CoreError> { validate_file(&args.path)?; - let absolute_path = dunce::canonicalize(&args.path).map_err(CoreError::Io)?; + let absolute_path = + perima_fs::platform_path::canonicalize(&args.path).map_err(CoreError::Io)?; // Resolve volume from the containing directory. WHY parent(): volume // detection inspects the mount point; a file path's parent is the diff --git a/crates/cli/src/cmd/scan.rs b/crates/cli/src/cmd/scan.rs index c6005d7..35982ab 100644 --- a/crates/cli/src/cmd/scan.rs +++ b/crates/cli/src/cmd/scan.rs @@ -443,6 +443,7 @@ fn validate_root(root: &Path) -> Result<(), CoreError> { } fn canonicalize_for_walk(root: &Path) -> Result { - // dunce::canonicalize avoids UNC prefixes on Windows. - dunce::canonicalize(root).map_err(CoreError::Io) + // WHY: routes through perima_fs::platform_path::canonicalize — the single + // source of truth for the #[cfg(windows)] dunce / std fallback. + perima_fs::platform_path::canonicalize(root).map_err(CoreError::Io) } diff --git a/crates/cli/src/cmd/tag.rs b/crates/cli/src/cmd/tag.rs index f9a2956..76b7276 100644 --- a/crates/cli/src/cmd/tag.rs +++ b/crates/cli/src/cmd/tag.rs @@ -6,7 +6,7 @@ //! - `tag ls [--json]` — list all active tags with attachment counts use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use perima_core::{BlakeHash, CoreError, DeviceId, FileRepository, TagRepository, normalize_tag}; @@ -80,7 +80,7 @@ where /// WHY separate helper: both `run_add` and `run_rm` need the same /// canonicalization + suffix-match logic. Extracting it avoids /// duplicating the error messages and the `list_file_locations` call. -fn resolve_hash(file_repo: &F, path: &PathBuf) -> Result +fn resolve_hash(file_repo: &F, path: &Path) -> Result where F: FileRepository + ?Sized, { @@ -97,7 +97,7 @@ where ))); } - let absolute = dunce::canonicalize(path).map_err(CoreError::Io)?; + let absolute = perima_fs::platform_path::canonicalize(path).map_err(CoreError::Io)?; let absolute_str = absolute .to_str() .ok_or_else(|| CoreError::InvalidPath(format!("non-UTF8 path: {}", absolute.display())))?; @@ -122,7 +122,7 @@ fn run_add( tag_repo: &T, file_repo: &F, device: DeviceId, - path: &PathBuf, + path: &Path, tags: &[String], ) -> Result<(), CoreError> where @@ -154,7 +154,7 @@ fn run_rm( tag_repo: &T, file_repo: &F, device: DeviceId, - path: &PathBuf, + path: &Path, tag_raw: &str, ) -> Result<(), CoreError> where diff --git a/crates/cli/src/cmd/watch.rs b/crates/cli/src/cmd/watch.rs index b06cc6c..0406de0 100644 --- a/crates/cli/src/cmd/watch.rs +++ b/crates/cli/src/cmd/watch.rs @@ -163,9 +163,9 @@ fn validate_root(root: &Path) -> Result<(), CoreError> { } fn canonicalize(root: &Path) -> Result { - // WHY dunce: avoids UNC prefixes on Windows while behaving identically - // on Unix. - dunce::canonicalize(root).map_err(CoreError::Io) + // WHY: routes through perima_fs::platform_path::canonicalize — the single + // source of truth for the #[cfg(windows)] dunce / std fallback. + perima_fs::platform_path::canonicalize(root).map_err(CoreError::Io) } // --------------------------------------------------------------------------- diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 012bca9..4384a56 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -16,6 +16,7 @@ unicode-normalization.workspace = true [dev-dependencies] proptest.workspace = true blake3.workspace = true +tempfile.workspace = true [lints] workspace = true diff --git a/crates/core/src/tag.rs b/crates/core/src/tag.rs index 5aec789..d712f3e 100644 --- a/crates/core/src/tag.rs +++ b/crates/core/src/tag.rs @@ -1,6 +1,11 @@ //! Tag value type + name normalization. use serde::{Deserialize, Serialize}; +// WHY: `unicode-normalization` is KEPT in the workspace solely for +// this tag-name NFC normalization. Path code (MediaPath) no longer +// uses it post-L5 (see GH issue #113). See GH issue +// #112 for the long-term tag normalization question (is NFC +// load-bearing, or handled by SQLite collation?). use unicode_normalization::UnicodeNormalization; use uuid::Uuid; diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 092d2fe..fa00072 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -112,27 +112,37 @@ const fn parse_nibble(b: u8) -> Option { #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] pub struct FileSize(pub u64); -/// Path relative to a volume root. NFC-normalized, forward-slash, -/// no leading slash. The constructor is *idempotent* AND makes -/// canonically-equivalent inputs compare equal (NFC = NFD). +/// Path relative to a volume root. Forward-slash, no leading slash. /// -/// WHY: the combination of (NFC normalization + forward-slash -/// conversion + leading-slash strip) in one pass is what makes -/// the constructor simultaneously idempotent AND case-canonical -/// under Unicode equivalence. Splitting these into separate -/// passes would preserve idempotence but break equivalence -/// (NFC-then-slash-fix would still differ from slash-fix-then-NFC -/// on edge cases involving combining marks inside path segments). +/// The constructor is *idempotent* over lexical form (double-slash and +/// leading-slash inputs produce the same bytes on re-construction). +/// It does NOT attempt Unicode canonical-equivalence; two `MediaPaths` +/// constructed from NFC and NFD spellings of the same visual filename +/// will compare unequal by byte. +/// +/// WHY the NFC guarantee was removed (see lib-audit L5): macOS NFD +/// vs NFC equivalence is better handled at the filesystem layer by +/// round-tripping the OS's own normalization via a canonicalize call +/// (in perima, `dunce::canonicalize` which delegates to +/// `std::fs::canonicalize` on non-Windows and strips `\\?\` prefixes +/// on Windows). Production `MediaPaths` are constructed downstream +/// of a canonicalized walk (see +/// `crates/cli/src/cmd/scan.rs::canonicalize_for_walk` and peers), +/// so they receive platform-consistent bytes. Callers constructing +/// `MediaPaths` from user-typed strings (rare in production; this +/// type is typically machine-derived from canonicalized walk output) +/// accept the byte-exact-match contract. #[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] pub struct MediaPath(String); impl MediaPath { /// Construct a normalized `MediaPath` from a raw string. + /// + /// Converts `\\` to `/` and strips leading `/`. Does NOT perform + /// Unicode normalization — see struct-level doc. #[must_use] pub fn new(raw: &str) -> Self { - use unicode_normalization::UnicodeNormalization; - let nfc: String = raw.nfc().collect(); - let slashed = nfc.replace('\\', "/"); + let slashed = raw.replace('\\', "/"); let trimmed = slashed.trim_start_matches('/').to_owned(); Self(trimmed) } @@ -187,7 +197,12 @@ impl Default for DeviceId { pub struct DiscoveredFile { /// Absolute path as observed during the walk. pub absolute_path: PathBuf, - /// Path relative to the volume root, NFC-normalized. + /// Path relative to the volume root. Lexical normalization only + /// (forward-slash, no leading slash). NFC normalization is a + /// filesystem-layer concern handled upstream by the canonicalized + /// walk; see `MediaPath`'s struct-level doc and + /// `crates/core/tests/props_path_nfc_equivalence.rs` for the + /// mechanism. pub relative_path: MediaPath, /// File size in bytes at walk time. pub size: FileSize, @@ -336,11 +351,16 @@ mod tests { } #[test] - fn media_path_nfc_equivalence_fixed() { - // "café" — precomposed (NFC) vs decomposed (NFD). - let precomposed = "caf\u{00E9}"; - let decomposed = "cafe\u{0301}"; - assert_eq!(MediaPath::new(precomposed), MediaPath::new(decomposed)); + fn media_path_no_longer_nfc_collapses() { + // Post-L5: MediaPath::new is lexical-only. NFC and NFD spellings + // of "café" produce byte-distinct MediaPaths. Equivalence across + // Unicode forms is a filesystem-layer concern (fs::canonicalize), + // not a MediaPath::new concern. + let precomposed = "caf\u{00E9}"; // NFC, 5 bytes + let decomposed = "cafe\u{0301}"; // NFD, 6 bytes + assert_ne!(MediaPath::new(precomposed), MediaPath::new(decomposed)); + // But identical spellings still collapse. + assert_eq!(MediaPath::new(precomposed), MediaPath::new(precomposed)); } #[test] diff --git a/crates/core/tests/props_path_nfc_equivalence.rs b/crates/core/tests/props_path_nfc_equivalence.rs index edcdbfe..0b482fe 100644 --- a/crates/core/tests/props_path_nfc_equivalence.rs +++ b/crates/core/tests/props_path_nfc_equivalence.rs @@ -1,18 +1,76 @@ -//! Property: `MediaPath::new` collapses canonically-equivalent -//! inputs. Applying NFD decomposition before construction must -//! yield the same `MediaPath`. +//! macOS-only test: `fs::canonicalize` returns the stored dirent name +//! for any NFC/NFD lookup spelling of the same file. This replaces +//! the pre-L5 proptest that asserted +//! `MediaPath::new(NFC) == MediaPath::new(NFD)` — see +//! `crates/core/src/types.rs` `MediaPath` doc for the invariant shift. +//! +//! # Mechanism (reviewer-verified) +//! +//! On default macOS APFS and HFS+ volumes the VFS layer is +//! normalization-insensitive: +//! - HFS+ stores NFD; any NFC or NFD lookup resolves to the stored +//! NFD entry. +//! - APFS preserves whatever bytes the caller wrote at creation; the +//! macOS VFS layer handles NFC/NFD equivalence at lookup time, so +//! both NFC and NFD spellings of a given file resolve to the one +//! stored dirent. (Case-sensitive APFS variants can change this — +//! out of scope for perima's tooling.) +//! +//! `realpath(3)` — which `std::fs::canonicalize` calls — returns the +//! stored dirent name (whichever form the file was written under). So +//! `canonicalize(NFC_lookup)` and `canonicalize(NFD_lookup)` return +//! the SAME string (the stored one) on both filesystems. +//! +//! On Linux ext4: byte-exact, no normalization; `canonicalize(NFD)` +//! of an NFC-stored file FAILS. We don't attempt the equivalence +//! there. +//! +//! On Windows NTFS: the Win32 API layer applies some normalization +//! but the behavior is hand-wavy across NT versions + drivers. We +//! skip Windows rather than assert a weaker claim that might mislead +//! a debugger. +//! +//! Off-macOS this file contains only a trivial byte-identity test +//! (`media_path_byte_identity_off_macos` below) — the real NFC/NFD +//! coverage runs only under macOS CI. The off-macOS branch exists +//! to keep the test file non-empty on Linux + Windows; it asserts +//! properties already covered by `props_path_idempotence.rs` and +//! the inline tests in `crates/core/src/types.rs`. -use perima_core::MediaPath; -use proptest::prelude::*; -use unicode_normalization::UnicodeNormalization; +#[cfg(target_os = "macos")] +#[test] +#[allow(clippy::similar_names)] // NFC/NFD are domain terms of art — intentional +fn fs_canonicalize_resolves_nfc_and_nfd_lookups_to_same_stored_name() { + use std::fs; + let dir = tempfile::tempdir().expect("tempdir"); + // Write the fixture under an NFC-spelled filename. + let nfc_name = "caf\u{00E9}.txt"; + let nfc_path = dir.path().join(nfc_name); + fs::write(&nfc_path, b"hello").expect("write NFC fixture"); -proptest! { - #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })] - #[test] - fn media_path_nfc_equivalence(s in "\\PC{0,200}") { - let nfd: String = s.nfd().collect(); - let from_original = MediaPath::new(&s); - let from_nfd = MediaPath::new(&nfd); - prop_assert_eq!(from_original, from_nfd); - } + // Look up the same file via its NFD spelling. Both APFS + HFS+ + // resolve this to the SAME stored dirent. + let nfd_name = "cafe\u{0301}.txt"; + let nfd_path = dir.path().join(nfd_name); + + let canon_nfc = fs::canonicalize(&nfc_path).expect("canonicalize NFC"); + let canon_nfd = fs::canonicalize(&nfd_path).expect("canonicalize NFD on macOS"); + assert_eq!( + canon_nfc, canon_nfd, + "macOS realpath should return the same stored dirent name for NFC + NFD lookups" + ); +} + +#[cfg(not(target_os = "macos"))] +#[test] +fn media_path_byte_identity_off_macos() { + // On Linux + Windows we don't exercise fs::canonicalize NFC/NFD + // equivalence — Linux has none (byte-exact ext4), Windows is + // hand-wavy across NT versions. The weaker invariant tested here: + // MediaPath::new is deterministic byte-identity under identical + // input. NFC-equivalence is no longer MediaPath's job. + use perima_core::MediaPath; + let s = "caf\u{00E9}/file.txt"; + assert_eq!(MediaPath::new(s), MediaPath::new(s)); + assert_eq!(MediaPath::new(s).as_str(), "caf\u{00E9}/file.txt"); } diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml index 3eb26fc..4ebd785 100644 --- a/crates/desktop/Cargo.toml +++ b/crates/desktop/Cargo.toml @@ -20,6 +20,7 @@ perima-db = { path = "../db" } perima-fs = { path = "../fs" } perima-hash = { path = "../hash" } perima-media.workspace = true +miette.workspace = true tauri.workspace = true tauri-specta.workspace = true specta.workspace = true @@ -31,7 +32,6 @@ tracing.workspace = true uuid.workspace = true chrono.workspace = true rayon.workspace = true -dunce.workspace = true directories.workspace = true tokio.workspace = true tokio-util.workspace = true diff --git a/crates/desktop/src/commands.rs b/crates/desktop/src/commands.rs index a75538a..b05ab2a 100644 --- a/crates/desktop/src/commands.rs +++ b/crates/desktop/src/commands.rs @@ -326,7 +326,10 @@ pub async fn run_scan_inner_with_metadata( metadata_repo: Option>, ) -> Result { validate_root(root)?; - let canonical_root = dunce::canonicalize(root).map_err(perima_core::CoreError::Io)?; + // WHY: routes through perima_fs::platform_path::canonicalize — the single + // source of truth for the #[cfg(windows)] dunce / std fallback. + let canonical_root = + perima_fs::platform_path::canonicalize(root).map_err(perima_core::CoreError::Io)?; let scanner = WalkdirScanner::new(); let hasher = Blake3Service::new(); @@ -535,7 +538,8 @@ pub async fn start_watch( ) -> Result<(), String> { let root = PathBuf::from(&path); validate_root(&root).map_err(|e| e.to_string())?; - let canonical_root = dunce::canonicalize(&root).map_err(|e| format!("canonicalize: {e}"))?; + let canonical_root = + perima_fs::platform_path::canonicalize(&root).map_err(|e| format!("canonicalize: {e}"))?; let detected = perima_fs::detect_volume(&canonical_root).map_err(|e| e.to_string())?; let db_path = state.data_dir.join("perima.db"); diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index c56fcfc..ab0f5e2 100644 --- a/crates/fs/Cargo.toml +++ b/crates/fs/Cargo.toml @@ -11,7 +11,6 @@ publish = false # WHY: intra-workspace only; not published to crates.io perima-core = { path = "../core" } thiserror.workspace = true walkdir.workspace = true -path-slash.workspace = true dunce.workspace = true tracing.workspace = true sysinfo.workspace = true diff --git a/crates/fs/src/lib.rs b/crates/fs/src/lib.rs index 78e9d7f..3b4fb09 100644 --- a/crates/fs/src/lib.rs +++ b/crates/fs/src/lib.rs @@ -4,6 +4,7 @@ pub mod errors; pub mod paths; +pub mod platform_path; pub mod volumes; pub mod walker; pub mod watcher; diff --git a/crates/fs/src/paths.rs b/crates/fs/src/paths.rs index 37e2224..84f90c5 100644 --- a/crates/fs/src/paths.rs +++ b/crates/fs/src/paths.rs @@ -12,20 +12,40 @@ use crate::errors::Error; /// `absolute` must be under `volume_root`; callers should pre- /// canonicalize both sides to avoid symlink surprises. /// +/// Only `Path::Component::Normal` segments are preserved; `..` +/// (`ParentDir`) and `.` (`CurDir`) are silently dropped during the +/// components-iterator join. Callers must pre-resolve such components +/// if semantic preservation is required — all current callers feed +/// canonicalized OS paths where `..`/`.` cannot survive. +/// /// # Errors /// Returns `Error::NotUnderVolume` if `absolute` is not prefixed by /// `volume_root`. pub fn relativize(absolute: &Path, volume_root: &Path) -> Result { - let abs = dunce::simplified(absolute); - let root = dunce::simplified(volume_root); + use std::path::Component; + + let abs = crate::platform_path::simplified(absolute); + let root = crate::platform_path::simplified(volume_root); let rel = abs .strip_prefix(root) .map_err(|_| Error::NotUnderVolume(absolute.to_path_buf()))?; - // WHY: convert to forward-slash explicitly before handing to - // MediaPath so our Windows test cases normalize consistently - // on non-Windows hosts. - let as_str = path_slash::PathExt::to_slash_lossy(rel); - Ok(MediaPath::new(as_str.as_ref())) + + // WHY: iterate Normal components and join with '/'. This handles + // Windows (backslash separator), Unix (forward slash), AND Windows + // UNC/drive-letter prefixes correctly — Prefix/RootDir/CurDir/ + // ParentDir components are skipped by the filter, so there's no + // leading `//` to fight MediaPath::new's trim_start_matches('/'). + // Equivalent on Unix to the prior path_slash behavior + // (to_slash_lossy). + let as_str = rel + .components() + .filter_map(|c| match c { + Component::Normal(s) => Some(s.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>() + .join("/"); + Ok(MediaPath::new(&as_str)) } #[cfg(test)] @@ -53,4 +73,33 @@ mod tests { let r = relativize(root, root).expect("relativize root"); assert_eq!(r.as_str(), ""); } + + #[test] + fn relativize_joins_with_forward_slash_regardless_of_platform() { + let root = std::path::Path::new("/tmp/perima"); + let abs = std::path::Path::new("/tmp/perima/sub/dir/a.jpg"); + let r = relativize(abs, root).expect("relativize"); + assert_eq!(r.as_str(), "sub/dir/a.jpg"); + } + + #[test] + #[cfg(windows)] + fn relativize_windows_unc_path_produces_forward_slash_path() { + // Real UNC input — would be mangled by replace('\\',"/") → + // //server/share/... then trim_start_matches('/') collapses + // leading slashes. Components-iterator approach sidesteps this. + let root = std::path::Path::new(r"\\server\share"); + let abs = std::path::Path::new(r"\\server\share\photos\a.jpg"); + let r = relativize(abs, root).expect("relativize"); + assert_eq!(r.as_str(), "photos/a.jpg"); + } + + #[test] + #[cfg(windows)] + fn relativize_windows_drive_letter_path_produces_forward_slash_path() { + let root = std::path::Path::new(r"C:\data"); + let abs = std::path::Path::new(r"C:\data\photos\a.jpg"); + let r = relativize(abs, root).expect("relativize"); + assert_eq!(r.as_str(), "photos/a.jpg"); + } } diff --git a/crates/fs/src/platform_path.rs b/crates/fs/src/platform_path.rs new file mode 100644 index 0000000..8770462 --- /dev/null +++ b/crates/fs/src/platform_path.rs @@ -0,0 +1,49 @@ +//! Windows-scoped path-canonicalization helpers. +//! +//! `dunce` works around two Windows bugs in `std::fs::canonicalize`: +//! (1) `\\?\` UNC-prefix pollution of user-displayable paths and +//! (2) rejection of valid legacy drive-letter paths. On non-Windows +//! `dunce` is documented to pass through to `std::fs::canonicalize` / +//! identity `simplified`, so we avoid pulling it as a direct dep +//! outside crates/fs — the `#[cfg(windows)]` branch below is the +//! only place dunce is reachable from. + +use std::io; +use std::path::{Path, PathBuf}; + +/// Canonicalize a path, stripping `\\?\` on Windows. +/// +/// On non-Windows this is `std::fs::canonicalize`. +/// +/// # Errors +/// Returns [`std::io::Error`] if the path does not exist or cannot be +/// resolved by the OS. +#[inline] +pub fn canonicalize(p: &Path) -> io::Result { + #[cfg(windows)] + { + dunce::canonicalize(p) + } + #[cfg(not(windows))] + { + std::fs::canonicalize(p) + } +} + +/// Return `p` with `\\?\` prefix stripped on Windows; identity elsewhere. +// WHY allow: clippy suggests `const fn` based on the non-Windows identity +// branch, but the #[cfg(windows)] branch calls dunce::simplified which is +// not const. Silencing here is cleaner than splitting into two cfg-gated fns. +#[allow(clippy::missing_const_for_fn)] +#[inline] +#[must_use] +pub fn simplified(p: &Path) -> &Path { + #[cfg(windows)] + { + dunce::simplified(p) + } + #[cfg(not(windows))] + { + p + } +} diff --git a/crates/fs/src/volumes.rs b/crates/fs/src/volumes.rs index caf090c..ac0b4bb 100644 --- a/crates/fs/src/volumes.rs +++ b/crates/fs/src/volumes.rs @@ -21,7 +21,8 @@ pub struct DetectedVolume { /// Detect the volume that contains `path` using a longest mount-prefix match. /// -/// Canonicalizes `path` via `dunce::canonicalize`, then enumerates all +/// Canonicalizes `path` via [`crate::platform_path::canonicalize`] +/// (dunce on Windows, `std::fs::canonicalize` elsewhere), then enumerates all /// disks with [`sysinfo::Disks::new_with_refreshed_list`] and finds the /// disk whose mount point is the longest prefix of the canonicalized path. /// @@ -30,7 +31,7 @@ pub struct DetectedVolume { /// - `CoreError::Io` if `path` cannot be canonicalized (e.g. does not exist). /// - `CoreError::Internal` if no mounted disk covers the path. pub fn detect_volume(path: &Path) -> Result { - let canonical = dunce::canonicalize(path)?; + let canonical = crate::platform_path::canonicalize(path)?; let disks = Disks::new_with_refreshed_list(); // WHY: longest-prefix wins so that nested mounts (e.g. /mnt/data and diff --git a/crates/fs/src/watcher.rs b/crates/fs/src/watcher.rs index e14037e..810ca07 100644 --- a/crates/fs/src/watcher.rs +++ b/crates/fs/src/watcher.rs @@ -64,11 +64,13 @@ impl DebouncedWatcher { // strip_prefix in relativize() fails for every event and nothing // reaches the bus. Canonicalizing both the registered paths and // volume_root ensures they match whatever the OS reports in events. - // dunce::canonicalize avoids UNC prefixes on Windows. - let canonical_root = dunce::canonicalize(volume_root).map_err(CoreError::Io)?; + // crate::platform_path::canonicalize wraps dunce on Windows (avoids + // UNC prefix pollution) and std::fs::canonicalize elsewhere. + let canonical_root = + crate::platform_path::canonicalize(volume_root).map_err(CoreError::Io)?; let canonical_paths: Vec = paths .iter() - .map(|p| dunce::canonicalize(p).map_err(CoreError::Io)) + .map(|p| crate::platform_path::canonicalize(p).map_err(CoreError::Io)) .collect::>()?; // WHY std mpsc: notify-debouncer-full uses std::sync::mpsc for its diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 0cee69e..16327e5 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -9,8 +9,16 @@ publish = false # WHY: intra-workspace only; not published to crates.io [dependencies] perima-core = { path = "../core" } +# WHY crate-local (not workspace): L7 will separately update workspace deps; +# keeping this local avoids any race between the two tasks. +fast_image_resize = { version = "6", features = ["image"] } image.workspace = true -kamadak-exif.workspace = true +nom-exif.workspace = true +# WHY chrono: nom-exif's as_time_components() returns (NaiveDateTime, Option); +# we need NaiveDateTime::format for the naive path and DateTime::to_rfc3339 for the +# offset-aware path. No `use chrono::…` appears in media source files because the +# inferred types flow from nom-exif's return values — the dep is real, just implicit. +chrono.workspace = true mp4parse.workspace = true mime_guess.workspace = true tokio.workspace = true diff --git a/crates/media/src/extractor.rs b/crates/media/src/extractor.rs index 7c4f24b..1ea2d46 100644 --- a/crates/media/src/extractor.rs +++ b/crates/media/src/extractor.rs @@ -1,20 +1,19 @@ //! Metadata extractors for images and MP4/MOV video. //! //! `ImageExtractor` reads PNG/JPEG dimensions via the `image` crate and -//! EXIF tags via `kamadak-exif`. `VideoExtractor` parses `moov`-box data +//! EXIF tags via `nom-exif`. `VideoExtractor` parses `moov`-box data //! via Mozilla's `mp4parse` reader. `CompositeExtractor` dispatches to //! the first registered extractor whose [`MetadataExtractor::accepts`] //! returns `true` for the requested MIME. use std::fs::File; -use std::io::BufReader; use std::path::Path; use std::sync::Arc; use perima_core::{BlakeHash, CoreError, MediaMetadata, MetadataExtractor}; /// Image metadata extractor backed by `image` (dimensions) and -/// `kamadak-exif` (capture timestamp + camera). +/// `nom-exif` (capture timestamp + camera). /// /// Handles every `image/*` MIME the `image` crate can decode. #[derive(Clone, Copy, Debug, Default)] @@ -75,92 +74,95 @@ impl MetadataExtractor for ImageExtractor { /// Read EXIF `DateTimeOriginal`, `Make`, and `Model` from an image. /// +/// Uses `nom-exif`'s typed accessors: `as_str()` returns bare strings +/// (no display quoting). `as_time_components()` returns +/// `(NaiveDateTime, Option)`; we format naive → +/// `"YYYY-MM-DDTHH:MM:SS"`, offset-aware → RFC 3339 with offset suffix +/// via `chrono::DateTime::to_rfc3339`, bypassing the `Display` impl of +/// the `#[non_exhaustive]` `EntryValue` enum. +/// /// Returns `(None, None, None)` if the file has no EXIF segment, the /// segment is malformed, or the individual fields are absent. A missing /// EXIF block is expected for PNGs and many camera-exported JPEGs — /// treating it as an error would be noisy. Any I/O or parser error is /// traced at `debug` level. fn read_exif(path: &Path) -> (Option, Option, Option) { - let Ok(file) = File::open(path) else { - return (None, None, None); - }; - let mut reader = BufReader::new(file); - let exif = match exif::Reader::new().read_from_container(&mut reader) { - Ok(e) => e, + let ms = match nom_exif::MediaSource::file_path(path) { + Ok(ms) => ms, Err(err) => { tracing::debug!( path = %path.display(), error = %err, - "EXIF parse failed or absent", + "nom-exif: could not open file as MediaSource", ); return (None, None, None); } }; - let captured_at = exif - .get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY) - .and_then(|f| { - let raw = f.display_value().to_string(); - // `DateTimeOriginal` is "YYYY:MM:DD HH:MM:SS" per EXIF spec. - // WHY reshape: callers consume ISO 8601 throughout the DB - // (`first_seen`, `last_seen`). Converting at extraction time - // keeps storage canonical even though the timezone is - // unknown (EXIF omits zone; we annotate as naive-UTC). - exif_datetime_to_iso8601(&raw) - }); + // Early-return when the container carries no EXIF block (e.g. raw + // PNG, MP4 without embedded EXIF). This avoids the parse attempt + // and the noisy "no Exif data" error it would produce. + if !ms.has_exif() { + return (None, None, None); + } + let mut parser = nom_exif::MediaParser::new(); + let iter: nom_exif::ExifIter = match parser.parse(ms) { + Ok(iter) => iter, + Err(err) => { + tracing::debug!( + path = %path.display(), + error = %err, + "nom-exif: EXIF parse failed", + ); + return (None, None, None); + } + }; + let exif: nom_exif::Exif = iter.into(); + + // WHY trim_end_matches: EXIF ASCII tags are often NUL-terminated or + // space-padded (e.g. "NIKON CORPORATION \0"). nom-exif's as_str() + // does not strip these; we normalise before storing. let camera_make = exif - .get_field(exif::Tag::Make, exif::In::PRIMARY) - .map(|f| strip_quotes(&f.display_value().to_string())); + .get(nom_exif::ExifTag::Make) + .and_then(|v| v.as_str()) + .map(|s| s.trim_end_matches(['\0', ' ']).to_owned()); let camera_model = exif - .get_field(exif::Tag::Model, exif::In::PRIMARY) - .map(|f| strip_quotes(&f.display_value().to_string())); + .get(nom_exif::ExifTag::Model) + .and_then(|v| v.as_str()) + .map(|s| s.trim_end_matches(['\0', ' ']).to_owned()); + + // WHY reshape: callers consume ISO 8601 throughout the DB + // (`first_seen`, `last_seen`). Converting at extraction time keeps + // storage canonical. + // WHY as_time_components: the typed accessor is stable across any future + // Display-impl changes in nom-exif's #[non_exhaustive] EntryValue enum + // and carries the FixedOffset when present, letting us emit a proper + // RFC 3339 string with offset suffix for timezone-aware EXIF values. + // Using byte-index-10 into a to_string() output would be fragile and + // discards the offset information entirely. + let captured_at = exif + .get(nom_exif::ExifTag::DateTimeOriginal) + .and_then(nom_exif::EntryValue::as_time_components) + .map(|(naive, offset)| { + offset.map_or_else( + || naive.format("%Y-%m-%dT%H:%M:%S").to_string(), + |tz| { + // WHY .single(): defensive — FixedOffset cannot produce + // LocalResult::None/Ambiguous, but the typed shape is + // forward-compatible if nom-exif ever exposes Tz-aware values. + naive.and_local_timezone(tz).single().map_or_else( + || naive.format("%Y-%m-%dT%H:%M:%S").to_string(), + |dt| dt.to_rfc3339(), + ) + }, + ) + }); (captured_at, camera_make, camera_model) } -/// `kamadak-exif`'s `display_value()` wraps ASCII strings in double -/// quotes (e.g. `"Canon"`). Strip those so downstream consumers see -/// bare values. -fn strip_quotes(s: &str) -> String { - s.trim_matches('"').to_owned() -} - -/// Convert an EXIF `DateTimeOriginal` rendering to ISO 8601. -/// -/// EXIF on the wire uses `"YYYY:MM:DD HH:MM:SS"` (all colons); however -/// `kamadak-exif::Field::display_value()` normalises the date-part -/// separators to hyphens, yielding `"YYYY-MM-DD HH:MM:SS"`. We accept -/// either form by reading the first 19 chars positionally: only the -/// space at index 10 is inspected to decide the input shape is -/// plausible. Output is always `"YYYY-MM-DDTHH:MM:SS"`. -/// -/// WHY accept both: reusing one function for future raw-tag reads (that -/// keep the EXIF-colon form) without forcing the caller to choose. -/// -/// Returns `None` if the input is shorter than 19 chars or missing the -/// date/time separator at position 10. -fn exif_datetime_to_iso8601(raw: &str) -> Option { - let raw = raw.trim().trim_matches('"').trim(); - if raw.len() < 19 { - return None; - } - let bytes = raw.as_bytes(); - if bytes[10] != b' ' { - return None; - } - let mut out = String::with_capacity(19); - for (i, c) in raw.chars().take(19).enumerate() { - match i { - 4 | 7 => out.push('-'), - 10 => out.push('T'), - _ => out.push(c), - } - } - Some(out) -} - /// Video metadata extractor backed by `mp4parse`. /// /// Handles `video/mp4` and `video/quicktime` (the two MIMEs the @@ -385,33 +387,3 @@ impl MetadataExtractor for CompositeExtractor { }) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn exif_datetime_converts_colon_form_to_iso8601() { - let got = exif_datetime_to_iso8601("2024:06:01 12:34:56").expect("parse"); - assert_eq!(got, "2024-06-01T12:34:56"); - } - - #[test] - fn exif_datetime_converts_hyphen_form_to_iso8601() { - // `kamadak-exif::Field::display_value()` normalises the date - // portion to hyphens; the output must still be ISO 8601. - let got = exif_datetime_to_iso8601("2024-06-01 12:34:56").expect("parse"); - assert_eq!(got, "2024-06-01T12:34:56"); - } - - #[test] - fn exif_datetime_rejects_short_input() { - assert!(exif_datetime_to_iso8601("2024").is_none()); - } - - #[test] - fn strip_quotes_handles_bare_string() { - assert_eq!(strip_quotes("Canon"), "Canon"); - assert_eq!(strip_quotes("\"Canon\""), "Canon"); - } -} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 4c4d51c..85d97dc 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -6,7 +6,7 @@ //! scanner. //! //! The extractors are confined here so the rest of the workspace stays -//! free of `image`, `kamadak-exif`, `mp4parse`, and `mime_guess` +//! free of `image`, `nom-exif`, `mp4parse`, and `mime_guess` //! dependencies. #![forbid(unsafe_code)] diff --git a/crates/media/src/thumbnail.rs b/crates/media/src/thumbnail.rs index e693512..34599b2 100644 --- a/crates/media/src/thumbnail.rs +++ b/crates/media/src/thumbnail.rs @@ -18,8 +18,9 @@ use std::path::{Path, PathBuf}; +use fast_image_resize::images::Image; +use fast_image_resize::{IntoImageView, Resizer}; use image::ImageReader; -use image::imageops::FilterType; use perima_core::{BlakeHash, CoreError}; /// Generate WebP thumbnails for image sources. @@ -66,6 +67,42 @@ impl std::fmt::Debug for ThumbnailGenerator { /// images × ~20 KB each ≈ 2 GB, acceptable per plan risks section). pub const DEFAULT_MAX_SIZE: u32 = 256; +/// Compute fit-in-box dimensions preserving aspect ratio. +/// +/// WHY: `image::DynamicImage::resize` does this internally; `fast_image_resize` +/// expects explicit target dimensions. +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + // WHY: ratio is in (0.0, 1.0] (box ≤ src), and src_w / src_h are u32, so + // src * ratio is ≥ 0 and ≤ src_w/src_h < u32::MAX. Truncation after + // .round() is deliberate and safe here. +)] +fn compute_fit(src_w: u32, src_h: u32, box_w: u32, box_h: u32) -> (u32, u32) { + let ratio = (f64::from(box_w) / f64::from(src_w)).min(f64::from(box_h) / f64::from(src_h)); + let dst_w = ((f64::from(src_w) * ratio).round() as u32).max(1); + let dst_h = ((f64::from(src_h) * ratio).round() as u32).max(1); + (dst_w, dst_h) +} + +/// Normalize input to a pixel type our resize match can handle. +/// +/// WHY: `fast_image_resize` + image-feature exposes `pixel_type()` which returns +/// None for some `DynamicImage` variants (16-bit PNG, HDR f32, grayscale+alpha). +/// Rather than regressing thumbnail support for exotic inputs, coerce them to +/// 8-bit of the closest channel-count match. Common cases (Rgb8/Rgba8/Luma8) +/// pass through unchanged — the "RGB→RGBA promotion" regression is guarded. +fn coerce_for_resize(img: image::DynamicImage) -> image::DynamicImage { + use image::DynamicImage::{ImageLuma8, ImageRgb8, ImageRgba8}; + match &img { + ImageLuma8(_) | ImageRgb8(_) | ImageRgba8(_) => img, + // LumaA8, 16-bit, HDR, and any future variant: coerce to 8-bit RGBA. + // LumaA8 has no 1:1 fast_image_resize PixelType — promote to RGBA to + // preserve alpha. All other exotic variants similarly downgrade to RGBA. + _ => ImageRgba8(img.to_rgba8()), + } +} + impl ThumbnailGenerator { /// Construct a generator rooted at `data_dir` with /// [`DEFAULT_MAX_SIZE`] as the pixel ceiling. @@ -134,6 +171,75 @@ impl ThumbnailGenerator { .join(format!("{hex}.webp")) } + /// Resize `img` to fit within `max_size × max_size` using + /// SIMD-accelerated Lanczos3 via `fast_image_resize`. + /// + /// # Errors + /// + /// Returns `CoreError::Internal` if `fast_image_resize` cannot handle + /// the pixel type after coercion (should be unreachable in practice). + fn resize_image(&self, img: image::DynamicImage) -> Result { + // WHY fast_image_resize: 3-10× faster than image::imageops::resize + // (SIMD Lanczos3: SSE4.1/AVX2/NEON/WASM). Audit §Q5. Default algorithm + // for Resizer::new() is Lanczos3 for convolution-capable pixel types, + // so no ResizeOptions needed. + + // Normalize exotic variants (16-bit, HDR, LumaA) to 8-bit before resize. + // Common RGB/RGBA/Luma8 pass through unchanged. + let img = coerce_for_resize(img); + + let (dst_w, dst_h) = compute_fit(img.width(), img.height(), self.max_size, self.max_size); + + // `img` is DynamicImage; with `features = ["image"]` it implements IntoImageView + // so we read pixels directly, no buffer copy. After coerce_for_resize, pixel_type() + // is guaranteed Some(U8 / U8x3 / U8x4). + let pixel_type = img.pixel_type().ok_or_else(|| { + CoreError::Internal(format!( + "fast_image_resize: coerce_for_resize returned unsupported variant ({:?})", + img.color() + )) + })?; + + let mut dst = Image::new(dst_w, dst_h, pixel_type); + + Resizer::new() + .resize(&img, &mut dst, None) + .map_err(|e| CoreError::Internal(format!("fast_image_resize: {e}")))?; + + // Wrap dst back into DynamicImage matching the source pixel type. + // WHY into_vec(): consumes `dst` and returns the already-allocated Vec + // without copying (BufferContainer::Owned path in fast_image_resize 6.x). + let buf = dst.into_vec(); + let resized = match pixel_type { + fast_image_resize::PixelType::U8x3 => image::DynamicImage::ImageRgb8( + image::RgbImage::from_raw(dst_w, dst_h, buf).ok_or_else(|| { + CoreError::Internal("RgbImage::from_raw returned None".into()) + })?, + ), + fast_image_resize::PixelType::U8x4 => image::DynamicImage::ImageRgba8( + image::RgbaImage::from_raw(dst_w, dst_h, buf).ok_or_else(|| { + CoreError::Internal("RgbaImage::from_raw returned None".into()) + })?, + ), + fast_image_resize::PixelType::U8 => image::DynamicImage::ImageLuma8( + image::GrayImage::from_raw(dst_w, dst_h, buf).ok_or_else(|| { + CoreError::Internal("GrayImage::from_raw returned None".into()) + })?, + ), + // WHY defense-in-depth: coerce_for_resize guarantees U8/U8x3/U8x4 + // above, so this arm is statically unreachable. The Err path exists + // to avoid a panic if a future fast_image_resize version adds new + // PixelType variants that bypass the coerce guarantee. + other => { + return Err(CoreError::Internal(format!( + "fast_image_resize: thumbnail path saw unexpected pixel type {other:?}" + ))); + } + }; + + Ok(resized) + } + /// Decode `source`, resize to fit `max_size` while preserving /// aspect, encode as WebP, and atomically write to the target /// computed by [`path_for`](Self::path_for). @@ -178,10 +284,7 @@ impl ThumbnailGenerator { .decode() .map_err(|e| CoreError::Internal(format!("decode {}: {e}", source.display())))?; - // WHY `resize` (not `resize_exact`): preserves aspect ratio, - // clamping the longer side to `max_size`. A 1000×500 input - // becomes 256×128 with `max_size = 256`. - let resized = img.resize(self.max_size, self.max_size, FilterType::Lanczos3); + let resized = self.resize_image(img)?; // WHY atomic write: without `.tmp` + `rename` a crash mid- // encode leaves a half-written `.webp` that passes the @@ -210,10 +313,11 @@ impl ThumbnailGenerator { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use std::path::Path; - use image::{ImageBuffer, Rgb}; + use image::{DynamicImage, ImageBuffer, Rgb}; use tempfile::TempDir; use super::*; @@ -370,4 +474,165 @@ mod tests { // No directories created under an ephemeral `PathBuf::new()` // root (the disabled generator never touches the filesystem). } + + #[test] + #[ignore = "perf benchmark; run: cargo test -p perima-media --release resize_only_bench -- --ignored --nocapture"] + #[allow(clippy::unwrap_used, clippy::print_stderr)] + fn resize_only_bench() { + use std::time::Instant; + let tmp = tempfile::tempdir().unwrap(); + let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 512); + + // 4928×3279 (24MP) matches fast_image_resize's upstream benchmark. + // Smaller sources hide the SIMD win behind loop overhead. + let rgb = image::RgbImage::from_pixel(4928, 3279, image::Rgb([128, 64, 32])); + let src_dyn = image::DynamicImage::ImageRgb8(rgb); + + let start = Instant::now(); + for _ in 0..50 { + let _ = tgen.resize_image(src_dyn.clone()).expect("resize_image"); + } + let elapsed = start.elapsed(); + eprintln!( + "resize_only_bench: 50 iters 4928x3279 → 512x(aspect) in {elapsed:?} ({:?} / iter)", + elapsed / 50 + ); + } + + // --- pixel-type preservation tests (merged from former mod pixel_type_tests) --- + + #[test] + fn resize_preserves_rgb_source_as_rgb() { + let tmp = tempfile::tempdir().unwrap(); + let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64); + + let rgb = image::RgbImage::from_pixel(200, 100, image::Rgb([200, 100, 50])); + let src = DynamicImage::ImageRgb8(rgb); + + let resized = tgen.resize_image(src).expect("resize_image"); + + assert!( + matches!(resized, DynamicImage::ImageRgb8(_)), + "RGB source must stay RGB after resize; got {:?}", + resized.color() + ); + } + + #[test] + fn resize_preserves_rgba_source_as_rgba() { + let tmp = tempfile::tempdir().unwrap(); + let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64); + + let rgba = image::RgbaImage::from_pixel(200, 100, image::Rgba([200, 100, 50, 180])); + let src = DynamicImage::ImageRgba8(rgba); + + let resized = tgen.resize_image(src).expect("resize_image"); + + assert!( + matches!(resized, DynamicImage::ImageRgba8(_)), + "RGBA source must stay RGBA after resize; got {:?}", + resized.color() + ); + } + + #[test] + fn resize_coerces_16bit_rgb_to_8bit_rgba() { + let tmp = tempfile::tempdir().unwrap(); + let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64); + + let rgb16 = image::ImageBuffer::, _>::from_pixel( + 200, + 100, + image::Rgb([30000_u16, 20000, 10000]), + ); + let src = DynamicImage::ImageRgb16(rgb16); + + let resized = tgen.resize_image(src).expect("resize_image"); + + assert!( + matches!( + resized, + DynamicImage::ImageRgba8(_) | DynamicImage::ImageRgb8(_) + ), + "16-bit source must coerce to 8-bit variant; got {:?}", + resized.color() + ); + } + + // --- new regression tests (fixes 4, 5, 6) --- + + #[test] + fn resize_preserves_luma8_source_as_luma8() { + let tmp = tempfile::tempdir().unwrap(); + let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64); + + let luma = image::GrayImage::from_pixel(200, 100, image::Luma([128_u8])); + let src = DynamicImage::ImageLuma8(luma); + + let resized = tgen.resize_image(src).expect("resize_image"); + + assert!( + matches!(resized, DynamicImage::ImageLuma8(_)), + "Luma8 source must stay Luma8 after resize; got {:?}", + resized.color() + ); + } + + #[test] + fn resize_coerces_lumaa8_to_rgba8_preserving_alpha() { + // coerce_for_resize promotes LumaA8 (grayscale + alpha) to RGBA8 + // because fast_image_resize has no PixelType::U8x2 in our match. + // This test verifies alpha is preserved through the promotion. + let tmp = tempfile::tempdir().unwrap(); + let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64); + + // Use a LumaA8 with non-opaque alpha so the test meaningfully + // verifies alpha survives. + let lumaa = image::ImageBuffer::, _>::from_pixel( + 200, + 100, + image::LumaA([128, 180]), + ); + let src = DynamicImage::ImageLumaA8(lumaa); + + let resized = tgen.resize_image(src).expect("resize_image"); + + assert!( + matches!(resized, DynamicImage::ImageRgba8(_)), + "LumaA8 source must coerce to RGBA8 after resize; got {:?}", + resized.color() + ); + } + + #[test] + fn generate_handles_16bit_png_via_full_pipeline() { + // Regression: 16-bit PNG decodes to DynamicImage::ImageRgb16 (or Rgba16), + // which coerce_for_resize normalizes to RGBA8. Without the coerce path + // the pixel_type() call would fail. Verify the full generate() pipeline + // produces a thumbnail file for this exotic input. + let tmp = tempfile::tempdir().unwrap(); + let src_path = tmp.path().join("16bit.png"); + + // Build a 16-bit RGB PNG and save it. + let rgb16 = image::ImageBuffer::, _>::from_pixel( + 300, + 200, + image::Rgb([30000_u16, 20000, 10000]), + ); + image::DynamicImage::ImageRgb16(rgb16) + .save(&src_path) + .expect("save 16-bit PNG"); + + let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64); + + // Fabricate a hash for the content-addressed thumbnail path. + let hash_bytes = *blake3::hash(b"16bit-png-test-fixture").as_bytes(); + let hash = perima_core::BlakeHash::from_bytes(hash_bytes); + + let out = tgen + .generate(&hash, &src_path) + .expect("generate") + .expect("Some path"); + assert!(out.exists(), "Thumbnail file should exist at {out:?}"); + } } diff --git a/crates/media/tests/integration.rs b/crates/media/tests/integration.rs index 3dffb38..7a98cbb 100644 --- a/crates/media/tests/integration.rs +++ b/crates/media/tests/integration.rs @@ -7,7 +7,7 @@ #![allow(clippy::missing_errors_doc)] use std::fs::{self, File}; -use std::io::{BufWriter, Cursor, Write}; +use std::io::{BufWriter, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -33,72 +33,305 @@ fn make_test_png(width: u32, height: u32, path: &Path) -> PathBuf { path.to_path_buf() } -/// Build a minimal JPEG containing only SOI + APP1(EXIF) + EOI. +/// Build a minimal JPEG containing SOI + APP0(JFIF) + APP1(EXIF) + EOI. /// /// WHY this shape (not a real encoded image): -/// - `kamadak-exif`'s `read_from_container` only needs SOI + APP1; -/// it does not parse the scan data. +/// - `nom-exif`'s `MediaParser` requires a preceding APP0 (JFIF) marker +/// before the APP1 (EXIF) segment for its streaming JPEG parser to +/// recognise the file as a valid JPEG container. /// - `image::image_dimensions` is NOT called against this fixture — /// the EXIF test focuses on the text fields, and the PNG-dimensions /// test uses a real PNG. /// - Avoiding a fully-encoded JPEG keeps the helper small and removes /// any dependency on the `image` JPEG encoder. /// -/// WHY generated at runtime (not a byte-vendored const): -/// the plan's preferred option is a const `&[u8]` produced externally -/// via `exiftool`. This environment does not have `exiftool`; building -/// the TIFF/EXIF segment at runtime via `exif::experimental::Writer` is -/// documented as the acceptable fallback (plan §Task 3, Option 2) and -/// has the same correctness property (deterministic bytes, no binary -/// blob in git) without the external tool dependency. +/// WHY hand-crafted TIFF bytes (not a third-party writer): +/// We no longer depend on `kamadak-exif`, which previously provided +/// `exif::experimental::Writer`. Instead we build a minimal little-endian +/// TIFF block with a proper `ExifIFDPointer` chain (IFD0 → `ExifSubIFD`) +/// that nom-exif can follow. The byte layout follows EXIF 2.3 §4.5. fn make_jpeg_with_exif(datetime_original: &str, make: &str, model: &str, path: &Path) -> PathBuf { - use exif::experimental::Writer; - use exif::{Field, In, Tag, Value}; - - // Build the TIFF/EXIF payload. - let dt = Field { - tag: Tag::DateTimeOriginal, - ifd_num: In::PRIMARY, - value: Value::Ascii(vec![datetime_original.as_bytes().to_vec()]), + let tiff_bytes = build_tiff_exif(datetime_original, make, model); + + // Assemble JPEG: SOI + APP0(JFIF) + APP1(Exif) + EOI. + // APP1 length covers [len_hi, len_lo, "Exif\0\0", TIFF] — i.e. + // 2 (len itself) + 6 (identifier) + tiff.len(). + let app1_payload_len = 2 + 6 + tiff_bytes.len(); + let app1_len = + u16::try_from(app1_payload_len).expect("APP1 payload too large for a single segment"); + + let file = File::create(path).expect("create jpeg"); + let mut w = BufWriter::new(file); + w.write_all(&[0xFF, 0xD8]).expect("SOI"); + // APP0 (JFIF) — 16-byte minimal header required by nom-exif's JPEG parser. + w.write_all(&[0xFF, 0xE0, 0x00, 0x10]) + .expect("APP0 marker+len"); + w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00") + .expect("JFIF payload"); + // APP1 (EXIF) + w.write_all(&[0xFF, 0xE1]).expect("APP1 marker"); + w.write_all(&app1_len.to_be_bytes()).expect("APP1 length"); + w.write_all(b"Exif\0\0").expect("Exif identifier"); + w.write_all(&tiff_bytes).expect("TIFF body"); + w.write_all(&[0xFF, 0xD9]).expect("EOI"); + w.flush().expect("flush jpeg"); + path.to_path_buf() +} + +/// Build a minimal little-endian TIFF block with the proper EXIF IFD +/// chain for three tags: `Make` and `Model` in IFD0, and +/// `DateTimeOriginal` in the EXIF `SubIFD` (pointed to from IFD0 via tag +/// `ExifIFDPointer` 0x8769). +/// +/// Layout (offsets relative to start of TIFF block): +/// ```text +/// 0x00 TIFF header (8 bytes): "II" + 0x002A + IFD0_offset=8 +/// 0x08 IFD0 (42 bytes): count=3, [Make, Model, ExifIFDPointer], next=0 +/// 0x32 ExifSubIFD (18 bytes): count=1, [DateTimeOriginal], next=0 +/// 0x44 String area: Make\0, Model\0, DateTimeOriginal\0 +/// ``` +/// +/// Each IFD entry is 12 bytes: tag(2) + type(2) + count(4) + offset(4). +fn build_tiff_exif(datetime_original: &str, make: &str, model: &str) -> Vec { + // Tag / type constants (EXIF 2.3). + const TAG_MAKE: u16 = 0x010F; + const TAG_MODEL: u16 = 0x0110; + const TAG_EXIF_IFD_POINTER: u16 = 0x8769; + const TAG_DATETIME_ORIGINAL: u16 = 0x9003; + const TYPE_ASCII: u16 = 2; + const TYPE_LONG: u16 = 4; + + // Offset layout (all LE, relative to TIFF block start). + // TIFF header: 8. IFD0: 2 + 3*12 + 4 = 42 → ends at 50. + // ExifSubIFD: 2 + 1*12 + 4 = 18 → ends at 68. String area from 68. + const IFD0_OFFSET: u32 = 8; + const IFD0_ENTRY_COUNT: u16 = 3; + const EXIF_SUBIFD_OFFSET: u32 = IFD0_OFFSET + 2 + (IFD0_ENTRY_COUNT as u32 * 12) + 4; // 50 + const EXIF_SUBIFD_ENTRY_COUNT: u16 = 1; + const STRING_AREA: u32 = EXIF_SUBIFD_OFFSET + 2 + (EXIF_SUBIFD_ENTRY_COUNT as u32 * 12) + 4; // 68 + + // ASCII values are NUL-terminated per TIFF/EXIF spec. + let make_bytes: Vec = { + let mut v = make.as_bytes().to_vec(); + v.push(0); + v }; - let mk = Field { - tag: Tag::Make, - ifd_num: In::PRIMARY, - value: Value::Ascii(vec![make.as_bytes().to_vec()]), + let model_bytes: Vec = { + let mut v = model.as_bytes().to_vec(); + v.push(0); + v }; - let mdl = Field { - tag: Tag::Model, - ifd_num: In::PRIMARY, - value: Value::Ascii(vec![model.as_bytes().to_vec()]), + let dt_bytes: Vec = { + let mut v = datetime_original.as_bytes().to_vec(); + v.push(0); + v }; - let mut writer = Writer::new(); - writer.push_field(&dt); - writer.push_field(&mk); - writer.push_field(&mdl); - - let mut tiff_buf = Cursor::new(Vec::::new()); - writer.write(&mut tiff_buf, false).expect("write tiff exif"); - let tiff_bytes = tiff_buf.into_inner(); + let make_len = u32::try_from(make_bytes.len()).expect("make too long for TIFF"); + let model_len = u32::try_from(model_bytes.len()).expect("model too long for TIFF"); + let dt_len = u32::try_from(dt_bytes.len()).expect("datetime too long for TIFF"); + + let make_offset = STRING_AREA; + let model_offset = make_offset + make_len; + let dt_offset = model_offset + model_len; + + let mut buf = Vec::new(); + + // TIFF header. + buf.extend_from_slice(b"II"); // little-endian byte order + buf.extend_from_slice(&42_u16.to_le_bytes()); // TIFF magic + buf.extend_from_slice(&IFD0_OFFSET.to_le_bytes()); + + // IFD0 (entries must be sorted ascending by tag). + buf.extend_from_slice(&IFD0_ENTRY_COUNT.to_le_bytes()); + // Make (0x010F) + buf.extend_from_slice(&TAG_MAKE.to_le_bytes()); + buf.extend_from_slice(&TYPE_ASCII.to_le_bytes()); + buf.extend_from_slice(&make_len.to_le_bytes()); + buf.extend_from_slice(&make_offset.to_le_bytes()); + // Model (0x0110) + buf.extend_from_slice(&TAG_MODEL.to_le_bytes()); + buf.extend_from_slice(&TYPE_ASCII.to_le_bytes()); + buf.extend_from_slice(&model_len.to_le_bytes()); + buf.extend_from_slice(&model_offset.to_le_bytes()); + // ExifIFDPointer (0x8769) — inline LONG offset to ExifSubIFD. + buf.extend_from_slice(&TAG_EXIF_IFD_POINTER.to_le_bytes()); + buf.extend_from_slice(&TYPE_LONG.to_le_bytes()); + buf.extend_from_slice(&1_u32.to_le_bytes()); // count = 1 + buf.extend_from_slice(&EXIF_SUBIFD_OFFSET.to_le_bytes()); + // IFD0 next-IFD pointer. + buf.extend_from_slice(&0_u32.to_le_bytes()); + + // ExifSubIFD. + buf.extend_from_slice(&EXIF_SUBIFD_ENTRY_COUNT.to_le_bytes()); + // DateTimeOriginal (0x9003) + buf.extend_from_slice(&TAG_DATETIME_ORIGINAL.to_le_bytes()); + buf.extend_from_slice(&TYPE_ASCII.to_le_bytes()); + buf.extend_from_slice(&dt_len.to_le_bytes()); + buf.extend_from_slice(&dt_offset.to_le_bytes()); + // ExifSubIFD next-IFD pointer. + buf.extend_from_slice(&0_u32.to_le_bytes()); + + // String area. + buf.extend_from_slice(&make_bytes); + buf.extend_from_slice(&model_bytes); + buf.extend_from_slice(&dt_bytes); + + buf +} - // Assemble JPEG: SOI + APP1 + (Exif\0\0 + TIFF) + EOI. - // APP1 length bytes cover [len_hi, len_lo, Exif\0\0, TIFF] — i.e. - // 2 (len itself) + 6 (identifier) + tiff.len(). - let payload_len = 2 + 6 + tiff_bytes.len(); - let len_u16 = u16::try_from(payload_len).expect("APP1 payload too large for a single segment"); +/// Build a minimal JPEG containing SOI + APP0(JFIF) + APP1(EXIF) + EOI with +/// both `DateTimeOriginal` (0x9003) and `OffsetTimeOriginal` (0x9011) set in +/// the `ExifSubIFD`. +/// +/// WHY separate helper (not a flag on `make_jpeg_with_exif`): keeping the +/// two helpers independent avoids a boolean parameter that would change the +/// byte layout mid-function and make the offset arithmetic harder to follow. +fn make_jpeg_with_exif_offset( + datetime_original: &str, + offset: &str, + make: &str, + model: &str, + path: &Path, +) -> PathBuf { + let tiff_bytes = build_tiff_exif_with_offset(datetime_original, offset, make, model); + + let app1_payload_len = 2 + 6 + tiff_bytes.len(); + let app1_len = + u16::try_from(app1_payload_len).expect("APP1 payload too large for a single segment"); let file = File::create(path).expect("create jpeg"); let mut w = BufWriter::new(file); - w.write_all(&[0xFF, 0xD8]).expect("SOI"); // SOI - w.write_all(&[0xFF, 0xE1]).expect("APP1 marker"); // APP1 - w.write_all(&len_u16.to_be_bytes()).expect("APP1 length"); + w.write_all(&[0xFF, 0xD8]).expect("SOI"); + w.write_all(&[0xFF, 0xE0, 0x00, 0x10]) + .expect("APP0 marker+len"); + w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00") + .expect("JFIF payload"); + w.write_all(&[0xFF, 0xE1]).expect("APP1 marker"); + w.write_all(&app1_len.to_be_bytes()).expect("APP1 length"); w.write_all(b"Exif\0\0").expect("Exif identifier"); w.write_all(&tiff_bytes).expect("TIFF body"); - w.write_all(&[0xFF, 0xD9]).expect("EOI"); // EOI + w.write_all(&[0xFF, 0xD9]).expect("EOI"); w.flush().expect("flush jpeg"); path.to_path_buf() } +/// Build a minimal little-endian TIFF block with two `ExifSubIFD` entries: +/// `DateTimeOriginal` (0x9003) and `OffsetTimeOriginal` (0x9011). +/// +/// Layout (offsets relative to start of TIFF block): +/// ```text +/// 0x00 TIFF header (8 bytes): "II" + 0x002A + IFD0_offset=8 +/// 0x08 IFD0 (42 bytes): count=3, [Make, Model, ExifIFDPointer], next=0 +/// 0x32 ExifSubIFD (30 bytes): count=2, [DateTimeOriginal, OffsetTimeOriginal], next=0 +/// 0x50 String area: Make\0, Model\0, DateTimeOriginal\0, OffsetTimeOriginal\0 +/// ``` +fn build_tiff_exif_with_offset( + datetime_original: &str, + offset: &str, + make: &str, + model: &str, +) -> Vec { + const TAG_MAKE: u16 = 0x010F; + const TAG_MODEL: u16 = 0x0110; + const TAG_EXIF_IFD_POINTER: u16 = 0x8769; + const TAG_DATETIME_ORIGINAL: u16 = 0x9003; + const TAG_OFFSET_TIME_ORIGINAL: u16 = 0x9011; + const TYPE_ASCII: u16 = 2; + const TYPE_LONG: u16 = 4; + + // IFD0: 2 + 3*12 + 4 = 42 → [8, 50) + // ExifSubIFD: 2 + 2*12 + 4 = 30 → [50, 80) + // String area from 80. + const IFD0_OFFSET: u32 = 8; + const IFD0_ENTRY_COUNT: u16 = 3; + const EXIF_SUBIFD_OFFSET: u32 = IFD0_OFFSET + 2 + (IFD0_ENTRY_COUNT as u32 * 12) + 4; // 50 + const EXIF_SUBIFD_ENTRY_COUNT: u16 = 2; + const STRING_AREA: u32 = EXIF_SUBIFD_OFFSET + 2 + (EXIF_SUBIFD_ENTRY_COUNT as u32 * 12) + 4; // 80 + + let make_bytes: Vec = { + let mut v = make.as_bytes().to_vec(); + v.push(0); + v + }; + let model_bytes: Vec = { + let mut v = model.as_bytes().to_vec(); + v.push(0); + v + }; + let dt_bytes: Vec = { + let mut v = datetime_original.as_bytes().to_vec(); + v.push(0); + v + }; + // EXIF spec: OffsetTimeOriginal is ASCII, count includes the NUL. + let offset_bytes: Vec = { + let mut v = offset.as_bytes().to_vec(); + v.push(0); + v + }; + + let make_len = u32::try_from(make_bytes.len()).expect("make too long"); + let model_len = u32::try_from(model_bytes.len()).expect("model too long"); + let dt_len = u32::try_from(dt_bytes.len()).expect("datetime too long"); + let offset_len = u32::try_from(offset_bytes.len()).expect("offset too long"); + + let make_offset = STRING_AREA; + let model_offset = make_offset + make_len; + let dt_offset = model_offset + model_len; + let offset_str_offset = dt_offset + dt_len; + + let mut buf = Vec::new(); + + // TIFF header. + buf.extend_from_slice(b"II"); + buf.extend_from_slice(&42_u16.to_le_bytes()); + buf.extend_from_slice(&IFD0_OFFSET.to_le_bytes()); + + // IFD0. + buf.extend_from_slice(&IFD0_ENTRY_COUNT.to_le_bytes()); + // Make (0x010F) + buf.extend_from_slice(&TAG_MAKE.to_le_bytes()); + buf.extend_from_slice(&TYPE_ASCII.to_le_bytes()); + buf.extend_from_slice(&make_len.to_le_bytes()); + buf.extend_from_slice(&make_offset.to_le_bytes()); + // Model (0x0110) + buf.extend_from_slice(&TAG_MODEL.to_le_bytes()); + buf.extend_from_slice(&TYPE_ASCII.to_le_bytes()); + buf.extend_from_slice(&model_len.to_le_bytes()); + buf.extend_from_slice(&model_offset.to_le_bytes()); + // ExifIFDPointer (0x8769) + buf.extend_from_slice(&TAG_EXIF_IFD_POINTER.to_le_bytes()); + buf.extend_from_slice(&TYPE_LONG.to_le_bytes()); + buf.extend_from_slice(&1_u32.to_le_bytes()); + buf.extend_from_slice(&EXIF_SUBIFD_OFFSET.to_le_bytes()); + // IFD0 next-IFD pointer. + buf.extend_from_slice(&0_u32.to_le_bytes()); + + // ExifSubIFD (entries must be sorted ascending by tag). + buf.extend_from_slice(&EXIF_SUBIFD_ENTRY_COUNT.to_le_bytes()); + // DateTimeOriginal (0x9003) + buf.extend_from_slice(&TAG_DATETIME_ORIGINAL.to_le_bytes()); + buf.extend_from_slice(&TYPE_ASCII.to_le_bytes()); + buf.extend_from_slice(&dt_len.to_le_bytes()); + buf.extend_from_slice(&dt_offset.to_le_bytes()); + // OffsetTimeOriginal (0x9011) + buf.extend_from_slice(&TAG_OFFSET_TIME_ORIGINAL.to_le_bytes()); + buf.extend_from_slice(&TYPE_ASCII.to_le_bytes()); + buf.extend_from_slice(&offset_len.to_le_bytes()); + buf.extend_from_slice(&offset_str_offset.to_le_bytes()); + // ExifSubIFD next-IFD pointer. + buf.extend_from_slice(&0_u32.to_le_bytes()); + + // String area. + buf.extend_from_slice(&make_bytes); + buf.extend_from_slice(&model_bytes); + buf.extend_from_slice(&dt_bytes); + buf.extend_from_slice(&offset_bytes); + + buf +} + /// Synthesise a minimal valid MP4 with one ~1 second AVC video track. /// /// WHY programmatic: checking binary fixtures into git bloats the repo @@ -209,6 +442,68 @@ fn image_extractor_jpeg_exif() { assert_eq!(meta.mime_type.as_deref(), Some("image/jpeg")); } +#[test] +fn image_extractor_jpeg_exif_with_offset() { + // WHY: exercises the FixedOffset branch of as_time_components() in + // read_exif. The OffsetTimeOriginal tag (0x9011, "+08:00") causes + // nom-exif to populate the Option field, which should + // produce an RFC 3339 string with the offset suffix. + let td = tempdir().expect("tempdir"); + let path = td.path().join("exif_offset.jpg"); + make_jpeg_with_exif_offset("2024:06:01 12:34:56", "+08:00", "Canon", "EOS R5", &path); + + let bytes = std::fs::read(&path).expect("read jpeg"); + assert_eq!(&bytes[0..2], &[0xFF, 0xD8], "must start with JPEG SOI"); + + let extractor = ImageExtractor::new(); + let meta = extractor + .extract(dummy_hash(), &path, "image/jpeg") + .expect("extract jpeg with offset"); + assert_eq!( + meta.captured_at.as_deref(), + Some("2024-06-01T12:34:56+08:00"), + "tz-aware EXIF must produce RFC 3339 with offset suffix", + ); + assert_eq!(meta.camera_make.as_deref(), Some("Canon")); + assert_eq!(meta.camera_model.as_deref(), Some("EOS R5")); +} + +#[test] +fn image_extractor_jpeg_without_exif_returns_default() { + // WHY: exercises the has_exif() short-circuit path. A JPEG with only + // SOI + APP0(JFIF) + EOI has no APP1 segment, so nom-exif should + // report has_exif() == false and read_exif returns (None, None, None). + let td = tempdir().expect("tempdir"); + let path = td.path().join("no_exif.jpg"); + + // Build a minimal JPEG: SOI + APP0(JFIF) + EOI — no APP1. + { + let file = File::create(&path).expect("create jpeg"); + let mut w = BufWriter::new(file); + w.write_all(&[0xFF, 0xD8]).expect("SOI"); + w.write_all(&[0xFF, 0xE0, 0x00, 0x10]) + .expect("APP0 marker+len"); + w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00") + .expect("JFIF payload"); + w.write_all(&[0xFF, 0xD9]).expect("EOI"); + w.flush().expect("flush"); + } + + let extractor = ImageExtractor::new(); + let meta = extractor + .extract(dummy_hash(), &path, "image/jpeg") + .expect("extract jpeg without exif"); + assert_eq!( + meta.captured_at, None, + "no-EXIF JPEG must have captured_at = None", + ); + assert_eq!(meta.camera_make, None, "no-EXIF JPEG must have make = None"); + assert_eq!( + meta.camera_model, None, + "no-EXIF JPEG must have model = None", + ); +} + // --------------------------------------------------------------------- // VideoExtractor tests // ---------------------------------------------------------------------