commit c2309e3ccf201a4d6c05864f868965b278c66a5f Author: Alex Date: Sat Aug 8 11:56:08 2026 +0200 init2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..69f304e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1054 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zomboid-converter" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "rusqlite", + "tempfile", + "walkdir", + "zip", +] + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..49182ad --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "zomboid-converter" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +rusqlite = { version = "0.32", features = ["bundled"] } +tempfile = "3" +walkdir = "2" +zip = "2" diff --git a/src/convert.rs b/src/convert.rs new file mode 100644 index 0000000..f174175 --- /dev/null +++ b/src/convert.rs @@ -0,0 +1,203 @@ +use crate::copy; +use crate::input::ResolvedInput; +use crate::mods; +use crate::password; +use crate::players_db; +use anyhow::{bail, Context, Result}; +use std::fs; +use std::path::Path; + +pub struct ConvertOptions<'a> { + pub input: &'a Path, + pub output: &'a Path, + pub server_name: &'a str, + pub server_config: Option<&'a Path>, + pub workshop_dir: Option<&'a Path>, + pub password: Option<&'a str>, + pub env_file: Option<&'a Path>, + pub force: bool, +} + +pub fn convert(opts: &ConvertOptions<'_>) -> Result<()> { + let resolved = ResolvedInput::resolve(opts.input)?; + + let dest_world = opts + .output + .join("Saves") + .join("Multiplayer") + .join(opts.server_name); + + if dest_world.exists() && !opts.force { + bail!( + "destination world already exists: {} (use --force to replace)", + dest_world.display() + ); + } + + eprintln!( + "Converting {} -> {}", + resolved.world_root.display(), + dest_world.display() + ); + + let copied = copy::copy_world_filtered(&resolved.world_root, &dest_world)?; + eprintln!("Copied {copied} files"); + + let players_db_path = dest_world.join("players.db"); + if players_db_path.is_file() { + let updated = players_db::patch_world_column(&players_db_path, opts.server_name)?; + eprintln!( + "Updated {updated} networkPlayers rows (world -> {})", + opts.server_name + ); + } else { + eprintln!("Warning: no players.db found in destination world"); + } + + if let Some(config_dir) = opts.server_config { + copy_server_config(config_dir, opts.output, &resolved.world_name, opts.server_name)?; + } + + recover_mods_into_server_ini(opts, &resolved.world_root)?; + apply_server_password(opts)?; + + eprintln!("Done."); + Ok(()) +} + +fn apply_server_password(opts: &ConvertOptions<'_>) -> Result<()> { + let Some(pw) = password::resolve_password(opts.password, opts.env_file, opts.output)? else { + eprintln!( + "No server password set (pass --password or SERVER_PASSWORD/PASSWORD in .env); leaving Password= unchanged" + ); + return Ok(()); + }; + + let ini = opts + .output + .join("Server") + .join(format!("{}.ini", opts.server_name)); + if !ini.is_file() { + eprintln!( + "Warning: {} missing — cannot apply Password=", + ini.display() + ); + return Ok(()); + } + + password::patch_ini_key(&ini, "Password", &pw)?; + eprintln!("Patched Password= in {}", ini.display()); + Ok(()) +} + +fn recover_mods_into_server_ini(opts: &ConvertOptions<'_>, world_root: &Path) -> Result<()> { + let mod_ids = mods::extract_mod_ids(world_root)?; + if mod_ids.is_empty() { + eprintln!("No mod IDs found in WorldDictionaryReadable.lua"); + return Ok(()); + } + + eprintln!("Recovered {} mod ID(s) from WorldDictionary", mod_ids.len()); + + let Some(workshop_dir) = opts.workshop_dir else { + eprintln!( + "Warning: --workshop-dir not set; wrote Mods= only. Pass a Steam workshop content/108600 path to also fill WorkshopItems=" + ); + let mods_line = mod_ids.join(";"); + let ini = opts.output.join("Server").join(format!("{}.ini", opts.server_name)); + if ini.is_file() { + // Keep existing WorkshopItems if any + let text = fs::read_to_string(&ini)?; + let workshop = text + .lines() + .find_map(|l| l.strip_prefix("WorkshopItems=")) + .unwrap_or("") + .to_string(); + mods::patch_server_ini_mods(&ini, &mods_line, &workshop)?; + eprintln!("Patched Mods= in {}", ini.display()); + } else { + eprintln!( + "Warning: {} missing — create/start the server once, then re-run with --workshop-dir", + ini.display() + ); + } + return Ok(()); + }; + + let (workshop_ids, _) = mods::resolve_workshop_ids(&mod_ids, workshop_dir)?; + let mods_line = mod_ids.join(";"); + let workshop_line = workshop_ids.join(";"); + + let server_dir = opts.output.join("Server"); + fs::create_dir_all(&server_dir)?; + let ini = server_dir.join(format!("{}.ini", opts.server_name)); + + if !ini.is_file() { + bail!( + "server ini not found at {} — start the dedicated server once to generate it, then re-run", + ini.display() + ); + } + + mods::patch_server_ini_mods(&ini, &mods_line, &workshop_line)?; + eprintln!( + "Patched {} ({} mods, {} workshop items)", + ini.display(), + mod_ids.len(), + workshop_ids.len() + ); + + Ok(()) +} + +fn copy_server_config( + config_dir: &Path, + output: &Path, + old_name: &str, + server_name: &str, +) -> Result<()> { + let dest_server = output.join("Server"); + fs::create_dir_all(&dest_server)?; + + let mappings = [ + (format!("{old_name}.ini"), format!("{server_name}.ini")), + ( + format!("{old_name}_SandboxVars.lua"), + format!("{server_name}_SandboxVars.lua"), + ), + ( + format!("{old_name}_spawnpoints.lua"), + format!("{server_name}_spawnpoints.lua"), + ), + ( + format!("{old_name}_spawnregions.lua"), + format!("{server_name}_spawnregions.lua"), + ), + ]; + + let mut copied_any = false; + for (src_name, dest_name) in mappings { + let src = config_dir.join(&src_name); + if src.is_file() { + let dest = dest_server.join(&dest_name); + fs::copy(&src, &dest).with_context(|| { + format!( + "failed to copy server config {} -> {}", + src.display(), + dest.display() + ) + })?; + eprintln!("Copied server config: {src_name} -> {dest_name}"); + copied_any = true; + } + } + + if !copied_any { + eprintln!( + "Warning: no server config files found in {} for world name `{old_name}`", + config_dir.display() + ); + } + + Ok(()) +} diff --git a/src/copy.rs b/src/copy.rs new file mode 100644 index 0000000..f33e5b2 --- /dev/null +++ b/src/copy.rs @@ -0,0 +1,81 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::Path; +use walkdir::WalkDir; + +const SKIP_FILENAMES: &[&str] = &["thumb.png", "InGameMap.ini", "serverid.dat"]; + +pub fn copy_world_filtered(source: &Path, dest: &Path) -> Result { + if dest.exists() { + fs::remove_dir_all(dest) + .with_context(|| format!("failed to remove existing destination: {}", dest.display()))?; + } + fs::create_dir_all(dest)?; + + let mut copied = 0u64; + + for entry in WalkDir::new(source).follow_links(false) { + let entry = entry?; + let rel = entry + .path() + .strip_prefix(source) + .context("walkdir path outside source root")?; + + if rel.as_os_str().is_empty() { + continue; + } + + if should_skip(rel) { + continue; + } + + let target = dest.join(rel); + if entry.file_type().is_dir() { + fs::create_dir_all(&target)?; + } else { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(entry.path(), &target)?; + copied += 1; + } + } + + Ok(copied) +} + +fn should_skip(rel: &Path) -> bool { + let file_name = match rel.file_name().and_then(|n| n.to_str()) { + Some(name) => name, + None => return false, + }; + + if SKIP_FILENAMES.contains(&file_name) { + return true; + } + + if file_name.ends_with(".db-journal") { + return true; + } + + // Syncthing-style atomic-write conflict copies (e.g. foo.bin-1234567890.123-ZwS1g3qi.bin) + if file_name.contains("-ZwS1g3qi") { + return true; + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn skips_conflict_and_journal_files() { + assert!(should_skip(Path::new("erosion.ini-1785960551.276219-ZwS1g3qi.ini"))); + assert!(should_skip(Path::new("players.db-journal"))); + assert!(should_skip(Path::new("thumb.png"))); + assert!(!should_skip(Path::new("players.db"))); + assert!(!should_skip(Path::new("map/1000/1056.bin"))); + } +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..fcd3a9d --- /dev/null +++ b/src/input.rs @@ -0,0 +1,119 @@ +use anyhow::{bail, Context, Result}; +use std::fs; +use std::io::copy; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; +use zip::ZipArchive; + +/// Resolved world input: a directory path and optional temp dir holding zip extraction. +pub struct ResolvedInput { + pub world_root: PathBuf, + pub world_name: String, + _temp_dir: Option, +} + +impl ResolvedInput { + pub fn resolve(input: &Path) -> Result { + if is_player_folder(input) { + bail!( + "input looks like a client player cache folder (ends with `_player`): {}", + input.display() + ); + } + + if input.is_dir() { + let world_name = input + .file_name() + .and_then(|n| n.to_str()) + .context("input directory has no name")? + .to_string(); + return Ok(Self { + world_root: input.to_path_buf(), + world_name, + _temp_dir: None, + }); + } + + if input.is_file() && input.extension().is_some_and(|e| e == "zip") { + return resolve_zip(input); + } + + bail!( + "input must be a world directory or .zip file: {}", + input.display() + ); + } +} + +fn is_player_folder(path: &Path) -> bool { + path.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|name| name.ends_with("_player")) +} + +fn resolve_zip(zip_path: &Path) -> Result { + let file = fs::File::open(zip_path) + .with_context(|| format!("failed to open zip: {}", zip_path.display()))?; + let mut archive = ZipArchive::new(file) + .with_context(|| format!("failed to read zip: {}", zip_path.display()))?; + + let temp_dir = tempfile::tempdir().context("failed to create temp directory")?; + let extract_root = temp_dir.path(); + + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + let entry_path = entry + .enclosed_name() + .with_context(|| format!("zip entry {} has an invalid path", entry.name()))?; + + let out_path = extract_root.join(entry_path); + if entry.is_dir() { + fs::create_dir_all(&out_path)?; + } else { + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent)?; + } + let mut out_file = fs::File::create(&out_path)?; + copy(&mut entry, &mut out_file)?; + } + } + + let top_level: Vec<_> = fs::read_dir(extract_root)? + .filter_map(|e| e.ok()) + .collect(); + + if top_level.len() != 1 { + bail!( + "expected exactly one top-level folder in zip {}, found {}", + zip_path.display(), + top_level.len() + ); + } + + let world_root = top_level[0].path(); + if !world_root.is_dir() { + bail!( + "expected top-level entry in zip to be a directory: {}", + zip_path.display() + ); + } + + if is_player_folder(&world_root) { + bail!( + "zip contains a client player cache folder (ends with `_player`): {}", + world_root.display() + ); + } + + let world_name = world_root + .file_name() + .and_then(|n| n.to_str()) + .context("extracted world directory has no name")? + .to_string(); + + Ok(ResolvedInput { + world_root, + world_name, + _temp_dir: Some(temp_dir), + }) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d8d7e95 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,64 @@ +mod convert; +mod copy; +mod input; +mod mods; +mod password; +mod players_db; + +use anyhow::Result; +use clap::Parser; +use std::path::PathBuf; + +#[derive(Parser)] +#[command( + name = "zomboid-converter", + about = "Convert a locally hosted Project Zomboid multiplayer world into a dedicated server save layout" +)] +struct Cli { + /// Hosted world directory or .zip archive (not the `_player` folder) + #[arg(short, long)] + input: PathBuf, + + /// Dedicated server-data root (writes Saves/Multiplayer//) + #[arg(short, long)] + output: PathBuf, + + /// Target world and config basename (e.g. pzserver) + #[arg(short = 'n', long, default_value = "pzserver")] + server_name: String, + + /// Optional directory containing hosted Server/ config files to copy and rename + #[arg(long)] + server_config: Option, + + /// Steam workshop content dir for app 108600 (resolves mod IDs → WorkshopItems) + #[arg(long)] + workshop_dir: Option, + + /// Server join password (Password= in server ini). Overrides .env when set. + #[arg(long)] + password: Option, + + /// Path to .env for SERVER_PASSWORD/PASSWORD (default: /../.env) + #[arg(long)] + env_file: Option, + + /// Replace an existing destination world folder + #[arg(long)] + force: bool, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + convert::convert(&convert::ConvertOptions { + input: &cli.input, + output: &cli.output, + server_name: &cli.server_name, + server_config: cli.server_config.as_deref(), + workshop_dir: cli.workshop_dir.as_deref(), + password: cli.password.as_deref(), + env_file: cli.env_file.as_deref(), + force: cli.force, + }) +} diff --git a/src/mods.rs b/src/mods.rs new file mode 100644 index 0000000..40af573 --- /dev/null +++ b/src/mods.rs @@ -0,0 +1,189 @@ +use anyhow::{Context, Result}; +use std::collections::{BTreeSet, HashMap}; +use std::fs; +use std::path::Path; + +const VANILLA_MOD: &str = "pz-vanilla"; + +/// Extract unique mod IDs from WorldDictionaryReadable.lua (excludes pz-vanilla). +pub fn extract_mod_ids(world_root: &Path) -> Result> { + let dict_path = world_root.join("WorldDictionaryReadable.lua"); + let text = fs::read_to_string(&dict_path).with_context(|| { + format!( + "failed to read WorldDictionaryReadable.lua at {}", + dict_path.display() + ) + })?; + + let mut mods = BTreeSet::new(); + for line in text.lines() { + if let Some(id) = parse_mod_id_line(line) { + if id != VANILLA_MOD { + mods.insert(id); + } + } + } + + // damnlib is a common dependency — load it first when present + let mut ordered: Vec = mods.into_iter().collect(); + if let Some(pos) = ordered.iter().position(|m| m == "damnlib") { + let lib = ordered.remove(pos); + ordered.insert(0, lib); + } + + Ok(ordered) +} + +fn parse_mod_id_line(line: &str) -> Option { + let key = "modID = \""; + let start = line.find(key)? + key.len(); + let rest = &line[start..]; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} + +/// Resolve mod folder IDs to Steam Workshop item IDs by scanning a Steam workshop content dir +/// (`.../steamapps/workshop/content/108600`). +pub fn resolve_workshop_ids( + mod_ids: &[String], + workshop_content_dir: &Path, +) -> Result<(Vec, Vec)> { + let catalog = index_workshop_mods(workshop_content_dir)?; + let mut workshop_ids = Vec::new(); + let mut missing = Vec::new(); + let mut seen = BTreeSet::new(); + + for mod_id in mod_ids { + match catalog.get(mod_id) { + Some(wid) => { + if seen.insert(wid.clone()) { + workshop_ids.push(wid.clone()); + } + } + None => missing.push(mod_id.clone()), + } + } + + if !missing.is_empty() { + anyhow::bail!( + "could not resolve Workshop IDs for {} mod(s): {}", + missing.len(), + missing.join(", ") + ); + } + + Ok((workshop_ids, missing)) +} + +fn index_workshop_mods(workshop_content_dir: &Path) -> Result> { + let mut catalog = HashMap::new(); + if !workshop_content_dir.is_dir() { + anyhow::bail!( + "workshop content directory not found: {}", + workshop_content_dir.display() + ); + } + + for entry in fs::read_dir(workshop_content_dir)? { + let entry = entry?; + let name = entry.file_name(); + let wid = name.to_string_lossy(); + if !entry.file_type()?.is_dir() || !wid.chars().all(|c| c.is_ascii_digit()) { + continue; + } + + scan_mod_infos(entry.path().as_path(), &wid, &mut catalog)?; + } + + Ok(catalog) +} + +fn scan_mod_infos(dir: &Path, workshop_id: &str, catalog: &mut HashMap) -> Result<()> { + let mut stack = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + for entry in fs::read_dir(¤t)? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() { + stack.push(path); + continue; + } + if entry.file_name() == "mod.info" { + let text = fs::read_to_string(&path)?; + if let Some(mod_id) = parse_mod_info_id(&text) { + catalog.entry(mod_id).or_insert_with(|| workshop_id.to_string()); + } + } + } + } + Ok(()) +} + +fn parse_mod_info_id(text: &str) -> Option { + for line in text.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("id=") { + return Some(rest.trim().to_string()); + } + // rare: "id = foo" + if let Some(rest) = line.strip_prefix("id") { + let rest = rest.trim_start(); + if let Some(rest) = rest.strip_prefix('=') { + return Some(rest.trim().to_string()); + } + } + } + None +} + +/// Patch Mods= and WorkshopItems= in an existing server .ini (preserves other settings). +pub fn patch_server_ini_mods(ini_path: &Path, mods: &str, workshop_items: &str) -> Result<()> { + let text = fs::read_to_string(ini_path) + .with_context(|| format!("failed to read {}", ini_path.display()))?; + + let mut out = String::with_capacity(text.len() + mods.len() + workshop_items.len()); + let mut saw_mods = false; + let mut saw_workshop = false; + + for line in text.lines() { + if let Some(rest) = line.strip_prefix("Mods=") { + let _ = rest; + out.push_str("Mods="); + out.push_str(mods); + out.push('\n'); + saw_mods = true; + } else if let Some(rest) = line.strip_prefix("WorkshopItems=") { + let _ = rest; + out.push_str("WorkshopItems="); + out.push_str(workshop_items); + out.push('\n'); + saw_workshop = true; + } else { + out.push_str(line); + out.push('\n'); + } + } + + if !saw_mods { + anyhow::bail!("no Mods= line found in {}", ini_path.display()); + } + if !saw_workshop { + anyhow::bail!("no WorkshopItems= line found in {}", ini_path.display()); + } + + fs::write(ini_path, out).with_context(|| format!("failed to write {}", ini_path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_mod_id_line() { + assert_eq!( + parse_mod_id_line(r#" modID = "damnlib","#), + Some("damnlib".into()) + ); + } +} diff --git a/src/password.rs b/src/password.rs new file mode 100644 index 0000000..3a44c3d --- /dev/null +++ b/src/password.rs @@ -0,0 +1,131 @@ +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Resolve server join password: CLI override wins, else `.env` keys. +pub fn resolve_password( + cli_password: Option<&str>, + env_file: Option<&Path>, + output: &Path, +) -> Result> { + if let Some(pw) = cli_password { + if !pw.is_empty() { + return Ok(Some(pw.to_string())); + } + } + + let env_path = match env_file { + Some(p) => Some(p.to_path_buf()), + None => default_env_path(output), + }; + + let Some(env_path) = env_path else { + return Ok(None); + }; + + if !env_path.is_file() { + return Ok(None); + } + + let vars = parse_env_file(&env_path)?; + for key in ["SERVER_PASSWORD", "PASSWORD"] { + if let Some(val) = vars.get(key) { + if !val.is_empty() { + return Ok(Some(val.clone())); + } + } + } + + Ok(None) +} + +fn default_env_path(output: &Path) -> Option { + // output is typically .../server/server-data → sibling .env at .../server/.env + let candidate = output.join("..").join(".env"); + let candidate = candidate.canonicalize().ok().filter(|p| p.is_file()); + if candidate.is_some() { + return candidate; + } + let candidate = output.join(".env"); + if candidate.is_file() { + return Some(candidate); + } + None +} + +pub fn parse_env_file(path: &Path) -> Result> { + let text = fs::read_to_string(path) + .with_context(|| format!("failed to read env file: {}", path.display()))?; + let mut map = HashMap::new(); + + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let mut value = value.trim().to_string(); + if (value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\'')) + { + value = value[1..value.len() - 1].to_string(); + } + map.insert(key.to_string(), value); + } + + Ok(map) +} + +/// Patch a single KEY=value line in a server .ini (exact key prefix match). +pub fn patch_ini_key(ini_path: &Path, key: &str, value: &str) -> Result<()> { + let text = fs::read_to_string(ini_path) + .with_context(|| format!("failed to read {}", ini_path.display()))?; + let prefix = format!("{key}="); + let mut out = String::with_capacity(text.len() + value.len()); + let mut saw = false; + + for line in text.lines() { + if line.starts_with(&prefix) { + out.push_str(&prefix); + out.push_str(value); + out.push('\n'); + saw = true; + } else { + out.push_str(line); + out.push('\n'); + } + } + + if !saw { + anyhow::bail!("no {key}= line found in {}", ini_path.display()); + } + + fs::write(ini_path, out).with_context(|| format!("failed to write {}", ini_path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn parses_env_and_prefers_cli() { + let mut f = NamedTempFile::new().unwrap(); + writeln!(f, "SERVER_PASSWORD=fromenv").unwrap(); + let path = f.path(); + let vars = parse_env_file(path).unwrap(); + assert_eq!(vars.get("SERVER_PASSWORD").unwrap(), "fromenv"); + + let resolved = resolve_password(Some("clipw"), Some(path), Path::new("/tmp")).unwrap(); + assert_eq!(resolved.as_deref(), Some("clipw")); + + let resolved = resolve_password(None, Some(path), Path::new("/tmp")).unwrap(); + assert_eq!(resolved.as_deref(), Some("fromenv")); + } +} diff --git a/src/players_db.rs b/src/players_db.rs new file mode 100644 index 0000000..d696303 --- /dev/null +++ b/src/players_db.rs @@ -0,0 +1,14 @@ +use anyhow::{Context, Result}; +use rusqlite::Connection; +use std::path::Path; + +pub fn patch_world_column(players_db: &Path, server_name: &str) -> Result { + let conn = Connection::open(players_db) + .with_context(|| format!("failed to open players.db: {}", players_db.display()))?; + + let updated = conn + .execute("UPDATE networkPlayers SET world = ?1", [server_name]) + .with_context(|| "failed to update networkPlayers.world")?; + + Ok(updated) +}