From 276be4f1846042b9e90c1becd2bb140ff48f44ec Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Thu, 29 May 2025 16:07:14 +0200 Subject: [PATCH 01/24] add `zip` and `tempdir` (dev) dependency --- Cargo.lock | 494 +++++++++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 2 + 2 files changed, 483 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 04ce844..a64cbe3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,17 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[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 = "aho-corasick" version = "1.1.3" @@ -45,6 +56,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atty" version = "0.2.14" @@ -101,7 +121,7 @@ checksum = "fab383d79e3f1fe444c2161c3a0331d0e1478f7fb74bde75e3f5032577a3f706" dependencies = [ "autocxx-engine", "env_logger", - "indexmap", + "indexmap 1.9.3", "syn 2.0.98", ] @@ -116,7 +136,7 @@ dependencies = [ "autocxx-parser", "cc", "cxx-gen", - "indexmap", + "indexmap 1.9.3", "indoc", "itertools 0.10.5", "log", @@ -153,7 +173,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "893a36d31f4618434f78f890f3136b039bbe6a719919e03eb249835e849454b7" dependencies = [ - "indexmap", + "indexmap 1.9.3", "itertools 0.10.5", "log", "once_cell", @@ -195,12 +215,48 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" +[[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.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[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.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c3d1b2e905a3a7b00a6141adb0e4c0bb941d11caf55349d863942a1cc44e3c9" dependencies = [ + "jobserver", + "libc", "shlex", ] @@ -219,6 +275,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[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 = "clang-sys" version = "1.8.1" @@ -287,6 +353,40 @@ dependencies = [ "windows-sys", ] +[[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 = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cxx" version = "1.0.140" @@ -344,12 +444,49 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "diff" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[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 = "either" version = "1.13.0" @@ -375,6 +512,12 @@ dependencies = [ "termcolor", ] +[[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.10" @@ -391,12 +534,33 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "flate2" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +dependencies = [ + "crc32fast", + "libz-rs-sys", + "miniz_oxide", +] + [[package]] name = "foldhash" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" +[[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.1" @@ -404,8 +568,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", "windows-targets", ] @@ -427,6 +593,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" + [[package]] name = "hermit-abi" version = "0.1.19" @@ -442,6 +614,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "humantime" version = "2.1.0" @@ -455,16 +636,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.12.3", "serde", ] +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown 0.15.3", +] + [[package]] name = "indoc" version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "insta" version = "1.43.1" @@ -526,6 +726,25 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.169" @@ -542,6 +761,26 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "liblzma" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66352d7a8ac12d4877b6e6ea5a9b7650ee094257dc40889955bea5bc5b08c1d0" +dependencies = [ + "liblzma-sys", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5839bad90c3cc2e0b8c4ed8296b80e86040240f81d46b9c0e9bc8dd51ddd3af1" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "libsbml" version = "0.1.0" @@ -557,8 +796,19 @@ dependencies = [ "pretty_assertions", "quick-xml", "serde", + "tempfile", "thiserror 2.0.12", "vcpkg", + "zip", +] + +[[package]] +name = "libz-rs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6489ca9bd760fe9642d7644e827b0c9add07df89857b0416ee15c1cc1a3b8c5a" +dependencies = [ + "zlib-rs", ] [[package]] @@ -572,15 +822,15 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.4.15" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "log" -version = "0.4.25" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "memchr" @@ -654,6 +904,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "object" version = "0.36.7" @@ -681,12 +937,28 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[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.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -802,9 +1074,9 @@ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustix" -version = "0.38.44" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" dependencies = [ "bitflags", "errno", @@ -857,12 +1129,29 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "similar" version = "2.7.0" @@ -881,6 +1170,12 @@ 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 = "supports-color" version = "2.1.0" @@ -933,11 +1228,10 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.17.1" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ - "cfg-if", "fastrand", "getrandom", "once_cell", @@ -1015,6 +1309,31 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + [[package]] name = "unicode-ident" version = "1.0.16" @@ -1054,6 +1373,63 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.98", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1172,3 +1548,95 @@ name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "zip" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153a6fff49d264c4babdcfa6b4d534747f520e56e8f0f384f3b808c4b64cc1fd" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom", + "hmac", + "indexmap 2.9.0", + "liblzma", + "memchr", + "pbkdf2", + "sha1", + "time", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "868b928d7949e09af2f6086dfc1e01936064cc7a819253bce650d4e2a2d63ba8" + +[[package]] +name = "zopfli" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +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.15+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 29be5af..ebc2153 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ paste = "1.0.15" quick-xml = { version = "0.37.2", features = ["serialize"] } serde = { version = "1.0.217", features = ["derive"] } thiserror = "2.0.12" +zip = "4.0.0" [build-dependencies] autocxx-build = "0.28.0" @@ -31,6 +32,7 @@ vcpkg = "0.2.15" [dev-dependencies] insta = "1.43.1" pretty_assertions = "1.4.1" +tempfile = "3.20.0" [lints.clippy] needless-lifetimes = "allow" From 777e17027e1090d0fb2954e8d9d059d4f816a9d4 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Thu, 29 May 2025 16:07:29 +0200 Subject: [PATCH 02/24] add combine archive --- src/combine/combinearchive.rs | 1178 +++++++++++++++++++++++++++++++++ src/combine/error.rs | 35 + src/combine/manifest.rs | 442 +++++++++++++ src/lib.rs | 7 + 4 files changed, 1662 insertions(+) create mode 100644 src/combine/combinearchive.rs create mode 100644 src/combine/error.rs create mode 100644 src/combine/manifest.rs diff --git a/src/combine/combinearchive.rs b/src/combine/combinearchive.rs new file mode 100644 index 0000000..a7783e4 --- /dev/null +++ b/src/combine/combinearchive.rs @@ -0,0 +1,1178 @@ +use std::{ + collections::HashMap, + io::{Cursor, Read, Write}, + path::Path, +}; +use zip::{write::SimpleFileOptions, ZipArchive, ZipWriter}; + +use crate::combine::manifest::OmexManifest; + +use super::{error::CombineArchiveError, manifest::Content}; + +/// A COMBINE Archive (OMEX) implementation for managing collections of files +/// with metadata according to the COMBINE Archive specification. +/// +/// The COMBINE Archive format is used in computational biology to package +/// models, data, and metadata together in a standardized way. This implementation +/// provides a high-level interface for creating, reading, and modifying OMEX files. +pub struct CombineArchive { + /// The manifest containing metadata about all files in the archive + pub manifest: OmexManifest, + + /// Optional path to the archive file on disk + path: Option, + + // Internal state for efficient mutation tracking + /// Original ZIP data when loaded from file + original_zip: Option>, + /// New or modified entries waiting to be written + pending_entries: HashMap>, + /// Entries marked for removal + removed_entries: std::collections::HashSet, + /// Flag indicating if the archive needs to be rebuilt + needs_rebuild: bool, +} + +/// Represents a single entry (file) within a COMBINE Archive. +/// +/// An entry contains both the file data and its associated metadata +/// from the manifest. +pub struct Entry { + /// Metadata about this entry from the manifest + pub content: Content, + /// The raw file data + pub data: Vec, +} + +impl CombineArchive { + /// Creates a new empty COMBINE Archive. + /// + /// The archive will have an empty manifest and no associated file path. + /// Use [`add_entry`](Self::add_entry) or [`add_file`](Self::add_file) to add content. + pub fn new() -> Self { + Self { + manifest: OmexManifest::new(), + path: None, + original_zip: None, + pending_entries: HashMap::new(), + removed_entries: std::collections::HashSet::new(), + needs_rebuild: false, + } + } + + /// Opens an existing COMBINE Archive from a file. + /// + /// This method reads the ZIP file, extracts and parses the manifest, + /// and prepares the archive for reading and modification. + /// + /// # Arguments + /// + /// * `path` - Path to the OMEX file to open + /// + /// # Returns + /// + /// Returns a `CombineArchive` instance on success, or a `CombineArchiveError` + /// if the file cannot be read or is not a valid COMBINE Archive. + /// + /// # Errors + /// + /// * `CombineArchiveError::Io` - If the file cannot be read + /// * `CombineArchiveError::Zip` - If the file is not a valid ZIP archive + /// * `CombineArchiveError::Manifest` - If the manifest.xml is missing or invalid + pub fn open>(path: P) -> Result { + let path_buf = path.as_ref().to_path_buf(); + let zip_data = std::fs::read(&path_buf)?; + + // Extract and parse the manifest + let manifest = Self::extract_manifest(&zip_data)?; + + Ok(Self { + manifest, + path: Some(path_buf), + original_zip: Some(zip_data), + pending_entries: HashMap::new(), + removed_entries: std::collections::HashSet::new(), + needs_rebuild: false, + }) + } + + /// Adds a file from the filesystem to the archive. + /// + /// This is a convenience method that reads a file from disk and adds it + /// to the archive with the specified metadata. + /// + /// # Arguments + /// + /// * `file_path` - Path to the file on disk to add + /// * `location` - Location within the archive (e.g., "./model.xml") + /// * `format` - MIME type or format identifier for the file + /// * `master` - Whether this file is the master file of the archive + /// + /// # Errors + /// + /// * `CombineArchiveError::Io` - If the file cannot be read + /// * `CombineArchiveError::Manifest` - If there's an error updating the manifest + pub fn add_file>( + &mut self, + file_path: P, + location: impl Into, + format: impl Into, + master: bool, + ) -> Result<(), CombineArchiveError> { + let data = std::fs::read(file_path)?; + self.add_entry(location, format, master, &data[..]) + } + + /// Adds data to the archive from any source that implements `Read`. + /// + /// This is the primary method for adding content to the archive. It updates + /// the manifest and stages the data for writing. If an entry with the same + /// location already exists, it will be updated if the format and master flag + /// match, or replaced if they differ. + /// + /// # Arguments + /// + /// * `location` - Location within the archive (e.g., "./model.xml") + /// * `format` - MIME type or format identifier for the file + /// * `master` - Whether this file is the master file of the archive + /// * `data` - Data source implementing `Read` + /// + /// # Behavior with Existing Entries + /// + /// - If an entry exists with the same location, format, and master flag: + /// the data is updated while preserving the manifest entry + /// - If an entry exists but format or master flag differs: + /// the old entry is completely replaced + /// + /// # Errors + /// + /// * `CombineArchiveError::Io` - If reading from the data source fails + /// * `CombineArchiveError::Manifest` - If there's an error updating the manifest + pub fn add_entry( + &mut self, + location: impl Into, + format: impl Into, + master: bool, + mut data: impl Read, + ) -> Result<(), CombineArchiveError> { + let location = location.into(); + let format = format.into(); + + // Check if entry already exists and handle accordingly + if let Some(existing_content) = self.find_content(&location) { + if existing_content.format == format && existing_content.master == master { + // Same metadata - just update the data + let mut data_buf = Vec::new(); + data.read_to_end(&mut data_buf)?; + + let zip_location = location.replace("./", ""); + self.removed_entries.remove(&zip_location); + self.pending_entries.insert(zip_location, data_buf); + self.needs_rebuild = true; + return Ok(()); + } else { + // Different metadata - remove the old entry first + self.manifest.content.retain(|c| c.location != location); + } + } + + // Add new entry to manifest + self.manifest.add_entry(location.clone(), format, master)?; + + // Read and store the data + let mut data_buf = Vec::new(); + data.read_to_end(&mut data_buf)?; + + let zip_location = location.replace("./", ""); + self.removed_entries.remove(&zip_location); + self.pending_entries.insert(zip_location, data_buf); + self.needs_rebuild = true; + + Ok(()) + } + + /// Removes an entry from the archive. + /// + /// This removes both the file data and its metadata from the manifest. + /// The change is staged and will take effect when the archive is saved. + /// + /// # Arguments + /// + /// * `location` - Location of the entry to remove (e.g., "./model.xml") + /// + /// # Note + /// + /// Removing the master file will leave the archive without a master file, + /// which may make it invalid according to the COMBINE specification. + pub fn remove_entry(&mut self, location: &str) -> Result<(), CombineArchiveError> { + let zip_location = location.replace("./", ""); + + // Remove from manifest + self.manifest.content.retain(|c| c.location != location); + + // Mark for removal from ZIP + self.removed_entries.insert(zip_location.clone()); + self.pending_entries.remove(&zip_location); + self.needs_rebuild = true; + + Ok(()) + } + + /// Retrieves an entry from the archive. + /// + /// This method returns both the file data and its metadata. It will check + /// pending changes first, then fall back to the original archive data. + /// + /// # Arguments + /// + /// * `location` - Location of the entry to retrieve (e.g., "./model.xml") + /// + /// # Returns + /// + /// Returns an `Entry` containing the file data and metadata, or an error + /// if the entry doesn't exist or cannot be read. + /// + /// # Errors + /// + /// * `CombineArchiveError::FileNotFound` - If the entry doesn't exist + /// * `CombineArchiveError::Zip` - If there's an error reading from the ZIP + /// * `CombineArchiveError::Io` - If there's an I/O error + pub fn entry(&mut self, location: &str) -> Result { + if !self.manifest.has_location(location) { + return Err(CombineArchiveError::FileNotFound(location.to_string())); + } + + let zip_location = location.replace("./", ""); + + // Check pending entries first (most recent changes) + if let Some(data) = self.pending_entries.get(&zip_location) { + return Ok(Entry { + content: self.find_content(location).unwrap().clone(), + data: data.clone(), + }); + } + + // Check if it was removed + if self.removed_entries.contains(&zip_location) { + return Err(CombineArchiveError::FileNotFound(location.to_string())); + } + + // Read from original ZIP archive + if let Some(ref zip_data) = self.original_zip { + let mut archive = ZipArchive::new(Cursor::new(zip_data))?; + let mut file = archive.by_name(&zip_location)?; + let mut data = Vec::new(); + file.read_to_end(&mut data)?; + + return Ok(Entry { + content: self.find_content(location).unwrap().clone(), + data, + }); + } + + Err(CombineArchiveError::FileNotFound(location.to_string())) + } + + /// Retrieves the master file of the archive. + /// + /// The master file is the primary file in a COMBINE Archive, typically + /// the main model or simulation description. + /// + /// # Returns + /// + /// Returns an `Entry` for the master file, or an error if no master file + /// is defined or it cannot be read. + /// + /// # Errors + /// + /// * `CombineArchiveError::MasterFileNotFound` - If no master file is defined + /// * Other errors from [`entry`](Self::entry) method + pub fn master(&mut self) -> Result { + let location = self + .manifest + .master_file() + .ok_or(CombineArchiveError::MasterFileNotFound)? + .location + .clone(); + self.entry(&location) + } + + /// Lists all entries in the archive. + /// + /// Returns references to the metadata for all files in the archive. + /// This reflects the current state including any pending additions or removals. + /// + /// # Returns + /// + /// A vector of references to `Content` objects representing each entry's metadata. + pub fn list_entries(&self) -> Vec<&Content> { + self.manifest.content.iter().collect() + } + + /// Checks if an entry exists in the archive. + /// + /// This checks the manifest for the specified location, reflecting + /// any pending changes. + /// + /// # Arguments + /// + /// * `location` - Location to check (e.g., "./model.xml") + /// + /// # Returns + /// + /// `true` if the entry exists, `false` otherwise. + pub fn has_entry(&self, location: &str) -> bool { + self.manifest.has_location(location) + } + + /// Saves the archive to a file. + /// + /// This method builds the complete ZIP archive with all current entries + /// and writes it to the specified path. After saving, the internal state + /// is updated to reflect the saved state. + /// + /// # Arguments + /// + /// * `path` - Path where the archive should be saved + /// + /// # Errors + /// + /// * `CombineArchiveError::Io` - If the file cannot be written + /// * `CombineArchiveError::Zip` - If there's an error creating the ZIP + /// * `CombineArchiveError::Manifest` - If the manifest cannot be serialized + pub fn save>(&mut self, path: P) -> Result<(), CombineArchiveError> { + let zip_data = self.build_zip()?; + std::fs::write(path, &zip_data)?; + + // Update internal state to reflect saved state + self.original_zip = Some(zip_data); + self.pending_entries.clear(); + self.removed_entries.clear(); + self.needs_rebuild = false; + + Ok(()) + } + + /// Saves changes to the original file. + /// + /// This method is only available for archives that were opened from a file. + /// It saves the current state back to the original file path. + /// + /// # Errors + /// + /// * `CombineArchiveError::NoPath` - If the archive wasn't opened from a file + /// * Other errors from [`save`](Self::save) method + pub fn save_changes(&mut self) -> Result<(), CombineArchiveError> { + if let Some(ref path) = self.path.clone() { + self.save(path) + } else { + Err(CombineArchiveError::NoPath) + } + } + + /// Gets the archive as bytes without saving to disk. + /// + /// This method builds the complete ZIP archive in memory and returns + /// the raw bytes. Useful for streaming or when you don't want to + /// create a temporary file. + /// + /// # Returns + /// + /// Returns the complete archive as a byte vector. + /// + /// # Errors + /// + /// * `CombineArchiveError::Zip` - If there's an error creating the ZIP + /// * `CombineArchiveError::Manifest` - If the manifest cannot be serialized + pub fn to_bytes(&mut self) -> Result, CombineArchiveError> { + self.build_zip() + } + + // Private helper methods + + /// Extracts and parses the manifest from ZIP data. + fn extract_manifest(zip_data: &[u8]) -> Result { + let mut archive = ZipArchive::new(Cursor::new(zip_data))?; + let mut manifest_buf = Vec::new(); + archive + .by_name("manifest.xml")? + .read_to_end(&mut manifest_buf)?; + let manifest = OmexManifest::from_xml(&String::from_utf8(manifest_buf).unwrap())?; + Ok(manifest) + } + + /// Finds content metadata by location. + fn find_content(&self, location: &str) -> Option<&Content> { + self.manifest + .content + .iter() + .find(|c| c.location == location) + } + + /// Builds the complete ZIP archive with current state. + fn build_zip(&self) -> Result, CombineArchiveError> { + let mut buffer = Vec::new(); + let mut writer = ZipWriter::new(Cursor::new(&mut buffer)); + let options = + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + + // Copy entries from original ZIP that aren't removed or overwritten + if let Some(ref original_data) = self.original_zip { + let mut original_archive = ZipArchive::new(Cursor::new(original_data))?; + for i in 0..original_archive.len() { + let mut file = original_archive.by_index(i)?; + let name = file.name().to_string(); + + // Skip if removed, overwritten, or is manifest (we'll add manifest last) + if self.removed_entries.contains(&name) + || self.pending_entries.contains_key(&name) + || name == "manifest.xml" + { + continue; + } + + writer.start_file(&name, options)?; + std::io::copy(&mut file, &mut writer)?; + } + } + + // Add all pending entries (new or modified files) + for (name, data) in &self.pending_entries { + writer.start_file(name, options)?; + writer.write_all(data)?; + } + + // Always add manifest last to ensure it's up to date + let manifest_xml = self.manifest.to_xml().map_err(|e| { + CombineArchiveError::Manifest(quick_xml::DeError::Custom(e.to_string())) + })?; + writer.start_file("manifest.xml", options)?; + writer.write_all(manifest_xml.as_bytes())?; + + writer.finish()?; + Ok(buffer) + } +} + +impl Default for CombineArchive { + fn default() -> Self { + Self::new() + } +} + +impl Entry { + /// Converts the entry data to a UTF-8 string. + /// + /// This is useful for text-based files like XML, CSV, or JSON. + /// + /// # Returns + /// + /// Returns the file content as a string, or an error if the data + /// is not valid UTF-8. + /// + /// # Errors + /// + /// Returns `std::string::FromUtf8Error` if the data is not valid UTF-8. + pub fn as_string(&self) -> Result { + String::from_utf8(self.data.clone()) + } + + /// Gets the raw data bytes. + /// + /// Returns a slice of the raw file data. This works for both + /// text and binary files. + pub fn as_bytes(&self) -> &[u8] { + &self.data + } + + /// Creates a reader for the entry data. + /// + /// Returns a `Cursor` that implements `Read` and `Seek`, allowing + /// you to read the data incrementally or seek to specific positions. + pub fn reader(&self) -> Cursor<&[u8]> { + Cursor::new(&self.data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_test_dir() -> TempDir { + tempfile::tempdir().unwrap() + } + + #[test] + fn test_new_archive_creation() { + let archive = CombineArchive::new(); + assert_eq!(archive.list_entries().len(), 0); + assert!(!archive.has_entry("./test.xml")); + assert!(archive.path.is_none()); + assert!(!archive.needs_rebuild); + } + + #[test] + fn test_open_archive_to_sbml() { + let archive_path = Path::new("tests/data/test.omex"); + let mut archive = CombineArchive::open(&archive_path).unwrap(); + + // Get the master SBML file + let master = archive.master().unwrap(); + let xml_string = master.as_string().unwrap(); + + let expected_path = Path::new("tests/data/expected_omex_content.xml"); + let expected_content = fs::read_to_string(expected_path).unwrap(); + assert_eq!(xml_string, expected_content); + + // Check the CSV content + let csv_entry = archive.entry("./data.tsv").unwrap(); + let csv_string = csv_entry.as_string().unwrap(); + let expected_csv_path = Path::new("tests/data/expected_omex_data.tsv"); + let expected_csv_content = fs::read_to_string(expected_csv_path).unwrap(); + assert_eq!(csv_string, expected_csv_content); + } + + #[test] + fn test_add_entry_basic() { + let mut archive = CombineArchive::new(); + + archive + .add_entry( + "./model.xml", + "http://identifiers.org/combine.specifications/sbml", + true, + b"model".as_slice(), + ) + .unwrap(); + + assert_eq!(archive.list_entries().len(), 1); + assert!(archive.has_entry("./model.xml")); + assert!(archive.needs_rebuild); + + let entry = archive.entry("./model.xml").unwrap(); + assert_eq!(entry.as_string().unwrap(), "model"); + assert_eq!( + entry.content.format, + "http://identifiers.org/combine.specifications/sbml" + ); + assert!(entry.content.master); + } + + #[test] + fn test_add_multiple_entries() { + let mut archive = CombineArchive::new(); + + // Add multiple entries + archive + .add_entry( + "./model.xml", + "http://identifiers.org/combine.specifications/sbml", + true, + b"model".as_slice(), + ) + .unwrap(); + + archive + .add_entry("./data.csv", "text/csv", false, b"x,y\n1,2\n3,4".as_slice()) + .unwrap(); + + archive + .add_entry( + "./script.py", + "text/x-python", + false, + b"print('hello world')".as_slice(), + ) + .unwrap(); + + assert_eq!(archive.list_entries().len(), 3); + assert!(archive.has_entry("./model.xml")); + assert!(archive.has_entry("./data.csv")); + assert!(archive.has_entry("./script.py")); + + // Check master file + let master = archive.master().unwrap(); + assert_eq!(master.as_string().unwrap(), "model"); + } + + #[test] + fn test_add_file_from_disk() { + let temp_dir = create_test_dir(); + let file_path = temp_dir.path().join("test.txt"); + fs::write(&file_path, "Hello from file!").unwrap(); + + let mut archive = CombineArchive::new(); + archive + .add_file(&file_path, "./test.txt", "text/plain", false) + .unwrap(); + + assert!(archive.has_entry("./test.txt")); + let entry = archive.entry("./test.txt").unwrap(); + assert_eq!(entry.as_string().unwrap(), "Hello from file!"); + } + + #[test] + fn test_end_to_end_save_and_load() { + let temp_dir = create_test_dir(); + let archive_path = temp_dir.path().join("test.omex"); + + // Create and populate archive + let mut archive = CombineArchive::new(); + archive + .add_entry( + "./model.xml", + "http://identifiers.org/combine.specifications/sbml", + true, + b"test model".as_slice(), + ) + .unwrap(); + + archive + .add_entry( + "./data.csv", + "text/csv", + false, + b"time,value\n0,1\n1,2\n2,3".as_slice(), + ) + .unwrap(); + + // Save to disk + archive.save(&archive_path).unwrap(); + assert!(archive_path.exists()); + assert!(!archive.needs_rebuild); // Should be clean after save + + // Load from disk + let mut loaded_archive = CombineArchive::open(&archive_path).unwrap(); + assert_eq!(loaded_archive.list_entries().len(), 2); + assert!(loaded_archive.has_entry("./model.xml")); + assert!(loaded_archive.has_entry("./data.csv")); + + // Verify content + let model_entry = loaded_archive.entry("./model.xml").unwrap(); + assert_eq!( + model_entry.as_string().unwrap(), + "test model" + ); + assert!(model_entry.content.master); + + let data_entry = loaded_archive.entry("./data.csv").unwrap(); + assert_eq!(data_entry.as_string().unwrap(), "time,value\n0,1\n1,2\n2,3"); + assert!(!data_entry.content.master); + + // Verify master file access + let master = loaded_archive.master().unwrap(); + assert_eq!( + master.as_string().unwrap(), + "test model" + ); + } + + #[test] + fn test_archive_mutation_add_remove() { + let temp_dir = create_test_dir(); + let archive_path = temp_dir.path().join("test.omex"); + + // Create initial archive + let mut archive = CombineArchive::new(); + archive + .add_entry( + "./model.xml", + "application/xml", + true, + b"v1".as_slice(), + ) + .unwrap(); + archive + .add_entry("./data1.csv", "text/csv", false, b"a,b\n1,2".as_slice()) + .unwrap(); + archive + .add_entry("./data2.csv", "text/csv", false, b"c,d\n3,4".as_slice()) + .unwrap(); + archive.save(&archive_path).unwrap(); + + // Load and mutate + let mut loaded_archive = CombineArchive::open(&archive_path).unwrap(); + assert_eq!(loaded_archive.list_entries().len(), 3); + + // Remove an entry + loaded_archive.remove_entry("./data1.csv").unwrap(); + assert_eq!(loaded_archive.list_entries().len(), 2); + assert!(!loaded_archive.has_entry("./data1.csv")); + assert!(loaded_archive.has_entry("./data2.csv")); + + // Add a new entry + loaded_archive + .add_entry( + "./script.py", + "text/x-python", + false, + b"print('new script')".as_slice(), + ) + .unwrap(); + assert_eq!(loaded_archive.list_entries().len(), 3); + assert!(loaded_archive.has_entry("./script.py")); + + // Modify existing entry (overwrite) + loaded_archive + .add_entry( + "./model.xml", + "application/xml", + true, + b"v2".as_slice(), + ) + .unwrap(); + + // Save changes + loaded_archive.save_changes().unwrap(); + + // Reload and verify mutations + let mut final_archive = CombineArchive::open(&archive_path).unwrap(); + assert_eq!(final_archive.list_entries().len(), 3); + assert!(!final_archive.has_entry("./data1.csv")); + assert!(final_archive.has_entry("./data2.csv")); + assert!(final_archive.has_entry("./script.py")); + assert!(final_archive.has_entry("./model.xml")); + + // Verify modified content + let model = final_archive.entry("./model.xml").unwrap(); + assert_eq!(model.as_string().unwrap(), "v2"); + + let script = final_archive.entry("./script.py").unwrap(); + assert_eq!(script.as_string().unwrap(), "print('new script')"); + } + + #[test] + fn test_complex_mutation_workflow() { + let temp_dir = create_test_dir(); + let archive_path = temp_dir.path().join("complex.omex"); + + // Create archive with multiple files + let mut archive = CombineArchive::new(); + for i in 1..=5 { + archive + .add_entry( + format!("./file{}.txt", i), + "text/plain", + i == 1, // First file is master + format!("Content of file {}", i).as_bytes(), + ) + .unwrap(); + } + archive.save(&archive_path).unwrap(); + + // Load and perform complex mutations + let mut archive = CombineArchive::open(&archive_path).unwrap(); + + // Remove some files + archive.remove_entry("./file2.txt").unwrap(); + archive.remove_entry("./file4.txt").unwrap(); + + // Add new files + archive + .add_entry( + "./new1.json", + "application/json", + false, + b"{\"new\": 1}".as_slice(), + ) + .unwrap(); + archive + .add_entry( + "./new2.xml", + "application/xml", + false, + b"2".as_slice(), + ) + .unwrap(); + + // Modify existing file + archive + .add_entry( + "./file3.txt", + "text/plain", + false, + b"Modified file 3".as_slice(), + ) + .unwrap(); + + // Save and reload + archive.save_changes().unwrap(); + let mut final_archive = CombineArchive::open(&archive_path).unwrap(); + + // Verify final state + assert_eq!(final_archive.list_entries().len(), 5); // 1,3,5 + new1,new2 + assert!(final_archive.has_entry("./file1.txt")); + assert!(!final_archive.has_entry("./file2.txt")); + assert!(final_archive.has_entry("./file3.txt")); + assert!(!final_archive.has_entry("./file4.txt")); + assert!(final_archive.has_entry("./file5.txt")); + assert!(final_archive.has_entry("./new1.json")); + assert!(final_archive.has_entry("./new2.xml")); + + // Verify content + let file3 = final_archive.entry("./file3.txt").unwrap(); + assert_eq!(file3.as_string().unwrap(), "Modified file 3"); + + let new1 = final_archive.entry("./new1.json").unwrap(); + assert_eq!(new1.as_string().unwrap(), "{\"new\": 1}"); + } + + #[test] + fn test_to_bytes_without_saving() { + let mut archive = CombineArchive::new(); + archive + .add_entry("./test.txt", "text/plain", true, b"test content".as_slice()) + .unwrap(); + + let bytes = archive.to_bytes().unwrap(); + assert!(!bytes.is_empty()); + + // Verify we can read the bytes back + let temp_dir = create_test_dir(); + let temp_path = temp_dir.path().join("from_bytes.omex"); + fs::write(&temp_path, &bytes).unwrap(); + + let mut loaded = CombineArchive::open(&temp_path).unwrap(); + assert!(loaded.has_entry("./test.txt")); + let entry = loaded.entry("./test.txt").unwrap(); + assert_eq!(entry.as_string().unwrap(), "test content"); + } + + #[test] + fn test_entry_methods() { + let mut archive = CombineArchive::new(); + archive + .add_entry( + "./test.txt", + "text/plain", + false, + b"Hello World!".as_slice(), + ) + .unwrap(); + + let entry = archive.entry("./test.txt").unwrap(); + + // Test different access methods + assert_eq!(entry.as_string().unwrap(), "Hello World!"); + assert_eq!(entry.as_bytes(), b"Hello World!"); + + let mut reader = entry.reader(); + let mut buffer = String::new(); + reader.read_to_string(&mut buffer).unwrap(); + assert_eq!(buffer, "Hello World!"); + } + + #[test] + fn test_error_cases() { + let mut archive = CombineArchive::new(); + + // Test file not found + assert!(matches!( + archive.entry("./nonexistent.txt"), + Err(CombineArchiveError::FileNotFound(_)) + )); + + // Test master file not found on empty archive + assert!(matches!( + archive.master(), + Err(CombineArchiveError::MasterFileNotFound) + )); + + // Test save_changes without path + assert!(matches!( + archive.save_changes(), + Err(CombineArchiveError::NoPath) + )); + + // Test opening non-existent file + assert!(CombineArchive::open("./nonexistent.omex").is_err()); + } + + #[test] + fn test_removed_entry_access() { + let temp_dir = create_test_dir(); + let archive_path = temp_dir.path().join("test.omex"); + + // Create archive with entry + let mut archive = CombineArchive::new(); + archive + .add_entry("./test.txt", "text/plain", true, b"content".as_slice()) + .unwrap(); + archive.save(&archive_path).unwrap(); + + // Load and remove entry + let mut archive = CombineArchive::open(&archive_path).unwrap(); + archive.remove_entry("./test.txt").unwrap(); + + // Try to access removed entry + assert!(matches!( + archive.entry("./test.txt"), + Err(CombineArchiveError::FileNotFound(_)) + )); + assert!(!archive.has_entry("./test.txt")); + } + + #[test] + fn test_location_normalization() { + let mut archive = CombineArchive::new(); + + // Add with "./" prefix + archive + .add_entry("./test.txt", "text/plain", false, b"content1".as_slice()) + .unwrap(); + + // Should be accessible both ways + assert!(archive.has_entry("./test.txt")); + let entry = archive.entry("./test.txt").unwrap(); + assert_eq!(entry.as_string().unwrap(), "content1"); + } + + #[test] + fn test_overwrite_entry() { + let mut archive = CombineArchive::new(); + + // Add initial entry + archive + .add_entry("./test.txt", "text/plain", false, b"original".as_slice()) + .unwrap(); + + // Overwrite with new content + archive + .add_entry("./test.txt", "text/plain", false, b"updated".as_slice()) + .unwrap(); + + // Should have updated content + let entry = archive.entry("./test.txt").unwrap(); + assert_eq!(entry.as_string().unwrap(), "updated"); + assert_eq!(archive.list_entries().len(), 1); // Should not duplicate + } + + #[test] + fn test_binary_data() { + let mut archive = CombineArchive::new(); + let binary_data = vec![0u8, 1, 2, 3, 255, 254, 253]; + + archive + .add_entry( + "./binary.dat", + "application/octet-stream", + false, + binary_data.as_slice(), + ) + .unwrap(); + + let entry = archive.entry("./binary.dat").unwrap(); + assert_eq!(entry.as_bytes(), binary_data.as_slice()); + + // String conversion should fail for binary data + assert!(entry.as_string().is_err()); + } + + #[test] + fn test_large_archive_operations() { + let temp_dir = create_test_dir(); + let archive_path = temp_dir.path().join("large.omex"); + + // Create archive with many entries + let mut archive = CombineArchive::new(); + for i in 0..100 { + archive + .add_entry( + format!("./file{:03}.txt", i), + "text/plain", + i == 0, // First file is master + format!("Content of file number {}", i).as_bytes(), + ) + .unwrap(); + } + + archive.save(&archive_path).unwrap(); + + // Load and verify all entries + let mut loaded = CombineArchive::open(&archive_path).unwrap(); + assert_eq!(loaded.list_entries().len(), 100); + + // Verify random entries + for i in [0, 25, 50, 75, 99] { + let entry = loaded.entry(&format!("./file{:03}.txt", i)).unwrap(); + assert_eq!( + entry.as_string().unwrap(), + format!("Content of file number {}", i) + ); + } + + // Remove half the entries + for i in (0..100).step_by(2) { + loaded.remove_entry(&format!("./file{:03}.txt", i)).unwrap(); + } + + assert_eq!(loaded.list_entries().len(), 50); + loaded.save_changes().unwrap(); + + // Reload and verify + let final_archive = CombineArchive::open(&archive_path).unwrap(); + assert_eq!(final_archive.list_entries().len(), 50); + } + + #[test] + fn test_update_entry_same_format() { + let mut archive = CombineArchive::new(); + + // Add initial entry + archive + .add_entry( + "./test.txt", + "text/plain", + false, + b"original content".as_slice(), + ) + .unwrap(); + + assert_eq!(archive.list_entries().len(), 1); + let entry = archive.entry("./test.txt").unwrap(); + assert_eq!(entry.as_string().unwrap(), "original content"); + + // Update with same format - should update content, keep manifest entry + archive + .add_entry( + "./test.txt", + "text/plain", + false, + b"updated content".as_slice(), + ) + .unwrap(); + + // Should still have only one entry + assert_eq!(archive.list_entries().len(), 1); + let entry = archive.entry("./test.txt").unwrap(); + assert_eq!(entry.as_string().unwrap(), "updated content"); + assert_eq!(entry.content.format, "text/plain"); + assert!(!entry.content.master); + } + + #[test] + fn test_update_entry_different_format() { + let mut archive = CombineArchive::new(); + + // Add initial entry + archive + .add_entry( + "./test.txt", + "text/plain", + false, + b"original content".as_slice(), + ) + .unwrap(); + + assert_eq!(archive.list_entries().len(), 1); + + // Update with different format - should replace manifest entry + archive + .add_entry( + "./test.txt", + "application/json", + false, + b"{\"updated\": true}".as_slice(), + ) + .unwrap(); + + // Should still have only one entry but with new format + assert_eq!(archive.list_entries().len(), 1); + let entry = archive.entry("./test.txt").unwrap(); + assert_eq!(entry.as_string().unwrap(), "{\"updated\": true}"); + assert_eq!(entry.content.format, "application/json"); + assert!(!entry.content.master); + } + + #[test] + fn test_update_entry_different_master_flag() { + let mut archive = CombineArchive::new(); + + // Add initial entry as non-master + archive + .add_entry("./test.txt", "text/plain", false, b"content".as_slice()) + .unwrap(); + + assert_eq!(archive.list_entries().len(), 1); + assert!(!archive.entry("./test.txt").unwrap().content.master); + + // Update with same format but different master flag + archive + .add_entry( + "./test.txt", + "text/plain", + true, + b"master content".as_slice(), + ) + .unwrap(); + + // Should still have only one entry but now as master + assert_eq!(archive.list_entries().len(), 1); + let entry = archive.entry("./test.txt").unwrap(); + assert_eq!(entry.as_string().unwrap(), "master content"); + assert_eq!(entry.content.format, "text/plain"); + assert!(entry.content.master); + } + + #[test] + fn test_end_to_end_with_updates() { + let temp_dir = create_test_dir(); + let archive_path = temp_dir.path().join("updates.omex"); + + // Create initial archive + let mut archive = CombineArchive::new(); + archive + .add_entry( + "./model.xml", + "application/xml", + true, + b"v1".as_slice(), + ) + .unwrap(); + archive + .add_entry("./data.csv", "text/csv", false, b"a,b\n1,2".as_slice()) + .unwrap(); + archive.save(&archive_path).unwrap(); + + // Load and update entries + let mut archive = CombineArchive::open(&archive_path).unwrap(); + + // Update model with same format (should preserve manifest entry) + archive + .add_entry( + "./model.xml", + "application/xml", + true, + b"v2".as_slice(), + ) + .unwrap(); + + // Update data with different format (should replace manifest entry) + archive + .add_entry( + "./data.csv", + "application/json", + false, + b"{\"data\": [1,2,3]}".as_slice(), + ) + .unwrap(); + + archive.save_changes().unwrap(); + + // Reload and verify + let mut final_archive = CombineArchive::open(&archive_path).unwrap(); + assert_eq!(final_archive.list_entries().len(), 2); + + let model = final_archive.entry("./model.xml").unwrap(); + assert_eq!(model.as_string().unwrap(), "v2"); + assert_eq!(model.content.format, "application/xml"); + assert!(model.content.master); + + let data = final_archive.entry("./data.csv").unwrap(); + assert_eq!(data.as_string().unwrap(), "{\"data\": [1,2,3]}"); + assert_eq!(data.content.format, "application/json"); + assert!(!data.content.master); + } +} diff --git a/src/combine/error.rs b/src/combine/error.rs new file mode 100644 index 0000000..de32cac --- /dev/null +++ b/src/combine/error.rs @@ -0,0 +1,35 @@ +/// Errors that can occur when working with COMBINE Archives. +#[derive(Debug, thiserror::Error)] +pub enum CombineArchiveError { + /// I/O error (file reading, writing, etc.) + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + /// ZIP archive error (corruption, invalid format, etc.) + #[error("Zip error: {0}")] + Zip(#[from] zip::result::ZipError), + + /// Manifest parsing or serialization error + #[error("Manifest error: {0}")] + Manifest(#[from] quick_xml::DeError), + + /// Requested file not found in archive + #[error("File not found: {0}")] + FileNotFound(String), + + /// No files found with the specified format + #[error("No files found with format: {0}")] + FileFormatNotFound(String), + + /// No master file defined in the archive + #[error("Master file not found")] + MasterFileNotFound, + + /// Attempted to add an entry that already exists + #[error("Location already exists: {0}")] + LocationAlreadyExists(String), + + /// Attempted to save changes but no file path is available + #[error("No file path specified for saving")] + NoPath, +} diff --git a/src/combine/manifest.rs b/src/combine/manifest.rs new file mode 100644 index 0000000..1313862 --- /dev/null +++ b/src/combine/manifest.rs @@ -0,0 +1,442 @@ +//! The manifest module provides functionality for working with COMBINE archive manifests. +//! +//! A COMBINE archive is a ZIP container format that bundles together multiple files used in +//! computational modeling in biology. The manifest file describes the contents of the archive, +//! including their locations and formats. +//! +//! This module provides: +//! - Serialization and deserialization of OMEX manifest files +//! - Types for representing manifest data +//! - Support for common formats used in systems biology + +use std::{fmt::Display, str::FromStr}; + +use quick_xml::SeError; +use serde::{Deserialize, Serialize}; + +use super::error::CombineArchiveError; + +/// Represents an OMEX manifest file for COMBINE archives +/// +/// An OMEX manifest describes the contents of a COMBINE archive, including +/// the location, format, and role of each file in the archive. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename = "omexManifest")] +pub struct OmexManifest { + /// XML namespace for OMEX manifest + /// + /// The standard namespace is "http://identifiers.org/combine.specifications/omex-manifest" + #[serde(rename = "@xmlns")] + pub xmlns: String, + + /// List of content entries in the manifest + /// + /// Each content entry describes a file within the COMBINE archive. + #[serde(rename = "content")] + pub content: Vec, +} + +/// Represents a content entry in the OMEX manifest +/// +/// Each content entry describes a single file within the COMBINE archive, +/// including its location, format, and whether it's the master file. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Content { + /// Location/path of the content file + /// + /// This is typically a relative path within the archive. + #[serde(rename = "@location")] + pub location: String, + + /// Format identifier (usually a URI) + /// + /// Identifies the format of the file, typically using a URI from identifiers.org. + /// For example, SBML files use "http://identifiers.org/combine.specifications/sbml". + #[serde(rename = "@format")] + pub format: String, + + /// Whether this content is the master file + /// + /// The master file is the primary file that should be processed first + /// when working with the archive. + #[serde(rename = "@master")] + pub master: bool, +} + +impl Default for OmexManifest { + /// Creates a default OMEX manifest with the standard namespace and an empty content list + fn default() -> Self { + Self { + xmlns: "http://identifiers.org/combine.specifications/omex-manifest".to_string(), + content: Vec::new(), + } + } +} + +impl OmexManifest { + /// Create a new OMEX manifest with default namespace + /// + /// This creates an empty manifest with the standard OMEX namespace. + pub fn new() -> Self { + Self::default() + } + + /// Add a content entry to the manifest + /// + /// # Arguments + /// + /// * `location` - The location/path of the file within the archive + /// * `format` - The format identifier for the file + /// * `master` - Whether this file is the master file + pub fn add_entry( + &mut self, + location: impl Into, + format: impl Into, + master: bool, + ) -> Result<(), CombineArchiveError> { + // First check if the there is already an entry with the same location + let location = location.into(); + if self.content.iter().any(|c| c.location == location) { + return Err(CombineArchiveError::LocationAlreadyExists(location)); + } + + self.content.push(Content { + location: location.into(), + format: format.into(), + master, + }); + + Ok(()) + } + + /// Serialize the manifest to XML string + /// + /// # Returns + /// + /// * `Ok(String)` - The serialized XML string + /// * `Err(SeError)` - Error during serialization + pub fn to_xml(&self) -> Result { + quick_xml::se::to_string(self) + } + + /// Deserialize the manifest from XML string + /// + /// # Arguments + /// + /// * `xml` - The XML string to deserialize + /// + /// # Returns + /// + /// * `Ok(OmexManifest)` - The deserialized manifest + /// * `Err(DeError)` - Error during deserialization + pub fn from_xml(xml: &str) -> Result { + quick_xml::de::from_str(xml) + } + + pub fn has_location(&self, location: &str) -> bool { + self.content.iter().any(|c| c.location == location) + } + + pub fn has_format(&self, format: impl Into) -> bool { + let format = format.into(); + self.content.iter().any(|c| c.format == format) + } + + pub fn master_file(&self) -> Option<&Content> { + self.content.iter().find(|c| c.master) + } +} + +impl Content { + /// Create a new content entry + /// + /// # Arguments + /// + /// * `location` - The location/path of the file within the archive + /// * `format` - The format identifier for the file + /// * `master` - Whether this file is the master file + /// + /// # Returns + /// + /// A new Content instance + pub fn new(location: impl Into, format: impl Into, master: bool) -> Self { + Self { + location: location.into(), + format: format.into(), + master, + } + } +} + +/// Enumeration of commonly used formats in COMBINE archives +/// +/// This enum provides a type-safe way to work with well-known format identifiers. +#[derive(Debug, Clone, PartialEq)] +pub enum KnownFormats { + /// Systems Biology Markup Language (SBML) + SBML, + /// Simulation Experiment Description Markup Language (SED-ML) + SEDML, + /// Systems Biology Graphical Notation (SBGN) + SBGN, +} + +impl FromStr for KnownFormats { + type Err = String; + + /// Parse a string into a KnownFormats value + /// + /// Accepts both full URIs and shorthand names. + /// + /// # Arguments + /// + /// * `s` - The string to parse + /// + /// # Returns + /// + /// * `Ok(KnownFormats)` - The parsed format + /// * `Err(String)` - Error message if the format is unknown + fn from_str(s: &str) -> Result { + match s { + "http://identifiers.org/combine.specifications/sbml" | "sbml" => Ok(KnownFormats::SBML), + "http://identifiers.org/combine.specifications/sed" | "sedml" => { + Ok(KnownFormats::SEDML) + } + "http://identifiers.org/combine.specifications/sbgn" | "sbgn" => Ok(KnownFormats::SBGN), + _ => Err(format!("Unknown format: {}", s)), + } + } +} + +impl From for String { + /// Convert a KnownFormats value to its URI string representation + fn from(value: KnownFormats) -> Self { + value.to_string() + } +} + +impl Display for KnownFormats { + /// Format a KnownFormats value as its URI string representation + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + KnownFormats::SBML => write!(f, "http://identifiers.org/combine.specifications/sbml"), + KnownFormats::SEDML => { + write!(f, "http://identifiers.org/combine.specifications/sed") + } + KnownFormats::SBGN => write!(f, "http://identifiers.org/combine.specifications/sbgn"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_manifest_creation() { + let mut manifest = OmexManifest::new(); + + manifest + .add_entry( + ".", + "http://identifiers.org/combine.specifications/omex", + false, + ) + .unwrap(); + manifest + .add_entry( + "./manifest.xml", + "http://identifiers.org/combine.specifications/omex-manifest", + false, + ) + .unwrap(); + manifest + .add_entry( + "./model.xml", + "http://identifiers.org/combine.specifications/sbml", + true, + ) + .unwrap(); + manifest + .add_entry( + "./data.tsv", + "https://purl.org/NET/mediatypes/text/tab-separated-values", + false, + ) + .unwrap(); + + assert_eq!(manifest.content.len(), 4); + assert_eq!(manifest.content[2].master, true); + assert_eq!(manifest.content[0].location, "."); + } + + #[test] + fn test_xml_serialization() { + let mut manifest = OmexManifest::new(); + manifest + .add_entry( + ".", + "http://identifiers.org/combine.specifications/omex", + false, + ) + .unwrap(); + manifest + .add_entry( + "./manifest.xml", + "http://identifiers.org/combine.specifications/omex-manifest", + false, + ) + .unwrap(); + manifest + .add_entry( + "./model.xml", + "http://identifiers.org/combine.specifications/sbml", + true, + ) + .unwrap(); + manifest + .add_entry( + "./data.tsv", + "https://purl.org/NET/mediatypes/text/tab-separated-values", + false, + ) + .unwrap(); + + let xml = manifest.to_xml().expect("Failed to serialize to XML"); + assert!(xml.contains("omexManifest")); + assert!( + xml.contains("xmlns=\"http://identifiers.org/combine.specifications/omex-manifest\"") + ); + assert!(xml.contains("master=\"true\"")); + } + + #[test] + fn test_xml_deserialization() { + let xml = r#" + + + + + +"#; + + let manifest = OmexManifest::from_xml(xml).expect("Failed to deserialize from XML"); + + assert_eq!(manifest.content.len(), 4); + assert_eq!( + manifest.xmlns, + "http://identifiers.org/combine.specifications/omex-manifest" + ); + assert_eq!(manifest.content[2].master, true); + assert_eq!(manifest.content[2].location, "./model.xml"); + } + + #[test] + fn test_roundtrip_serialization() { + let mut original = OmexManifest::new(); + original + .add_entry( + ".", + "http://identifiers.org/combine.specifications/omex", + false, + ) + .unwrap(); + original + .add_entry( + "./model.xml", + "http://identifiers.org/combine.specifications/sbml", + true, + ) + .unwrap(); + + let xml = original.to_xml().expect("Failed to serialize"); + let deserialized = OmexManifest::from_xml(&xml).expect("Failed to deserialize"); + + assert_eq!(original, deserialized); + } + + #[test] + fn test_known_formats() { + assert_eq!(KnownFormats::from_str("sbml"), Ok(KnownFormats::SBML)); + assert_eq!(KnownFormats::from_str("sedml"), Ok(KnownFormats::SEDML)); + assert_eq!(KnownFormats::from_str("sbgn"), Ok(KnownFormats::SBGN)); + assert_eq!( + KnownFormats::from_str("unknown"), + Err("Unknown format: unknown".to_string()) + ); + } + + #[test] + fn test_known_formats_display() { + assert_eq!( + KnownFormats::SBML.to_string(), + "http://identifiers.org/combine.specifications/sbml" + ); + assert_eq!( + KnownFormats::SEDML.to_string(), + "http://identifiers.org/combine.specifications/sed" + ); + assert_eq!( + KnownFormats::SBGN.to_string(), + "http://identifiers.org/combine.specifications/sbgn" + ); + } + + #[test] + fn test_add_content_from_known_formats() { + let mut manifest = OmexManifest::new(); + manifest + .add_entry("./sbml.xml", KnownFormats::SBML, false) + .unwrap(); + assert_eq!( + manifest.content[0].format, + "http://identifiers.org/combine.specifications/sbml" + ); + + assert_eq!(manifest.content[0].location, "./sbml.xml"); + assert_eq!(manifest.content[0].master, false); + + manifest + .add_entry("./sedml.xml", KnownFormats::SEDML, false) + .unwrap(); + assert_eq!( + manifest.content[1].format, + "http://identifiers.org/combine.specifications/sed" + ); + assert_eq!(manifest.content[1].location, "./sedml.xml"); + assert_eq!(manifest.content[1].master, false); + + manifest + .add_entry("./sbgn.xml", KnownFormats::SBGN, false) + .unwrap(); + assert_eq!( + manifest.content[2].format, + "http://identifiers.org/combine.specifications/sbgn" + ); + assert_eq!(manifest.content[2].location, "./sbgn.xml"); + assert_eq!(manifest.content[2].master, false); + } + + #[test] + fn test_add_entry_duplicate_location() { + let mut manifest = OmexManifest::new(); + manifest.add_entry(".", KnownFormats::SBML, false).unwrap(); + assert!(manifest.add_entry(".", KnownFormats::SBML, false).is_err()); + } + + #[test] + fn test_has_location() { + let mut manifest = OmexManifest::new(); + assert!(!manifest.has_location(".")); + manifest.add_entry(".", KnownFormats::SBML, false).unwrap(); + assert!(manifest.has_location(".")); + } + + #[test] + fn test_has_format() { + let mut manifest = OmexManifest::new(); + assert!(!manifest.has_format(KnownFormats::SBML)); + manifest.add_entry(".", KnownFormats::SBML, false).unwrap(); + assert!(manifest.has_format(KnownFormats::SBML)); + } +} diff --git a/src/lib.rs b/src/lib.rs index 00f5681..c4c0486 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -165,6 +165,13 @@ pub mod prelude { pub use crate::unitdef::*; } +pub mod combine { + pub use crate::combine::combinearchive::*; + pub mod combinearchive; + pub mod error; + mod manifest; +} + /// Internal module containing the raw FFI bindings to libSBML. /// /// This module uses autocxx to generate safe Rust bindings to the C++ libSBML classes. From a2aec34161b4a7fe8ebb4ef49f3ec074a180a8fa Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Thu, 29 May 2025 16:07:36 +0200 Subject: [PATCH 03/24] add test cases --- tests/data/expected_omex_content.xml | 241 +++++++++++++++++++++++++++ tests/data/expected_omex_data.tsv | 23 +++ tests/data/test.omex | Bin 0 -> 2429 bytes 3 files changed, 264 insertions(+) create mode 100644 tests/data/expected_omex_content.xml create mode 100644 tests/data/expected_omex_data.tsv create mode 100644 tests/data/test.omex diff --git a/tests/data/expected_omex_content.xml b/tests/data/expected_omex_content.xml new file mode 100644 index 0000000..6ebc64c --- /dev/null +++ b/tests/data/expected_omex_content.xml @@ -0,0 +1,241 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Vessel 1 + 10.0 + + + + + + + + + + + + + Enzyme + MTEY + E.coli + + + + + + 1.1.1.1 + E.coli + 12345 + MTEY + + + + + + + + Enzyme-Substrate Complex + + + + + p0 + s0 + + + + + + + + Substrate + + + + + QTBSBXVTEAMEQO-UHFFFAOYSA-N + CC(=O)O + + + + + + + + Product + + + + + QTBSBXVTEAMEQO-UHFFFAOYSA-N + CC(=O)O + + + + + + + + + + + + + + 0.0 + 100.0 + 0.1 + + + + + + + + + + + + 0.0 + 100.0 + 0.1 + + + + + + + + + + + + 0.0 + 100.0 + 0.1 + + + + + + + + 100 + + + + + + + + + + + + + + + + E_tot + kcat + s0 + + + + K_m + s0 + + + + + + + diff --git a/tests/data/expected_omex_data.tsv b/tests/data/expected_omex_data.tsv new file mode 100644 index 0000000..f99de1f --- /dev/null +++ b/tests/data/expected_omex_data.tsv @@ -0,0 +1,23 @@ +time s0 s1 id +0.0 0.0 10.0 m0 +1.0 1.0 9.0 m0 +2.0 2.0 8.0 m0 +3.0 3.0 7.0 m0 +4.0 4.0 6.0 m0 +5.0 5.0 5.0 m0 +6.0 6.0 4.0 m0 +7.0 7.0 3.0 m0 +8.0 8.0 2.0 m0 +9.0 9.0 1.0 m0 +10.0 10.0 0.0 m0 +0.0 0.0 10.0 m1 +1.0 1.0 9.0 m1 +2.0 2.0 8.0 m1 +3.0 3.0 7.0 m1 +4.0 4.0 6.0 m1 +5.0 5.0 5.0 m1 +6.0 6.0 4.0 m1 +7.0 7.0 3.0 m1 +8.0 8.0 2.0 m1 +9.0 9.0 1.0 m1 +10.0 10.0 0.0 m1 diff --git a/tests/data/test.omex b/tests/data/test.omex new file mode 100644 index 0000000000000000000000000000000000000000..087704deb80a5f2edbf78637d98194f6b7c3735e GIT binary patch literal 2429 zcmZ{mX*3jk8^(v3>{*8>360$#6xpI0O9+FqM1<+dzAulmjI0@>5n(LZw=k9&>k|?) zlPqB}vXiY$WGjQbdf)RdPv^Y<`@?na57#;O|M%s(%#DE{FaQ8x0ZbL}*)9UD=yS)L zIU@kTajag>-VYFN0f7pkUY;cwQ?qU*j=1%G{-^7vvvKAVxzv+4C%An1Lax6x;BT}3 zzD|x}O$D$9;}3MucgeJ1lpfTIx;)xd{fYrbMl6ri#dJrEO?DAx8ZQsi6UgW{h3*hP z=0Wk1zb;H$@Lr+69cmFq_4{oks+zfep%z-HVpR$f*I8E7Zs0FTRoL=xPQaN@k7gQQ z-{U%KbzMOAY_edcZdShhl7D=Sb&W*?m>#34$u^b{rhZ>hM`o*5BOYlNy63)7iCyA# z)HdS^=Oc$b8@cZJ_|^sFwQf~sJ+U!)LB#y}4Z_lv$F`#R-%to4aX;ux0Dv+S0AT%v z!pGIk^A`u2ovqJ=I(NiiE%)g2DQ3GSi4%k;Ic<8_=IY$SN3zmSZzO!Sv+$q)NtXz+ z#n7nUlj25v!00zFsyTbF<~T~aCiXi|A=C>XZ1IJ|X)uO(@5hFrD&X3(D_3PIfA~xK@|NJ+0GErsC11@8lJ}KQ*nA*e%Grgmw&u_qtRALc- z{7CFEgGiF4$w+%TzFR37o>^Z21A4rkLx)8HkGQ+Q0!hNRykhkmtt#C6DcKc;vpo6a z<*h$uaud@@r9*&g*%IoV&a&L9U?3v)Dl{S;1GpOxIh&L}$;CXtIf=EeTd ztZ~^~;##8ALRDHr)oNMMYR`|#t>%-%;5xYd)9|(g@0Ue$sOp@O(E+$)?hYu2va&(tjhDbT={YGQsh+#T^ZpOxT8)s1aN{lxRtSxuH&LWAWT z&>iH1L4t;|am6IHr5Q9LZG5L4e?3en2W~KU>EK41s&>oU(34}0bL3p{hf-nzab|Fl zsc(;jW8dv@Tr1RdAh|$?K04Wod5Pi=_g_pgXjyx%VuVKsQ-e^$#EE!ZSK>NUEPc|0$E`xz zTq=7WBACR7uo7{widp=WbJNeXtYOkFM-9J$^kNqe8!C&GPSb%{BpFGsW~@31RRBCH zyUHP&oysQ6e{j~P7PN72eeAZ9U2D%WJTj4dsG>x9rO}#`_XsNZ-qXSON^E%Z6^$<~ z?}koFrqj^(;SW$#<)h#FP7Afc%YxP{6DC(f{iepbgS+K@eU;&V?$!E9!$YdG-a@)l zhh7AC`VYosS%yYN>nByN_9l(P)=%?*kDkvB^++HxCT^d*pI$lv#;kl`=>2+%p_c{{ zR3Zy!ODgp+a=GZdirl_+=o+KnqHDCnVNIa3i1OL##>+Bhkj3L;=$zZB)q?e}Oowa; zQ{B$4A@^=8jGvd8y86k{kgO&-j}hENDP~}QcDm$G9+-3Q#ep`*m*&_&VfxlX1=rj$7}Fh33VOONUmh>XuzlD^B6=^mkd?qM zg$#k*a>-7F=N$7yLDaSI*9{_64-oBoCm^$`pc zpJQMdkuTnvHZ5gu-XI`7*%~hzY{}2wvuAE~KktN%18UWBLHh$;9j0*D^wP+Zj{zli z)(M2q{q;P?W99&~)88ey)|2 zDq#7Jn7OKuW^2J}vgo`H8}4&rI5gi~jRu^L2#il#kY;}`ePz&=QipICAW~Rv)e|Ju zT0i)S!OBAQ*@{Wkud6BeQqD?GTq2h-P*XbcJGA^RVxy03;d2AAxz``sqbrHSxiOv% zzl3ur2cDR)A!zYCZzp}b!nkmMX1JVlak0Oz>+-Od=|voeyZ}CWx=9SjBR9`ajAd!9 zTv+?5N0J-JXBenjE5(wZu@1m2M1(DFc)#B5lO3k7`j1|jT!_Pz*;BA%}lC{?!!d*GKjB*k$UM@QU_AbaiBGYZd>KLr;{W{Ce0y!1L_Z<{BI| zE&z4=Qch%vmWJS`8Fe)0Pv*Y|BqJTx=zScFjtsxr0|4Oa9O$eN7!d4`qQ@B}$^jbO z5FCNQ_Ru2gp|6jKm#QVdIJf0;JAIAFePDRn@Q|czRNvVvAw=-Vc6ndFmeP>n7I3Fg zp5jOOv&d$_)o*fsdaapQ)l=7#&w?71X{Yk7vF?Jxk=k8(PG&f%<2A8EY7=N~%)kf+ z{{IGZeAqu Date: Thu, 29 May 2025 16:17:21 +0200 Subject: [PATCH 04/24] re-export combine archive --- src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib.rs b/src/lib.rs index c4c0486..3267fcc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -145,6 +145,7 @@ pub use traits::annotation::Annotation; /// Prelude module providing convenient imports of commonly used types pub mod prelude { + pub use crate::combine::combinearchive::*; pub use crate::compartment::Compartment; pub use crate::fbc::*; pub use crate::kineticlaw::*; From 7e46832c1bcd4ff0e88ec6f6fbb7a250fc5fb80e Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Thu, 29 May 2025 16:22:16 +0200 Subject: [PATCH 05/24] replace chars causing os problems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows doing its best to be the biggest headache at cross-platform development again ☕️ --- src/combine/combinearchive.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/combine/combinearchive.rs b/src/combine/combinearchive.rs index a7783e4..a0e671e 100644 --- a/src/combine/combinearchive.rs +++ b/src/combine/combinearchive.rs @@ -523,14 +523,18 @@ mod tests { let xml_string = master.as_string().unwrap(); let expected_path = Path::new("tests/data/expected_omex_content.xml"); - let expected_content = fs::read_to_string(expected_path).unwrap(); + let expected_content = fs::read_to_string(expected_path) + .unwrap() + .replace("\r\n", "\n"); assert_eq!(xml_string, expected_content); // Check the CSV content let csv_entry = archive.entry("./data.tsv").unwrap(); let csv_string = csv_entry.as_string().unwrap(); let expected_csv_path = Path::new("tests/data/expected_omex_data.tsv"); - let expected_csv_content = fs::read_to_string(expected_csv_path).unwrap(); + let expected_csv_content = fs::read_to_string(expected_csv_path) + .unwrap() + .replace("\r\n", "\n"); assert_eq!(csv_string, expected_csv_content); } From 429fb9386e4e183152923a5f60c2217cfa043f7a Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Thu, 29 May 2025 16:33:12 +0200 Subject: [PATCH 06/24] fix clippy issues --- src/combine/combinearchive.rs | 2 +- src/combine/manifest.rs | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/combine/combinearchive.rs b/src/combine/combinearchive.rs index a0e671e..a103e17 100644 --- a/src/combine/combinearchive.rs +++ b/src/combine/combinearchive.rs @@ -516,7 +516,7 @@ mod tests { #[test] fn test_open_archive_to_sbml() { let archive_path = Path::new("tests/data/test.omex"); - let mut archive = CombineArchive::open(&archive_path).unwrap(); + let mut archive = CombineArchive::open(archive_path).unwrap(); // Get the master SBML file let master = archive.master().unwrap(); diff --git a/src/combine/manifest.rs b/src/combine/manifest.rs index 1313862..aa8b970 100644 --- a/src/combine/manifest.rs +++ b/src/combine/manifest.rs @@ -101,7 +101,7 @@ impl OmexManifest { } self.content.push(Content { - location: location.into(), + location, format: format.into(), master, }); @@ -172,6 +172,7 @@ impl Content { /// /// This enum provides a type-safe way to work with well-known format identifiers. #[derive(Debug, Clone, PartialEq)] +#[allow(clippy::upper_case_acronyms)] pub enum KnownFormats { /// Systems Biology Markup Language (SBML) SBML, @@ -266,7 +267,7 @@ mod tests { .unwrap(); assert_eq!(manifest.content.len(), 4); - assert_eq!(manifest.content[2].master, true); + assert!(manifest.content[2].master); assert_eq!(manifest.content[0].location, "."); } @@ -327,7 +328,7 @@ mod tests { manifest.xmlns, "http://identifiers.org/combine.specifications/omex-manifest" ); - assert_eq!(manifest.content[2].master, true); + assert!(manifest.content[2].master); assert_eq!(manifest.content[2].location, "./model.xml"); } @@ -394,7 +395,7 @@ mod tests { ); assert_eq!(manifest.content[0].location, "./sbml.xml"); - assert_eq!(manifest.content[0].master, false); + assert!(!manifest.content[0].master); manifest .add_entry("./sedml.xml", KnownFormats::SEDML, false) @@ -404,7 +405,7 @@ mod tests { "http://identifiers.org/combine.specifications/sed" ); assert_eq!(manifest.content[1].location, "./sedml.xml"); - assert_eq!(manifest.content[1].master, false); + assert!(!manifest.content[1].master); manifest .add_entry("./sbgn.xml", KnownFormats::SBGN, false) @@ -414,7 +415,7 @@ mod tests { "http://identifiers.org/combine.specifications/sbgn" ); assert_eq!(manifest.content[2].location, "./sbgn.xml"); - assert_eq!(manifest.content[2].master, false); + assert!(!manifest.content[2].master); } #[test] From 32c0c479298bdcee508900a400a1fead38da90dc Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Thu, 29 May 2025 17:21:34 +0200 Subject: [PATCH 07/24] add direct unit def extraction to local param --- src/localparameter.rs | 70 +++++++++++++++++++++++++++++++++++++++++-- src/macros.rs | 2 +- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/localparameter.rs b/src/localparameter.rs index cd667ec..0899753 100644 --- a/src/localparameter.rs +++ b/src/localparameter.rs @@ -20,12 +20,12 @@ use std::{cell::RefCell, pin::Pin, rc::Rc}; use cxx::let_cxx_string; use crate::{ - clone, inner, into_id, pin_ptr, + clone, get_unit_definition, inner, into_id, pin_ptr, prelude::KineticLaw, sbase, sbmlcxx::{self}, sbo_term, - traits::fromptr::FromPtr, + traits::{fromptr::FromPtr, sbase::SBase}, upcast, upcast_annotation, upcast_optional_property, upcast_pin, upcast_required_property, }; @@ -85,6 +85,9 @@ impl<'a> LocalParameter<'a> { } } + // Gets the unit definition for the local parameter + get_unit_definition!(units); + // Getter and setter for id upcast_required_property!( LocalParameter<'a>, @@ -281,7 +284,7 @@ mod tests { use serde::{Deserialize, Serialize}; use super::*; - use crate::{model::Model, prelude::Reaction, sbmldoc::SBMLDocument}; + use crate::{model::Model, prelude::Reaction, sbmldoc::SBMLDocument, unit::UnitKind}; #[test] fn test_parameter_creation() { @@ -392,4 +395,65 @@ mod tests { .expect("Failed to get annotation"); assert_eq!(extracted.test, "test"); } + + #[test] + fn test_local_parameter_unit_definition() { + let doc = SBMLDocument::default(); + let model = doc.create_model("test"); + + // Create the unit definition + model + .build_unit_definition("ml", "milliliter") + .unit(UnitKind::Litre, Some(-1), Some(-3), None, None) + .build(); + + model + .build_unit_definition("M", "Molar") + .unit(UnitKind::Mole, Some(1), Some(1), None, None) + .unit(UnitKind::Litre, Some(-1), Some(1), None, None) + .build(); + + model + .build_compartment("compartment") + .unit("ml") + .constant(true) + .build(); + + let substrate = model.build_species("substrate").build(); + let product = model.build_species("product").build(); + + let reaction = model + .build_reaction("reaction") + .reactant(&substrate, 1.0) + .product(&product, 1.0) + .build(); + + let kinetic_law = reaction.create_kinetic_law("k1 * substrate"); + let local_parameter = kinetic_law.build_local_parameter("k1").units("M").build(); + + let valid = doc.check_consistency(); + + if !valid.valid { + println!("{:#?}", valid.errors); + panic!("Invalid SBML document"); + } + + let unit_definition = local_parameter.unit_definition().unwrap(); + assert_eq!(unit_definition.id(), "M"); + assert_eq!(unit_definition.units().len(), 2); + + // Mole + assert_eq!(unit_definition.units()[0].kind(), UnitKind::Mole); + assert_eq!(unit_definition.units()[0].exponent(), 1); + assert_eq!(unit_definition.units()[0].scale(), 1); + assert_eq!(unit_definition.units()[0].multiplier(), 1.0); + assert_eq!(unit_definition.units()[0].offset(), 0.0); + + // Litre + assert_eq!(unit_definition.units()[1].kind(), UnitKind::Litre); + assert_eq!(unit_definition.units()[1].exponent(), -1); + assert_eq!(unit_definition.units()[1].scale(), 1); + assert_eq!(unit_definition.units()[1].multiplier(), 1.0); + assert_eq!(unit_definition.units()[1].offset(), 0.0); + } } diff --git a/src/macros.rs b/src/macros.rs index 5401f60..bc1e4a9 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -443,7 +443,7 @@ macro_rules! get_unit_definition { ($property:ident) => { pub fn unit_definition(&self) -> Option>> { let model_ptr = self.base().getModel(); - let model = Model::from_ptr(model_ptr as *mut $crate::sbmlcxx::Model); + let model = $crate::model::Model::from_ptr(model_ptr as *mut $crate::sbmlcxx::Model); if let Some(unit) = self.$property() { model.get_unit_definition(&unit) From a36e8400ebd371695914ee965ad37295eab489bf Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Thu, 29 May 2025 17:58:02 +0200 Subject: [PATCH 08/24] make `manifest` mod public --- src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 3267fcc..369defc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -168,9 +168,10 @@ pub mod prelude { pub mod combine { pub use crate::combine::combinearchive::*; + pub use crate::combine::manifest::KnownFormats; pub mod combinearchive; pub mod error; - mod manifest; + pub mod manifest; } /// Internal module containing the raw FFI bindings to libSBML. From 79a73b8bcc1a4e8f707c0c82c3690931d70d4a24 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Thu, 29 May 2025 17:58:12 +0200 Subject: [PATCH 09/24] add COMBINE archive example --- examples/create.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/examples/create.rs b/examples/create.rs index 38ef863..15eb8b5 100644 --- a/examples/create.rs +++ b/examples/create.rs @@ -1,4 +1,4 @@ -use sbml::{prelude::*, unit::UnitKind}; +use sbml::{combine::KnownFormats, prelude::*, unit::UnitKind}; fn main() -> Result<(), Box> { let doc = SBMLDocument::default(); @@ -58,5 +58,22 @@ fn main() -> Result<(), Box> { // Print the SBML string println!("{}", sbml_string); + // Save as a string to a file + std::fs::write("./model.xml", &sbml_string).expect("Failed to write file"); + + // Alternatively, save in a COMBINE archive + let mut archive = CombineArchive::new(); + archive + .add_entry( + "./model.xml", + KnownFormats::SBML, + true, + sbml_string.as_bytes(), + ) + .expect("Failed to add entry to archive"); + archive + .save("./model.omex") + .expect("Failed to save archive"); + Ok(()) } From 8ff39ca808109ed6549e64db7f4cc2616f98f972 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Thu, 29 May 2025 17:58:18 +0200 Subject: [PATCH 10/24] serialize with indent --- src/combine/manifest.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/combine/manifest.rs b/src/combine/manifest.rs index aa8b970..ee168b7 100644 --- a/src/combine/manifest.rs +++ b/src/combine/manifest.rs @@ -11,7 +11,7 @@ use std::{fmt::Display, str::FromStr}; -use quick_xml::SeError; +use quick_xml::{se::Serializer, SeError}; use serde::{Deserialize, Serialize}; use super::error::CombineArchiveError; @@ -116,7 +116,11 @@ impl OmexManifest { /// * `Ok(String)` - The serialized XML string /// * `Err(SeError)` - Error during serialization pub fn to_xml(&self) -> Result { - quick_xml::se::to_string(self) + let mut buffer = String::new(); + let mut ser = Serializer::new(&mut buffer); + ser.indent(' ', 4); + self.serialize(ser)?; + Ok(buffer) } /// Deserialize the manifest from XML string From d60cbac6c49db1440cf85196bbea25d897900b05 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Thu, 29 May 2025 18:14:47 +0200 Subject: [PATCH 11/24] add entry getter by format --- src/combine/combinearchive.rs | 50 +++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/combine/combinearchive.rs b/src/combine/combinearchive.rs index a103e17..f2741a4 100644 --- a/src/combine/combinearchive.rs +++ b/src/combine/combinearchive.rs @@ -273,6 +273,38 @@ impl CombineArchive { Err(CombineArchiveError::FileNotFound(location.to_string())) } + /// Retrieves an entry from the archive by format. + /// + /// This method returns the first entry with the specified format. + /// + /// # Arguments + /// + /// * `format` - Format identifier for the file + /// + /// # Returns + /// + /// Returns an `Entry` for the first entry with the specified format, or an error + /// if no entry with the specified format is found. + /// + /// # Errors + /// + /// * `CombineArchiveError::FileNotFound` - If no entry with the specified format is found + pub fn entry_by_format( + &mut self, + format: impl Into, + ) -> Result { + let format = format.into(); + let location = self + .manifest + .content + .iter() + .find(|c| c.format == format) + .ok_or(CombineArchiveError::FileNotFound(format.to_string()))? + .location + .clone(); + self.entry(&location) + } + /// Retrieves the master file of the archive. /// /// The master file is the primary file in a COMBINE Archive, typically @@ -496,6 +528,8 @@ impl Entry { #[cfg(test)] mod tests { + use crate::combine::KnownFormats; + use super::*; use std::fs; use tempfile::TempDir; @@ -1179,4 +1213,20 @@ mod tests { assert_eq!(data.content.format, "application/json"); assert!(!data.content.master); } + + #[test] + fn test_entry_by_format() { + let mut archive = CombineArchive::new(); + archive + .add_entry( + "./model.xml", + KnownFormats::SBML, + true, + b"v1".as_slice(), + ) + .unwrap(); + + let entry = archive.entry_by_format(KnownFormats::SBML).unwrap(); + assert_eq!(entry.as_string().unwrap(), "v1"); + } } From d3c9d52ebcabfd8198157b23e93d581fdb5c7c74 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Thu, 29 May 2025 18:14:56 +0200 Subject: [PATCH 12/24] extend known formats --- src/combine/manifest.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/combine/manifest.rs b/src/combine/manifest.rs index ee168b7..3db50f5 100644 --- a/src/combine/manifest.rs +++ b/src/combine/manifest.rs @@ -184,6 +184,10 @@ pub enum KnownFormats { SEDML, /// Systems Biology Graphical Notation (SBGN) SBGN, + /// Tab-separated values (TSV) + TSV, + /// Comma-separated values (CSV) + CSV, } impl FromStr for KnownFormats { @@ -208,6 +212,10 @@ impl FromStr for KnownFormats { Ok(KnownFormats::SEDML) } "http://identifiers.org/combine.specifications/sbgn" | "sbgn" => Ok(KnownFormats::SBGN), + "https://purl.org/NET/mediatypes/text/tab-separated-values" | "tsv" => { + Ok(KnownFormats::TSV) + } + "https://purl.org/NET/mediatypes/text/csv" | "csv" => Ok(KnownFormats::CSV), _ => Err(format!("Unknown format: {}", s)), } } @@ -229,6 +237,11 @@ impl Display for KnownFormats { write!(f, "http://identifiers.org/combine.specifications/sed") } KnownFormats::SBGN => write!(f, "http://identifiers.org/combine.specifications/sbgn"), + KnownFormats::TSV => write!( + f, + "https://purl.org/NET/mediatypes/text/tab-separated-values" + ), + KnownFormats::CSV => write!(f, "https://purl.org/NET/mediatypes/text/csv"), } } } From ed99d03a141c130d0e0e222aa0222232c38e0fc1 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Thu, 29 May 2025 20:07:55 +0200 Subject: [PATCH 13/24] add tests for collection annots --- src/collections/compartments.rs | 49 ++++++++++++++++++++++++++++++++ src/collections/parameters.rs | 47 +++++++++++++++++++++++++++++++ src/collections/reactions.rs | 50 ++++++++++++++++++++++++++++++++- src/collections/rules.rs | 47 +++++++++++++++++++++++++++++++ src/collections/species.rs | 47 +++++++++++++++++++++++++++++++ src/collections/unitdefs.rs | 49 ++++++++++++++++++++++++++++++++ 6 files changed, 288 insertions(+), 1 deletion(-) diff --git a/src/collections/compartments.rs b/src/collections/compartments.rs index 2ba9103..9810c10 100644 --- a/src/collections/compartments.rs +++ b/src/collections/compartments.rs @@ -29,3 +29,52 @@ upcast_annotation!( sbmlcxx::ListOfCompartments, sbmlcxx::SBase ); + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + + use crate::sbmldoc::SBMLDocument; + + #[test] + fn test_list_of_compartments_annotation_serde() { + let doc = SBMLDocument::default(); + let model = doc.create_model("test"); + + #[derive(Serialize, Deserialize)] + struct TestAnnotation { + test: String, + } + + let annotation = TestAnnotation { + test: "Test".to_string(), + }; + + model + .set_compartments_annotation_serde(&annotation) + .unwrap(); + + let annotation: TestAnnotation = model.get_compartments_annotation_serde().unwrap(); + assert_eq!(annotation.test, "Test"); + } + + #[test] + fn test_list_of_compartments_annotation() { + let doc = SBMLDocument::default(); + let model = doc.create_model("test"); + + let annotation = "Test"; + model + .set_compartments_annotation(annotation) + .expect("Failed to set annotation"); + + let annotation = model.get_compartments_annotation(); + assert_eq!( + annotation + .replace("\n", "") + .replace("\r", "") + .replace(" ", ""), + "Test" + ); + } +} diff --git a/src/collections/parameters.rs b/src/collections/parameters.rs index 791d47c..a2a4f0c 100644 --- a/src/collections/parameters.rs +++ b/src/collections/parameters.rs @@ -29,3 +29,50 @@ upcast_annotation!( sbmlcxx::ListOfParameters, sbmlcxx::SBase ); + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + + use crate::sbmldoc::SBMLDocument; + + #[test] + fn test_list_of_parameters_annotation_serde() { + let doc = SBMLDocument::default(); + let model = doc.create_model("test"); + + #[derive(Serialize, Deserialize)] + struct TestAnnotation { + test: String, + } + + let annotation = TestAnnotation { + test: "Test".to_string(), + }; + + model.set_parameters_annotation_serde(&annotation).unwrap(); + + let annotation: TestAnnotation = model.get_parameters_annotation_serde().unwrap(); + assert_eq!(annotation.test, "Test"); + } + + #[test] + fn test_list_of_parameters_annotation() { + let doc = SBMLDocument::default(); + let model = doc.create_model("test"); + + let annotation = "Test"; + model + .set_parameters_annotation(annotation) + .expect("Failed to set annotation"); + + let annotation = model.get_parameters_annotation(); + assert_eq!( + annotation + .replace("\n", "") + .replace("\r", "") + .replace(" ", ""), + "Test" + ); + } +} diff --git a/src/collections/reactions.rs b/src/collections/reactions.rs index 140d132..3ad40cc 100644 --- a/src/collections/reactions.rs +++ b/src/collections/reactions.rs @@ -1,6 +1,6 @@ use std::{cell::RefCell, pin::Pin}; -use crate::{inner, model::Model, pin_ptr, sbmlcxx, upcast_annotation}; +use crate::{inner, model::Model, pin_ptr, sbase, sbmlcxx, upcast_annotation}; /// A safe wrapper around the libSBML ListOfReactions class. /// @@ -24,8 +24,56 @@ impl<'a> ListOfReactions<'a> { // Derive the inner type from the ListOfReactions type inner!(sbmlcxx::ListOfReactions, ListOfReactions<'a>); +sbase!(ListOfReactions<'a>, sbmlcxx::ListOfReactions); upcast_annotation!( ListOfReactions<'a>, sbmlcxx::ListOfReactions, sbmlcxx::SBase ); + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + + use crate::sbmldoc::SBMLDocument; + + #[test] + fn test_list_of_reactions_annotation_serde() { + let doc = SBMLDocument::default(); + let model = doc.create_model("test"); + + #[derive(Serialize, Deserialize)] + struct TestAnnotation { + test: String, + } + + let annotation = TestAnnotation { + test: "Test".to_string(), + }; + + model.set_reactions_annotation_serde(&annotation).unwrap(); + + let annotation: TestAnnotation = model.get_reactions_annotation_serde().unwrap(); + assert_eq!(annotation.test, "Test"); + } + + #[test] + fn test_list_of_reactions_annotation() { + let doc = SBMLDocument::default(); + let model = doc.create_model("test"); + + let annotation = "Test"; + model + .set_reactions_annotation(annotation) + .expect("Failed to set annotation"); + + let annotation = model.get_reactions_annotation(); + assert_eq!( + annotation + .replace("\n", "") + .replace("\r", "") + .replace(" ", ""), + "Test" + ); + } +} diff --git a/src/collections/rules.rs b/src/collections/rules.rs index d2b21e7..31efaf0 100644 --- a/src/collections/rules.rs +++ b/src/collections/rules.rs @@ -25,3 +25,50 @@ impl<'a> ListOfRules<'a> { // Derive the inner type from the ListOfRules type inner!(sbmlcxx::ListOfRules, ListOfRules<'a>); upcast_annotation!(ListOfRules<'a>, sbmlcxx::ListOfRules, sbmlcxx::SBase); + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + + use crate::sbmldoc::SBMLDocument; + + #[test] + fn test_list_of_rules_annotation_serde() { + let doc = SBMLDocument::default(); + let model = doc.create_model("test"); + + #[derive(Serialize, Deserialize)] + struct TestAnnotation { + test: String, + } + + let annotation = TestAnnotation { + test: "Test".to_string(), + }; + + model.set_rate_rules_annotation_serde(&annotation).unwrap(); + + let annotation: TestAnnotation = model.get_rate_rules_annotation_serde().unwrap(); + assert_eq!(annotation.test, "Test"); + } + + #[test] + fn test_list_of_rules_annotation() { + let doc = SBMLDocument::default(); + let model = doc.create_model("test"); + + let annotation = "Test"; + model + .set_rate_rules_annotation(annotation) + .expect("Failed to set annotation"); + + let annotation = model.get_rate_rules_annotation(); + assert_eq!( + annotation + .replace("\n", "") + .replace("\r", "") + .replace(" ", ""), + "Test" + ); + } +} diff --git a/src/collections/species.rs b/src/collections/species.rs index 0673e7e..75fcab4 100644 --- a/src/collections/species.rs +++ b/src/collections/species.rs @@ -25,3 +25,50 @@ impl<'a> ListOfSpecies<'a> { // Derive the inner type from the ListOfSpecies type inner!(sbmlcxx::ListOfSpecies, ListOfSpecies<'a>); upcast_annotation!(ListOfSpecies<'a>, sbmlcxx::ListOfSpecies, sbmlcxx::SBase); + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + + use crate::sbmldoc::SBMLDocument; + + #[test] + fn test_list_of_species_annotation_serde() { + let doc = SBMLDocument::default(); + let model = doc.create_model("test"); + + #[derive(Serialize, Deserialize)] + struct TestAnnotation { + test: String, + } + + let annotation = TestAnnotation { + test: "Test".to_string(), + }; + + model.set_species_annotation_serde(&annotation).unwrap(); + + let annotation: TestAnnotation = model.get_species_annotation_serde().unwrap(); + assert_eq!(annotation.test, "Test"); + } + + #[test] + fn test_list_of_species_annotation() { + let doc = SBMLDocument::default(); + let model = doc.create_model("test"); + + let annotation = "Test"; + model + .set_species_annotation(annotation) + .expect("Failed to set annotation"); + + let annotation = model.get_species_annotation(); + assert_eq!( + annotation + .replace("\n", "") + .replace("\r", "") + .replace(" ", ""), + "Test" + ); + } +} diff --git a/src/collections/unitdefs.rs b/src/collections/unitdefs.rs index d25e3c9..32b33d0 100644 --- a/src/collections/unitdefs.rs +++ b/src/collections/unitdefs.rs @@ -33,3 +33,52 @@ upcast_annotation!( sbmlcxx::ListOfUnitDefinitions, sbmlcxx::SBase ); + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + + use crate::sbmldoc::SBMLDocument; + + #[test] + fn test_list_of_unitdefs_annotation_serde() { + let doc = SBMLDocument::default(); + let model = doc.create_model("test"); + + #[derive(Serialize, Deserialize)] + struct TestAnnotation { + test: String, + } + + let annotation = TestAnnotation { + test: "Test".to_string(), + }; + + model + .set_unit_definitions_annotation_serde(&annotation) + .unwrap(); + + let annotation: TestAnnotation = model.get_unit_definitions_annotation_serde().unwrap(); + assert_eq!(annotation.test, "Test"); + } + + #[test] + fn test_list_of_unitdefs_annotation() { + let doc = SBMLDocument::default(); + let model = doc.create_model("test"); + + let annotation = "Test"; + model + .set_unit_definitions_annotation(annotation) + .expect("Failed to set annotation"); + + let annotation = model.get_unit_definitions_annotation(); + assert_eq!( + annotation + .replace("\n", "") + .replace("\r", "") + .replace(" ", ""), + "Test" + ); + } +} From 01076a22d5538aa8508c944dc37c40db314cf633 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Thu, 29 May 2025 20:08:21 +0200 Subject: [PATCH 14/24] remove lifetime of `SBMLDocument` --- src/reader.rs | 4 ++-- src/sbmldoc.rs | 62 +++++++++++++++++++++++++++++++------------------- tests/e2e.rs | 4 ++-- 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/src/reader.rs b/src/reader.rs index 604db1c..59fc21d 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -38,7 +38,7 @@ impl SBMLReader { /// /// # Returns /// An SBMLDocument instance containing the parsed model - pub fn from_xml_string(xml: &str) -> SBMLDocument<'static> { + pub fn from_xml_string(xml: &str) -> SBMLDocument { let reader = Self::new(); // Create an owned String to ensure the data persists let owned_xml = xml.to_string(); @@ -130,7 +130,7 @@ mod tests { assert_eq!(list_of_assignment_rules.len(), 0); } - fn read_sbml_file(path: &PathBuf) -> Result, LibSBMLError> { + fn read_sbml_file(path: &PathBuf) -> Result { let xml = std::fs::read_to_string(path).unwrap(); Ok(SBMLReader::from_xml_string(&xml)) } diff --git a/src/sbmldoc.rs b/src/sbmldoc.rs index 6b67df3..3cd1275 100644 --- a/src/sbmldoc.rs +++ b/src/sbmldoc.rs @@ -26,14 +26,12 @@ use crate::{ /// /// The SBMLDocument is the top-level container for an SBML model and associated data. /// It maintains the SBML level and version, and contains a single optional Model. -pub struct SBMLDocument<'a> { +pub struct SBMLDocument { /// The underlying libSBML document, wrapped in RefCell to allow interior mutability document: RefCell>, - /// The optional Model contained in this document - model: RefCell>>>, } -impl<'a> SBMLDocument<'a> { +impl SBMLDocument { /// Creates a new SBMLDocument with the specified SBML level and version. /// /// # Arguments @@ -64,7 +62,6 @@ impl<'a> SBMLDocument<'a> { Self { document: RefCell::new(document), - model: RefCell::new(None), } } @@ -79,20 +76,11 @@ impl<'a> SBMLDocument<'a> { /// /// # Returns /// A new SBMLDocument instance - pub(crate) fn from_unique_ptr(ptr: UniquePtr) -> SBMLDocument<'static> { + pub(crate) fn from_unique_ptr(ptr: UniquePtr) -> SBMLDocument { // Wrap the pointer in a RefCell let document = RefCell::new(ptr); - // Grab the model from the document - let model = document - .borrow_mut() - .as_mut() - .map(|model| Rc::new(Model::from_ptr(model.getModel1()))); - - SBMLDocument { - document, - model: RefCell::new(model), - } + SBMLDocument { document } } /// Returns a reference to the underlying libSBML document. @@ -147,15 +135,22 @@ impl<'a> SBMLDocument<'a> { /// /// # Returns /// A reference to the newly created Model - pub fn create_model(&self, id: &str) -> Rc> { - let model = Rc::new(Model::new(self, id)); - self.model.borrow_mut().replace(Rc::clone(&model)); - model + pub fn create_model<'a>(&'a self, id: &str) -> Rc> { + Rc::new(Model::new(self, id)) } /// Returns a reference to the Model if one exists. - pub fn model(&self) -> Option>> { - self.model.borrow().as_ref().map(Rc::clone) + pub fn model<'a>(&'a self) -> Option>> { + // Check if a model exists in the document + let has_model = self.document.borrow_mut().as_mut()?.getModel1().is_null() == false; + + if has_model { + Some(Rc::new(Model::from_ptr( + self.document.borrow_mut().as_mut()?.getModel1(), + ))) + } else { + None + } } /// Converts the SBML document to an XML string representation. @@ -203,7 +198,7 @@ impl<'a> SBMLDocument<'a> { } } -impl<'a> std::fmt::Debug for SBMLDocument<'a> { +impl std::fmt::Debug for SBMLDocument { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut ds = f.debug_struct("SBMLDocument"); ds.field("level", &self.level()); @@ -213,7 +208,7 @@ impl<'a> std::fmt::Debug for SBMLDocument<'a> { } } -impl<'a> Default for SBMLDocument<'a> { +impl Default for SBMLDocument { /// Creates a new SBMLDocument with the default SBML level and version, and FBC package. /// /// # Returns @@ -326,4 +321,23 @@ mod tests { println!("{:?}", doc.plugins()); assert!(!doc.plugins().is_empty()); } + + #[test] + fn test_sbmldoc_lifetime_changes() { + // Test that we can create a document and model without lifetime issues + let doc = SBMLDocument::default(); + let model = doc.create_model("test_model"); + + // Test that we can create species and other components + let species = model.create_species("test_species"); + assert_eq!(species.id(), "test_species"); + + // Test that we can get the model back + let retrieved_model = doc.model().expect("Model should exist"); + assert_eq!(retrieved_model.id(), "test_model"); + + // Test that the document doesn't have lifetime parameters + let _xml = doc.to_xml_string(); + assert!(!_xml.is_empty()); + } } diff --git a/tests/e2e.rs b/tests/e2e.rs index f305f42..d2c6a20 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -6,7 +6,7 @@ mod tests { fn test_sbmldoc_debug() { let doc = create_doc(); let debug_string = format!("{:?}", doc); - insta::assert_snapshot!(debug_string, @r#"SBMLDocument { level: 3, version: 2, model: Some(Model { id: "test_model", name: "", list_of_species: [Species { id: "species", name: Some("species"), compartment: Some("compartment"), initial_amount: None, initial_concentration: Some(1.0), unit: Some("mole"), boundary_condition: Some(false), constant: false, has_only_substance_units: Some(false) }, Species { id: "product", name: Some("product"), compartment: Some("compartment"), initial_amount: None, initial_concentration: Some(1.0), unit: Some("mole"), boundary_condition: Some(false), constant: false, has_only_substance_units: Some(false) }], list_of_compartments: [Compartment { id: "compartment", name: Some("compartment"), spatial_dimensions: None, unit: Some("ml"), size: Some(1.0), volume: Some(1.0), outside: None, constant: Some(true) }], list_of_unit_definitions: [UnitDefinition { id: "ml", name: Some("milliliter"), units: [Unit { kind: Litre, exponent: 1, multiplier: 1.0, scale: -3, offset: 0.0 }] }, UnitDefinition { id: "mole", name: Some("mole"), units: [Unit { kind: Mole, exponent: 1, multiplier: 1.0, scale: 0, offset: 0.0 }, Unit { kind: Litre, exponent: -1, multiplier: 1.0, scale: 0, offset: 0.0 }] }, UnitDefinition { id: "kelvin", name: Some("kelvin"), units: [Unit { kind: Kelvin, exponent: 1, multiplier: 1.0, scale: 0, offset: 0.0 }] }], list_of_reactions: [Reaction { id: "reaction", name: Some("reaction"), reversible: None, compartment: None, reactants: RefCell { value: [SpeciesReference { species: "species", stoichiometry: 1.0, constant: false }] }, products: RefCell { value: [SpeciesReference { species: "product", stoichiometry: 1.0, constant: false }] }, modifiers: RefCell { value: [] } }], list_of_parameters: [Parameter { id: "T", name: None, value: Some(310.0), units: Some("kelvin"), constant: Some(true) }, Parameter { id: "Km", name: None, value: Some(1.0), units: Some("mole"), constant: Some(true) }], list_of_rate_rules: [Rule { type: Ok(RateRule), variable: "product", formula: "kcat * substrate / (substrate + Km)" }], list_of_assignment_rules: [Rule { type: Ok(AssignmentRule), variable: "x", formula: "T * kcat * substrate / (T + Km)" }], list_of_objectives: [], list_of_flux_bounds: [FluxBound { id: Some("fb1"), reaction: Some("reaction"), operation: LessEqual }] }) }"#); + insta::assert_snapshot!(debug_string, @r#"SBMLDocument { level: 3, version: 2, model: Some(Model { id: "test_model", name: "", list_of_species: [Species { id: "species", name: Some("species"), compartment: Some("compartment"), initial_amount: None, initial_concentration: Some(1.0), unit: Some("mole"), boundary_condition: Some(false), constant: false, has_only_substance_units: Some(false) }, Species { id: "product", name: Some("product"), compartment: Some("compartment"), initial_amount: None, initial_concentration: Some(1.0), unit: Some("mole"), boundary_condition: Some(false), constant: false, has_only_substance_units: Some(false) }], list_of_compartments: [Compartment { id: "compartment", name: Some("compartment"), spatial_dimensions: None, unit: Some("ml"), size: Some(1.0), volume: Some(1.0), outside: None, constant: Some(true) }], list_of_unit_definitions: [UnitDefinition { id: "ml", name: Some("milliliter"), units: [Unit { kind: Litre, exponent: 1, multiplier: 1.0, scale: -3, offset: 0.0 }] }, UnitDefinition { id: "mole", name: Some("mole"), units: [Unit { kind: Mole, exponent: 1, multiplier: 1.0, scale: 0, offset: 0.0 }, Unit { kind: Litre, exponent: -1, multiplier: 1.0, scale: 0, offset: 0.0 }] }, UnitDefinition { id: "kelvin", name: Some("kelvin"), units: [Unit { kind: Kelvin, exponent: 1, multiplier: 1.0, scale: 0, offset: 0.0 }] }], list_of_reactions: [Reaction { id: "reaction", name: Some("reaction"), reversible: None, compartment: None, reactants: RefCell { value: [SpeciesReference { species: "species", stoichiometry: 1.0, constant: false }] }, products: RefCell { value: [SpeciesReference { species: "product", stoichiometry: 1.0, constant: false }] }, modifiers: RefCell { value: [] } }], list_of_parameters: [Parameter { id: "T", name: None, value: Some(310.0), units: Some("kelvin"), constant: Some(true) }, Parameter { id: "Km", name: None, value: Some(1.0), units: Some("mole"), constant: Some(true) }], list_of_rate_rules: [Rule { type: Ok(RateRule), variable: "product", formula: "kcat * substrate / (substrate + Km)" }], list_of_assignment_rules: [Rule { type: Ok(AssignmentRule), variable: "x", formula: "T * kcat * substrate / (T + Km)" }], list_of_objectives: [Objective { id: "objective", obj_type: Maximize, flux_objectives: [FluxObjective { id: Some("fo1"), reaction: Some("reaction"), coefficient: Some(1.0) }] }], list_of_flux_bounds: [FluxBound { id: Some("fb1"), reaction: Some("reaction"), operation: LessEqual }] }) }"#); } #[test] @@ -23,7 +23,7 @@ mod tests { assert_eq!(xml_string, xml_string2); } - fn create_doc() -> SBMLDocument<'static> { + fn create_doc() -> SBMLDocument { let doc = SBMLDocument::default(); let model = doc.create_model("test_model"); From 8121c0ba30b95cebc4be5cd50c5ab5c7c4de43e0 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Thu, 29 May 2025 20:18:06 +0200 Subject: [PATCH 15/24] fix clippy issue --- src/sbmldoc.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sbmldoc.rs b/src/sbmldoc.rs index 3cd1275..4432ed9 100644 --- a/src/sbmldoc.rs +++ b/src/sbmldoc.rs @@ -142,7 +142,7 @@ impl SBMLDocument { /// Returns a reference to the Model if one exists. pub fn model<'a>(&'a self) -> Option>> { // Check if a model exists in the document - let has_model = self.document.borrow_mut().as_mut()?.getModel1().is_null() == false; + let has_model = self.document.borrow_mut().as_mut()?.isSetModel(); if has_model { Some(Rc::new(Model::from_ptr( From 2429b246fa675fd3e675d7772fe777b7866b95c0 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Sun, 1 Jun 2025 17:11:44 +0200 Subject: [PATCH 16/24] add default `.`omex loc and `./manifest.xml` entries --- src/combine/combinearchive.rs | 440 +++++++++++++++++++++++++++++++--- src/combine/error.rs | 8 + 2 files changed, 418 insertions(+), 30 deletions(-) diff --git a/src/combine/combinearchive.rs b/src/combine/combinearchive.rs index f2741a4..94784a2 100644 --- a/src/combine/combinearchive.rs +++ b/src/combine/combinearchive.rs @@ -49,9 +49,37 @@ impl CombineArchive { /// /// The archive will have an empty manifest and no associated file path. /// Use [`add_entry`](Self::add_entry) or [`add_file`](Self::add_file) to add content. + /// + /// # Mandatory Entries + /// + /// Every COMBINE Archive automatically includes two mandatory entries: + /// - Archive self-reference at location "." with format "http://identifiers.org/combine.specifications/omex" + /// - Manifest reference at location "./manifest.xml" with format "http://identifiers.org/combine.specifications/omex-manifest" + /// + /// These entries are added automatically and cannot be removed. pub fn new() -> Self { + let mut manifest = OmexManifest::new(); + + // Add mandatory entries + // Note: We ignore the error here because we know these entries don't exist yet + manifest + .add_entry( + ".", + "http://identifiers.org/combine.specifications/omex", + false, + ) + .expect("Failed to add mandatory archive entry"); + + manifest + .add_entry( + "./manifest.xml", + "http://identifiers.org/combine.specifications/omex-manifest", + false, + ) + .expect("Failed to add mandatory manifest entry"); + Self { - manifest: OmexManifest::new(), + manifest, path: None, original_zip: None, pending_entries: HashMap::new(), @@ -78,13 +106,41 @@ impl CombineArchive { /// /// * `CombineArchiveError::Io` - If the file cannot be read /// * `CombineArchiveError::Zip` - If the file is not a valid ZIP archive - /// * `CombineArchiveError::Manifest` - If the manifest.xml is missing or invalid + /// * `CombineArchiveError::ManifestFileMissing` - If the manifest.xml file is missing + /// * `CombineArchiveError::Manifest` - If the manifest.xml is invalid + /// + /// # Mandatory Entries + /// + /// If the opened archive doesn't contain the archive self-reference entry (for backwards compatibility), + /// it will be automatically added: + /// - Archive self-reference at location "." + /// + /// The manifest reference at "./manifest.xml" must exist in the archive or an error will be thrown. pub fn open>(path: P) -> Result { let path_buf = path.as_ref().to_path_buf(); let zip_data = std::fs::read(&path_buf)?; - // Extract and parse the manifest - let manifest = Self::extract_manifest(&zip_data)?; + // Extract and parse the manifest - this will fail if manifest.xml doesn't exist + let mut manifest = Self::extract_manifest(&zip_data)?; + + // Ensure archive self-reference entry is present (for backwards compatibility) + // The manifest.xml entry should already be present in the manifest since we read it from the file + if !manifest.has_location(".") { + manifest.add_entry( + ".", + "http://identifiers.org/combine.specifications/omex", + false, + )?; + } + + // Ensure manifest entry is present in the manifest content (for backwards compatibility) + if !manifest.has_location("./manifest.xml") { + manifest.add_entry( + "./manifest.xml", + "http://identifiers.org/combine.specifications/omex-manifest", + false, + )?; + } Ok(Self { manifest, @@ -200,11 +256,28 @@ impl CombineArchive { /// /// * `location` - Location of the entry to remove (e.g., "./model.xml") /// + /// # Errors + /// + /// * `CombineArchiveError::CannotRemoveMandatoryEntry` - If attempting to remove mandatory entries + /// + /// # Mandatory Entries + /// + /// The following entries cannot be removed as they are mandatory for OMEX archives: + /// - "." (archive self-reference) + /// - "./manifest.xml" (manifest reference) + /// /// # Note /// /// Removing the master file will leave the archive without a master file, /// which may make it invalid according to the COMBINE specification. pub fn remove_entry(&mut self, location: &str) -> Result<(), CombineArchiveError> { + // Check if trying to remove mandatory entries + if location == "." || location == "./manifest.xml" { + return Err(CombineArchiveError::CannotRemoveMandatoryEntry( + location.to_string(), + )); + } + let zip_location = location.replace("./", ""); // Remove from manifest @@ -425,10 +498,21 @@ impl CombineArchive { /// Extracts and parses the manifest from ZIP data. fn extract_manifest(zip_data: &[u8]) -> Result { let mut archive = ZipArchive::new(Cursor::new(zip_data))?; + + // Check if manifest.xml exists in the archive let mut manifest_buf = Vec::new(); - archive - .by_name("manifest.xml")? - .read_to_end(&mut manifest_buf)?; + match archive.by_name("manifest.xml") { + Ok(mut file) => { + file.read_to_end(&mut manifest_buf)?; + } + Err(zip::result::ZipError::FileNotFound) => { + return Err(CombineArchiveError::ManifestFileMissing); + } + Err(e) => { + return Err(CombineArchiveError::Zip(e)); + } + } + let manifest = OmexManifest::from_xml(&String::from_utf8(manifest_buf).unwrap())?; Ok(manifest) } @@ -541,10 +625,13 @@ mod tests { #[test] fn test_new_archive_creation() { let archive = CombineArchive::new(); - assert_eq!(archive.list_entries().len(), 0); + assert_eq!(archive.list_entries().len(), 2); assert!(!archive.has_entry("./test.xml")); assert!(archive.path.is_none()); assert!(!archive.needs_rebuild); + + assert!(archive.has_entry(".")); + assert!(archive.has_entry("./manifest.xml")); } #[test] @@ -585,7 +672,7 @@ mod tests { ) .unwrap(); - assert_eq!(archive.list_entries().len(), 1); + assert_eq!(archive.list_entries().len(), 3); assert!(archive.has_entry("./model.xml")); assert!(archive.needs_rebuild); @@ -625,7 +712,7 @@ mod tests { ) .unwrap(); - assert_eq!(archive.list_entries().len(), 3); + assert_eq!(archive.list_entries().len(), 5); assert!(archive.has_entry("./model.xml")); assert!(archive.has_entry("./data.csv")); assert!(archive.has_entry("./script.py")); @@ -683,7 +770,7 @@ mod tests { // Load from disk let mut loaded_archive = CombineArchive::open(&archive_path).unwrap(); - assert_eq!(loaded_archive.list_entries().len(), 2); + assert_eq!(loaded_archive.list_entries().len(), 4); assert!(loaded_archive.has_entry("./model.xml")); assert!(loaded_archive.has_entry("./data.csv")); @@ -732,11 +819,11 @@ mod tests { // Load and mutate let mut loaded_archive = CombineArchive::open(&archive_path).unwrap(); - assert_eq!(loaded_archive.list_entries().len(), 3); + assert_eq!(loaded_archive.list_entries().len(), 5); // Remove an entry loaded_archive.remove_entry("./data1.csv").unwrap(); - assert_eq!(loaded_archive.list_entries().len(), 2); + assert_eq!(loaded_archive.list_entries().len(), 4); assert!(!loaded_archive.has_entry("./data1.csv")); assert!(loaded_archive.has_entry("./data2.csv")); @@ -749,7 +836,7 @@ mod tests { b"print('new script')".as_slice(), ) .unwrap(); - assert_eq!(loaded_archive.list_entries().len(), 3); + assert_eq!(loaded_archive.list_entries().len(), 5); assert!(loaded_archive.has_entry("./script.py")); // Modify existing entry (overwrite) @@ -767,7 +854,7 @@ mod tests { // Reload and verify mutations let mut final_archive = CombineArchive::open(&archive_path).unwrap(); - assert_eq!(final_archive.list_entries().len(), 3); + assert_eq!(final_archive.list_entries().len(), 5); assert!(!final_archive.has_entry("./data1.csv")); assert!(final_archive.has_entry("./data2.csv")); assert!(final_archive.has_entry("./script.py")); @@ -840,7 +927,7 @@ mod tests { let mut final_archive = CombineArchive::open(&archive_path).unwrap(); // Verify final state - assert_eq!(final_archive.list_entries().len(), 5); // 1,3,5 + new1,new2 + assert_eq!(final_archive.list_entries().len(), 7); assert!(final_archive.has_entry("./file1.txt")); assert!(!final_archive.has_entry("./file2.txt")); assert!(final_archive.has_entry("./file3.txt")); @@ -926,6 +1013,12 @@ mod tests { // Test opening non-existent file assert!(CombineArchive::open("./nonexistent.omex").is_err()); + + // Test opening invalid ZIP file + let temp_dir = create_test_dir(); + let invalid_file = temp_dir.path().join("invalid.omex"); + std::fs::write(&invalid_file, b"not a zip file").unwrap(); + assert!(CombineArchive::open(&invalid_file).is_err()); } #[test] @@ -984,7 +1077,7 @@ mod tests { // Should have updated content let entry = archive.entry("./test.txt").unwrap(); assert_eq!(entry.as_string().unwrap(), "updated"); - assert_eq!(archive.list_entries().len(), 1); // Should not duplicate + assert_eq!(archive.list_entries().len(), 3); } #[test] @@ -1030,7 +1123,7 @@ mod tests { // Load and verify all entries let mut loaded = CombineArchive::open(&archive_path).unwrap(); - assert_eq!(loaded.list_entries().len(), 100); + assert_eq!(loaded.list_entries().len(), 102); // Verify random entries for i in [0, 25, 50, 75, 99] { @@ -1046,12 +1139,12 @@ mod tests { loaded.remove_entry(&format!("./file{:03}.txt", i)).unwrap(); } - assert_eq!(loaded.list_entries().len(), 50); + assert_eq!(loaded.list_entries().len(), 52); loaded.save_changes().unwrap(); // Reload and verify let final_archive = CombineArchive::open(&archive_path).unwrap(); - assert_eq!(final_archive.list_entries().len(), 50); + assert_eq!(final_archive.list_entries().len(), 52); } #[test] @@ -1068,7 +1161,7 @@ mod tests { ) .unwrap(); - assert_eq!(archive.list_entries().len(), 1); + assert_eq!(archive.list_entries().len(), 3); let entry = archive.entry("./test.txt").unwrap(); assert_eq!(entry.as_string().unwrap(), "original content"); @@ -1082,8 +1175,8 @@ mod tests { ) .unwrap(); - // Should still have only one entry - assert_eq!(archive.list_entries().len(), 1); + // Should still have same number of entries + assert_eq!(archive.list_entries().len(), 3); let entry = archive.entry("./test.txt").unwrap(); assert_eq!(entry.as_string().unwrap(), "updated content"); assert_eq!(entry.content.format, "text/plain"); @@ -1104,7 +1197,7 @@ mod tests { ) .unwrap(); - assert_eq!(archive.list_entries().len(), 1); + assert_eq!(archive.list_entries().len(), 3); // Update with different format - should replace manifest entry archive @@ -1116,8 +1209,8 @@ mod tests { ) .unwrap(); - // Should still have only one entry but with new format - assert_eq!(archive.list_entries().len(), 1); + // Should still have same number of entries but with new format + assert_eq!(archive.list_entries().len(), 3); let entry = archive.entry("./test.txt").unwrap(); assert_eq!(entry.as_string().unwrap(), "{\"updated\": true}"); assert_eq!(entry.content.format, "application/json"); @@ -1133,7 +1226,7 @@ mod tests { .add_entry("./test.txt", "text/plain", false, b"content".as_slice()) .unwrap(); - assert_eq!(archive.list_entries().len(), 1); + assert_eq!(archive.list_entries().len(), 3); assert!(!archive.entry("./test.txt").unwrap().content.master); // Update with same format but different master flag @@ -1146,8 +1239,8 @@ mod tests { ) .unwrap(); - // Should still have only one entry but now as master - assert_eq!(archive.list_entries().len(), 1); + // Should still have same number of entries but now as master + assert_eq!(archive.list_entries().len(), 3); let entry = archive.entry("./test.txt").unwrap(); assert_eq!(entry.as_string().unwrap(), "master content"); assert_eq!(entry.content.format, "text/plain"); @@ -1201,7 +1294,7 @@ mod tests { // Reload and verify let mut final_archive = CombineArchive::open(&archive_path).unwrap(); - assert_eq!(final_archive.list_entries().len(), 2); + assert_eq!(final_archive.list_entries().len(), 4); let model = final_archive.entry("./model.xml").unwrap(); assert_eq!(model.as_string().unwrap(), "v2"); @@ -1229,4 +1322,291 @@ mod tests { let entry = archive.entry_by_format(KnownFormats::SBML).unwrap(); assert_eq!(entry.as_string().unwrap(), "v1"); } + + #[test] + fn test_mandatory_entries_present_in_new_archive() { + let archive = CombineArchive::new(); + + // Check that mandatory entries are present + assert!(archive.has_entry(".")); + assert!(archive.has_entry("./manifest.xml")); + + // Check their formats + let entries = archive.list_entries(); + let archive_entry = entries.iter().find(|e| e.location == ".").unwrap(); + let manifest_entry = entries + .iter() + .find(|e| e.location == "./manifest.xml") + .unwrap(); + + assert_eq!( + archive_entry.format, + "http://identifiers.org/combine.specifications/omex" + ); + assert_eq!( + manifest_entry.format, + "http://identifiers.org/combine.specifications/omex-manifest" + ); + + // Check they are not master files + assert!(!archive_entry.master); + assert!(!manifest_entry.master); + + // Total should be exactly 2 entries + assert_eq!(archive.list_entries().len(), 2); + } + + #[test] + fn test_mandatory_entries_cannot_be_removed() { + let mut archive = CombineArchive::new(); + + // Try to remove the archive self-reference + let result = archive.remove_entry("."); + assert!(matches!( + result, + Err(CombineArchiveError::CannotRemoveMandatoryEntry(_)) + )); + if let Err(CombineArchiveError::CannotRemoveMandatoryEntry(location)) = result { + assert_eq!(location, "."); + } + + // Try to remove the manifest reference + let result = archive.remove_entry("./manifest.xml"); + assert!(matches!( + result, + Err(CombineArchiveError::CannotRemoveMandatoryEntry(_)) + )); + if let Err(CombineArchiveError::CannotRemoveMandatoryEntry(location)) = result { + assert_eq!(location, "./manifest.xml"); + } + + // Entries should still be present + assert!(archive.has_entry(".")); + assert!(archive.has_entry("./manifest.xml")); + assert_eq!(archive.list_entries().len(), 2); + } + + #[test] + fn test_mandatory_entries_persist_after_save_and_load() { + let temp_dir = create_test_dir(); + let archive_path = temp_dir.path().join("mandatory_test.omex"); + + // Create archive with user entry + let mut archive = CombineArchive::new(); + archive + .add_entry( + "./user_file.txt", + "text/plain", + true, + b"user content".as_slice(), + ) + .unwrap(); + + // Save to disk + archive.save(&archive_path).unwrap(); + + // Load and verify mandatory entries are still present + let loaded_archive = CombineArchive::open(&archive_path).unwrap(); + assert!(loaded_archive.has_entry(".")); + assert!(loaded_archive.has_entry("./manifest.xml")); + assert!(loaded_archive.has_entry("./user_file.txt")); + assert_eq!(loaded_archive.list_entries().len(), 3); // 2 mandatory + 1 user + } + + #[test] + fn test_mandatory_entries_added_to_legacy_archives() { + // This test simulates opening an archive that was created before mandatory entries were implemented + // We can't easily test this with real data, but we test the logic in the open method + let temp_dir = create_test_dir(); + let archive_path = temp_dir.path().join("legacy_test.omex"); + + // Create an archive manually with minimal manifest (simulating legacy archive) + let mut minimal_archive = CombineArchive { + manifest: OmexManifest::new(), // Start with truly empty manifest + path: None, + original_zip: None, + pending_entries: HashMap::new(), + removed_entries: std::collections::HashSet::new(), + needs_rebuild: false, + }; + + // Add only a user file (no mandatory entries) + minimal_archive + .manifest + .add_entry("./legacy_file.txt", "text/plain", true) + .unwrap(); + minimal_archive + .pending_entries + .insert("legacy_file.txt".to_string(), b"legacy content".to_vec()); + + // Save this minimal archive + minimal_archive.save(&archive_path).unwrap(); + + // Now open it - the open method should add the missing mandatory entries + let loaded_archive = CombineArchive::open(&archive_path).unwrap(); + assert!(loaded_archive.has_entry(".")); + assert!(loaded_archive.has_entry("./manifest.xml")); + assert!(loaded_archive.has_entry("./legacy_file.txt")); + assert_eq!(loaded_archive.list_entries().len(), 3); // 2 mandatory + 1 legacy + } + + #[test] + fn test_mandatory_entries_can_be_overwritten_but_not_removed() { + let mut archive = CombineArchive::new(); + + // Verify initial state + assert_eq!(archive.list_entries().len(), 2); + assert!(archive.has_entry(".")); + assert!(archive.has_entry("./manifest.xml")); + + // Try to overwrite the mandatory entries with different formats (should succeed) + archive + .add_entry( + ".", + "some-other-format", + false, + b"different data".as_slice(), + ) + .unwrap(); + archive + .add_entry( + "./manifest.xml", + "another-format", + false, + b"other data".as_slice(), + ) + .unwrap(); + + // Should still have exactly 2 entries but with updated formats + assert_eq!(archive.list_entries().len(), 2); + assert!(archive.has_entry(".")); + assert!(archive.has_entry("./manifest.xml")); + + // Check that formats were actually updated + let entries = archive.list_entries(); + let archive_entry = entries.iter().find(|e| e.location == ".").unwrap(); + let manifest_entry = entries + .iter() + .find(|e| e.location == "./manifest.xml") + .unwrap(); + + assert_eq!(archive_entry.format, "some-other-format"); + assert_eq!(manifest_entry.format, "another-format"); + + // But they cannot be removed + let result = archive.remove_entry("."); + assert!(matches!( + result, + Err(CombineArchiveError::CannotRemoveMandatoryEntry(_)) + )); + + let result = archive.remove_entry("./manifest.xml"); + assert!(matches!( + result, + Err(CombineArchiveError::CannotRemoveMandatoryEntry(_)) + )); + + // Entries should still be present + assert!(archive.has_entry(".")); + assert!(archive.has_entry("./manifest.xml")); + assert_eq!(archive.list_entries().len(), 2); + } + + #[test] + fn test_mandatory_entries_with_user_content() { + let mut archive = CombineArchive::new(); + + // Add various user entries + archive + .add_entry( + "./model.sbml", + KnownFormats::SBML, + true, + b"".as_slice(), + ) + .unwrap(); + archive + .add_entry( + "./simulation.sedml", + KnownFormats::SEDML, + false, + b"".as_slice(), + ) + .unwrap(); + archive + .add_entry("./data.csv", "text/csv", false, b"x,y\n1,2".as_slice()) + .unwrap(); + + // Should have 2 mandatory + 3 user = 5 total + assert_eq!(archive.list_entries().len(), 5); + + // Mandatory entries should still be there + assert!(archive.has_entry(".")); + assert!(archive.has_entry("./manifest.xml")); + + // User entries should be there + assert!(archive.has_entry("./model.sbml")); + assert!(archive.has_entry("./simulation.sedml")); + assert!(archive.has_entry("./data.csv")); + + // Remove user entries + archive.remove_entry("./model.sbml").unwrap(); + archive.remove_entry("./simulation.sedml").unwrap(); + archive.remove_entry("./data.csv").unwrap(); + + // Should only have mandatory entries left + assert_eq!(archive.list_entries().len(), 2); + assert!(archive.has_entry(".")); + assert!(archive.has_entry("./manifest.xml")); + } + + #[test] + fn test_to_bytes_includes_mandatory_entries() { + let mut archive = CombineArchive::new(); + archive + .add_entry("./test.txt", "text/plain", true, b"test".as_slice()) + .unwrap(); + + let bytes = archive.to_bytes().unwrap(); + assert!(!bytes.is_empty()); + + // Write to file and read back to verify mandatory entries are included + let temp_dir = create_test_dir(); + let temp_path = temp_dir.path().join("from_bytes_mandatory.omex"); + std::fs::write(&temp_path, &bytes).unwrap(); + + let loaded = CombineArchive::open(&temp_path).unwrap(); + assert!(loaded.has_entry(".")); + assert!(loaded.has_entry("./manifest.xml")); + assert!(loaded.has_entry("./test.txt")); + assert_eq!(loaded.list_entries().len(), 3); + } + + #[test] + fn test_open_archive_without_manifest_file() { + use std::io::Write; + use zip::{write::SimpleFileOptions, ZipWriter}; + + let temp_dir = create_test_dir(); + let invalid_archive_path = temp_dir.path().join("invalid.omex"); + + // Create a ZIP file without manifest.xml + let file = std::fs::File::create(&invalid_archive_path).unwrap(); + let mut writer = ZipWriter::new(file); + let options = SimpleFileOptions::default(); + + // Add some other file but no manifest.xml + writer.start_file("some_file.txt", options).unwrap(); + writer + .write_all(b"This is not a valid OMEX archive") + .unwrap(); + writer.finish().unwrap(); + + // Try to open it - should fail with ManifestFileMissing error + let result = CombineArchive::open(&invalid_archive_path); + assert!(matches!( + result, + Err(CombineArchiveError::ManifestFileMissing) + )); + } } diff --git a/src/combine/error.rs b/src/combine/error.rs index de32cac..1a256af 100644 --- a/src/combine/error.rs +++ b/src/combine/error.rs @@ -32,4 +32,12 @@ pub enum CombineArchiveError { /// Attempted to save changes but no file path is available #[error("No file path specified for saving")] NoPath, + + /// Attempted to remove a mandatory entry that must always be present + #[error("Cannot remove mandatory entry: {0}")] + CannotRemoveMandatoryEntry(String), + + /// The manifest.xml file is missing from the archive + #[error("Manifest file (manifest.xml) is missing from the archive")] + ManifestFileMissing, } From 012e4978355f30499a64e928006585ae12a4c7fa Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Tue, 17 Jun 2025 21:07:45 +0200 Subject: [PATCH 17/24] change error type --- src/macros.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/macros.rs b/src/macros.rs index bc1e4a9..4a432d7 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -364,9 +364,9 @@ macro_rules! set_collection_annotation { /// /// # Returns /// Result indicating success or containing an error if the annotation is invalid - pub fn [](&'a self, annotation: &str) -> Result<(), Box> { + pub fn [](&'a self, annotation: &str) -> Result<(), quick_xml::SeError> { let collection = $collection_type::new(self); - collection.set_annotation(annotation)?; + collection.set_annotation(annotation).map_err(|e| SeError::Custom(e.to_string()))?; Ok(()) } From 30c4e535bb2fccb2c2f1248c23ae151622709c92 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Tue, 17 Jun 2025 23:56:26 +0200 Subject: [PATCH 18/24] use `SeError` on correct function --- src/macros.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/macros.rs b/src/macros.rs index 4a432d7..3202a40 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -364,9 +364,9 @@ macro_rules! set_collection_annotation { /// /// # Returns /// Result indicating success or containing an error if the annotation is invalid - pub fn [](&'a self, annotation: &str) -> Result<(), quick_xml::SeError> { + pub fn [](&'a self, annotation: &str) -> Result<(), Box> { let collection = $collection_type::new(self); - collection.set_annotation(annotation).map_err(|e| SeError::Custom(e.to_string()))?; + collection.set_annotation(annotation)?; Ok(()) } @@ -398,9 +398,9 @@ macro_rules! set_collection_annotation { /// /// # Returns /// Result indicating success or containing a serialization error - pub fn [](&'a self, annotation: &T) -> Result<(), Box> { + pub fn [](&'a self, annotation: &T) -> Result<(), quick_xml::SeError> { let collection = $collection_type::new(self); - collection.set_annotation_serde(annotation)?; + collection.set_annotation_serde(annotation).map_err(|e| SeError::Custom(e.to_string()))?; Ok(()) } } From c51482e0a458f1bc96e53f7033f19aaacf44d7c7 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Wed, 16 Jul 2025 18:56:06 +0200 Subject: [PATCH 19/24] Improve annotation deserialization in Wrapper Enhanced the Wrapper struct to support custom deserialization logic that iterates through all child elements in an annotation and attempts to deserialize each into the target type, using the first successful match. Added a new test to verify complex annotation deserialization and updated documentation for clarity. --- src/model.rs | 20 +++++++++ src/wrapper.rs | 120 +++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 132 insertions(+), 8 deletions(-) diff --git a/src/model.rs b/src/model.rs index 039b4cb..52ce430 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1100,6 +1100,26 @@ mod tests { ); } + #[test] + fn test_get_complex_annotation() { + #[derive(Serialize, Deserialize)] + #[serde(rename = "customAnnotation")] + struct TestAnnotation { + test: String, + } + + let doc = SBMLDocument::default(); + let model = Model::new(&doc, "test"); + model + .set_annotation( + "testtest2", + ) + .unwrap(); + + let annotation: TestAnnotation = model.get_annotation_serde().unwrap(); + assert_eq!(annotation.test, "test"); + } + #[test] fn test_set_annotation_serde() { #[derive(Serialize, Deserialize)] diff --git a/src/wrapper.rs b/src/wrapper.rs index bcb633d..0ab12e7 100644 --- a/src/wrapper.rs +++ b/src/wrapper.rs @@ -3,7 +3,10 @@ //! This module defines a generic wrapper that allows for flexible deserialization //! of annotations with custom types in SBML-related data structures. -use serde::Deserialize; +use serde::de::{self, MapAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::fmt; +use std::marker::PhantomData; /// A generic wrapper struct for deserializing XML annotations. /// @@ -11,18 +14,119 @@ use serde::Deserialize; /// a generic type `T` with a specific XML structure. It is particularly useful /// when working with serialized metadata in SBML models. /// +/// The custom deserializer iterates through all child elements within the +/// `` tag and attempts to deserialize each one into type `T`. +/// If multiple elements can be parsed into `T`, the first successful one is used. +/// Elements that cannot be parsed into `T` are silently ignored. +/// /// # Type Parameters /// * `T` - The type of the annotation content to be deserialized /// -/// # Serde Configuration -/// * Renames the root XML element to "annotation" -/// * Uses "$value" to capture the inner content -#[derive(Debug, Deserialize, Clone)] +/// # Behavior +/// * Expects XML with root element named "annotation" +/// * Iterates through all child elements +/// * Attempts to deserialize each child element into type `T` +/// * Returns the first successful match +/// * Ignores elements that cannot be parsed into `T` +/// +/// # Example +/// ```xml +/// +/// some_value +/// ignored +/// also_ignored +/// +/// ``` +/// +/// When deserializing into `Wrapper` where `TestStruct` has a `test` field, +/// only the `` element would be successfully parsed, while others are ignored. +#[derive(Debug, Clone, Serialize)] #[serde(rename = "annotation")] pub(crate) struct Wrapper { /// The actual annotation content - /// - /// Uses a special serde rename to capture the inner XML value - #[serde(rename = "$value")] pub(crate) annotation: T, } + +impl<'de, T> Deserialize<'de> for Wrapper +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct WrapperVisitor { + marker: PhantomData, + } + + impl<'de, T> Visitor<'de> for WrapperVisitor + where + T: Deserialize<'de>, + { + type Value = Wrapper; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an annotation element with parseable content") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut last_error: Option = None; + + // Iterate through all key-value pairs + while let Some(key) = map.next_key::()? { + // Try to deserialize the next value into T + // This handles both single values and nested structures + match map.next_value::() { + Ok(parsed_value) => { + // Successfully parsed this element into T + return Ok(Wrapper { + annotation: parsed_value, + }); + } + Err(err) => { + // This element couldn't be parsed into T, store error and continue + last_error = Some(format!("Failed to parse '{}': {}", key, err)); + continue; + } + } + } + + // If we get here, no element could be parsed into T + match last_error { + Some(err) => Err(de::Error::custom(err)), + None => Err(de::Error::custom( + "no elements found that could be parsed into the target type", + )), + } + } + } + + // Use a map deserializer since XML elements are treated as key-value pairs + deserializer.deserialize_map(WrapperVisitor { + marker: PhantomData, + }) + } +} + +impl Wrapper { + /// Creates a new wrapper with the given annotation content. + #[allow(dead_code)] + pub(crate) fn new(annotation: T) -> Self { + Self { annotation } + } + + /// Gets a reference to the annotation content. + #[allow(dead_code)] + pub(crate) fn get(&self) -> &T { + &self.annotation + } + + /// Consumes the wrapper and returns the annotation content. + #[allow(dead_code)] + pub(crate) fn into_inner(self) -> T { + self.annotation + } +} From 3845bc8b0c73d4033c0b899fdbe368fb523d6aa7 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Wed, 16 Jul 2025 18:59:02 +0200 Subject: [PATCH 20/24] Revert "Improve annotation deserialization in Wrapper" This reverts commit c51482e0a458f1bc96e53f7033f19aaacf44d7c7. --- src/model.rs | 20 --------- src/wrapper.rs | 120 ++++--------------------------------------------- 2 files changed, 8 insertions(+), 132 deletions(-) diff --git a/src/model.rs b/src/model.rs index 52ce430..039b4cb 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1100,26 +1100,6 @@ mod tests { ); } - #[test] - fn test_get_complex_annotation() { - #[derive(Serialize, Deserialize)] - #[serde(rename = "customAnnotation")] - struct TestAnnotation { - test: String, - } - - let doc = SBMLDocument::default(); - let model = Model::new(&doc, "test"); - model - .set_annotation( - "testtest2", - ) - .unwrap(); - - let annotation: TestAnnotation = model.get_annotation_serde().unwrap(); - assert_eq!(annotation.test, "test"); - } - #[test] fn test_set_annotation_serde() { #[derive(Serialize, Deserialize)] diff --git a/src/wrapper.rs b/src/wrapper.rs index 0ab12e7..bcb633d 100644 --- a/src/wrapper.rs +++ b/src/wrapper.rs @@ -3,10 +3,7 @@ //! This module defines a generic wrapper that allows for flexible deserialization //! of annotations with custom types in SBML-related data structures. -use serde::de::{self, MapAccess, Visitor}; -use serde::{Deserialize, Deserializer, Serialize}; -use std::fmt; -use std::marker::PhantomData; +use serde::Deserialize; /// A generic wrapper struct for deserializing XML annotations. /// @@ -14,119 +11,18 @@ use std::marker::PhantomData; /// a generic type `T` with a specific XML structure. It is particularly useful /// when working with serialized metadata in SBML models. /// -/// The custom deserializer iterates through all child elements within the -/// `` tag and attempts to deserialize each one into type `T`. -/// If multiple elements can be parsed into `T`, the first successful one is used. -/// Elements that cannot be parsed into `T` are silently ignored. -/// /// # Type Parameters /// * `T` - The type of the annotation content to be deserialized /// -/// # Behavior -/// * Expects XML with root element named "annotation" -/// * Iterates through all child elements -/// * Attempts to deserialize each child element into type `T` -/// * Returns the first successful match -/// * Ignores elements that cannot be parsed into `T` -/// -/// # Example -/// ```xml -/// -/// some_value -/// ignored -/// also_ignored -/// -/// ``` -/// -/// When deserializing into `Wrapper` where `TestStruct` has a `test` field, -/// only the `` element would be successfully parsed, while others are ignored. -#[derive(Debug, Clone, Serialize)] +/// # Serde Configuration +/// * Renames the root XML element to "annotation" +/// * Uses "$value" to capture the inner content +#[derive(Debug, Deserialize, Clone)] #[serde(rename = "annotation")] pub(crate) struct Wrapper { /// The actual annotation content + /// + /// Uses a special serde rename to capture the inner XML value + #[serde(rename = "$value")] pub(crate) annotation: T, } - -impl<'de, T> Deserialize<'de> for Wrapper -where - T: Deserialize<'de>, -{ - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - struct WrapperVisitor { - marker: PhantomData, - } - - impl<'de, T> Visitor<'de> for WrapperVisitor - where - T: Deserialize<'de>, - { - type Value = Wrapper; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("an annotation element with parseable content") - } - - fn visit_map(self, mut map: A) -> Result - where - A: MapAccess<'de>, - { - let mut last_error: Option = None; - - // Iterate through all key-value pairs - while let Some(key) = map.next_key::()? { - // Try to deserialize the next value into T - // This handles both single values and nested structures - match map.next_value::() { - Ok(parsed_value) => { - // Successfully parsed this element into T - return Ok(Wrapper { - annotation: parsed_value, - }); - } - Err(err) => { - // This element couldn't be parsed into T, store error and continue - last_error = Some(format!("Failed to parse '{}': {}", key, err)); - continue; - } - } - } - - // If we get here, no element could be parsed into T - match last_error { - Some(err) => Err(de::Error::custom(err)), - None => Err(de::Error::custom( - "no elements found that could be parsed into the target type", - )), - } - } - } - - // Use a map deserializer since XML elements are treated as key-value pairs - deserializer.deserialize_map(WrapperVisitor { - marker: PhantomData, - }) - } -} - -impl Wrapper { - /// Creates a new wrapper with the given annotation content. - #[allow(dead_code)] - pub(crate) fn new(annotation: T) -> Self { - Self { annotation } - } - - /// Gets a reference to the annotation content. - #[allow(dead_code)] - pub(crate) fn get(&self) -> &T { - &self.annotation - } - - /// Consumes the wrapper and returns the annotation content. - #[allow(dead_code)] - pub(crate) fn into_inner(self) -> T { - self.annotation - } -} From 2a57bf8b4cd9f05ce13224374f87e8afdbebaffa Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Thu, 17 Jul 2025 15:56:56 +0200 Subject: [PATCH 21/24] Bump quick-xml to version 0.38.0 Updated the quick-xml dependency from 0.37.2 to 0.38.0 in Cargo.toml to keep dependencies up to date and benefit from the latest fixes and features. --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a64cbe3..2fad452 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1014,9 +1014,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.37.2" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165859e9e55f79d67b96c5d96f4e88b6f2695a1972849c15a6a3f5c59fc2c003" +checksum = "8927b0664f5c5a98265138b7e3f90aa19a6b21353182469ace36d4ac527b7b1b" dependencies = [ "memchr", "serde", diff --git a/Cargo.toml b/Cargo.toml index ebc2153..adc3873 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ crate-type = ["staticlib", "rlib"] autocxx = "0.28.0" cxx = "1.0.140" paste = "1.0.15" -quick-xml = { version = "0.37.2", features = ["serialize"] } +quick-xml = { version = "0.38.0", features = ["serialize"] } serde = { version = "1.0.217", features = ["derive"] } thiserror = "2.0.12" zip = "4.0.0" From 5bb771f47d7aed155b6af0007c6b4dbd7130fb9a Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Sat, 19 Jul 2025 00:57:13 +0200 Subject: [PATCH 22/24] Refactor string formatting to use inline syntax Replaces instances of format!(...) and println!(...) with inline string interpolation (e.g., println!("{var}")). This modernizes the codebase for improved readability and consistency, without changing functionality. --- build.rs | 4 ++-- examples/create.rs | 2 +- src/combine/combinearchive.rs | 14 +++++++------- src/combine/manifest.rs | 2 +- src/fbc/fluxbound.rs | 6 +++--- src/fbc/fluxboundop.rs | 3 +-- src/fbc/fluxobjective.rs | 6 +++--- src/fbc/objective.rs | 2 +- src/fbc/objectivetype.rs | 3 +-- src/model.rs | 6 +++--- tests/e2e.rs | 2 +- 11 files changed, 24 insertions(+), 26 deletions(-) diff --git a/build.rs b/build.rs index e4b0d51..1ac2e81 100644 --- a/build.rs +++ b/build.rs @@ -126,7 +126,7 @@ fn setup_vcpkg() -> Result { /// * `cargo_metadata` - A slice of strings containing cargo metadata directives fn link_lib(cargo_metadata: &[String]) { for metadata in cargo_metadata { - println!("{}", metadata); + println!("{metadata}"); } } @@ -169,7 +169,7 @@ fn from_pkg_config(pkg_config: &str) -> Result<(Vec, Vec), Stri let mut cargo_metadata = Vec::new(); for lib in lib.libs { - cargo_metadata.push(format!("cargo:rustc-link-lib={}", lib)); + cargo_metadata.push(format!("cargo:rustc-link-lib={lib}")); } Ok((lib.include_paths.clone(), cargo_metadata)) diff --git a/examples/create.rs b/examples/create.rs index 15eb8b5..3f4a6fc 100644 --- a/examples/create.rs +++ b/examples/create.rs @@ -56,7 +56,7 @@ fn main() -> Result<(), Box> { let sbml_string = doc.to_xml_string(); // Print the SBML string - println!("{}", sbml_string); + println!("{sbml_string}"); // Save as a string to a file std::fs::write("./model.xml", &sbml_string).expect("Failed to write file"); diff --git a/src/combine/combinearchive.rs b/src/combine/combinearchive.rs index 94784a2..373442a 100644 --- a/src/combine/combinearchive.rs +++ b/src/combine/combinearchive.rs @@ -878,10 +878,10 @@ mod tests { for i in 1..=5 { archive .add_entry( - format!("./file{}.txt", i), + format!("./file{i}.txt"), "text/plain", i == 1, // First file is master - format!("Content of file {}", i).as_bytes(), + format!("Content of file {i}").as_bytes(), ) .unwrap(); } @@ -1111,10 +1111,10 @@ mod tests { for i in 0..100 { archive .add_entry( - format!("./file{:03}.txt", i), + format!("./file{i:03}.txt"), "text/plain", i == 0, // First file is master - format!("Content of file number {}", i).as_bytes(), + format!("Content of file number {i}").as_bytes(), ) .unwrap(); } @@ -1127,16 +1127,16 @@ mod tests { // Verify random entries for i in [0, 25, 50, 75, 99] { - let entry = loaded.entry(&format!("./file{:03}.txt", i)).unwrap(); + let entry = loaded.entry(&format!("./file{i:03}.txt")).unwrap(); assert_eq!( entry.as_string().unwrap(), - format!("Content of file number {}", i) + format!("Content of file number {i}") ); } // Remove half the entries for i in (0..100).step_by(2) { - loaded.remove_entry(&format!("./file{:03}.txt", i)).unwrap(); + loaded.remove_entry(&format!("./file{i:03}.txt")).unwrap(); } assert_eq!(loaded.list_entries().len(), 52); diff --git a/src/combine/manifest.rs b/src/combine/manifest.rs index 3db50f5..e75c3cd 100644 --- a/src/combine/manifest.rs +++ b/src/combine/manifest.rs @@ -216,7 +216,7 @@ impl FromStr for KnownFormats { Ok(KnownFormats::TSV) } "https://purl.org/NET/mediatypes/text/csv" | "csv" => Ok(KnownFormats::CSV), - _ => Err(format!("Unknown format: {}", s)), + _ => Err(format!("Unknown format: {s}")), } } } diff --git a/src/fbc/fluxbound.rs b/src/fbc/fluxbound.rs index 26b5fda..1ca9884 100644 --- a/src/fbc/fluxbound.rs +++ b/src/fbc/fluxbound.rs @@ -169,8 +169,8 @@ mod tests { ]; for (i, operation) in operations.iter().enumerate() { - let id = format!("fb{}", i); - let reaction_id = format!("reaction{}", i); + let id = format!("fb{i}"); + let reaction_id = format!("reaction{i}"); let flux_bound = FluxBound::new(&model, &id, &reaction_id, *operation) .expect("Failed to create flux bound"); @@ -267,7 +267,7 @@ mod tests { ) .expect("Failed to create flux bound"); - let debug_string = format!("{:?}", flux_bound); + let debug_string = format!("{flux_bound:?}"); assert!(debug_string.contains("FluxBound")); assert!(debug_string.contains("debug_test")); assert!(debug_string.contains("debug_reaction")); diff --git a/src/fbc/fluxboundop.rs b/src/fbc/fluxboundop.rs index c6c2dc6..79edc9e 100644 --- a/src/fbc/fluxboundop.rs +++ b/src/fbc/fluxboundop.rs @@ -93,8 +93,7 @@ impl FromStr for FluxBoundOperation { "equal" | "eq" => Ok(FluxBoundOperation::Equal), "unknown" => Ok(FluxBoundOperation::Unknown), _ => Err(LibSBMLError::InvalidArgument(format!( - "Invalid flux bound operation: {}. Only 'less_equal', 'greater_equal', 'less', 'greater', 'equal', and 'unknown' are supported.", - s + "Invalid flux bound operation: {s}. Only 'less_equal', 'greater_equal', 'less', 'greater', 'equal', and 'unknown' are supported." ))), } } diff --git a/src/fbc/fluxobjective.rs b/src/fbc/fluxobjective.rs index a436e03..38b15a0 100644 --- a/src/fbc/fluxobjective.rs +++ b/src/fbc/fluxobjective.rs @@ -165,8 +165,8 @@ mod tests { let coefficients = [0.0, 1.0, -1.0, 2.5, -0.5, 100.0, -100.0]; for (i, coefficient) in coefficients.iter().enumerate() { - let id = format!("fo{}", i); - let reaction_id = format!("reaction{}", i); + let id = format!("fo{i}"); + let reaction_id = format!("reaction{i}"); let flux_objective = FluxObjective::new(&objective, &id, &reaction_id, *coefficient) .expect("Failed to create flux objective"); @@ -269,7 +269,7 @@ mod tests { let flux_objective = FluxObjective::new(&objective, "debug_test", "debug_reaction", 2.5) .expect("Failed to create flux objective"); - let debug_string = format!("{:?}", flux_objective); + let debug_string = format!("{flux_objective:?}"); assert!(debug_string.contains("FluxObjective")); assert!(debug_string.contains("debug_test")); assert!(debug_string.contains("debug_reaction")); diff --git a/src/fbc/objective.rs b/src/fbc/objective.rs index ffc7b0a..af12cf6 100644 --- a/src/fbc/objective.rs +++ b/src/fbc/objective.rs @@ -315,7 +315,7 @@ mod tests { .create_flux_objective("fo1", "reaction1", 2.5) .expect("Failed to create flux objective"); - let debug_string = format!("{:?}", objective); + let debug_string = format!("{objective:?}"); assert!(debug_string.contains("Objective")); assert!(debug_string.contains("debug_obj")); assert!(debug_string.contains("Minimize")); diff --git a/src/fbc/objectivetype.rs b/src/fbc/objectivetype.rs index 8c323f8..e9770fb 100644 --- a/src/fbc/objectivetype.rs +++ b/src/fbc/objectivetype.rs @@ -54,8 +54,7 @@ impl FromStr for ObjectiveType { "maximize" => Ok(ObjectiveType::Maximize), "minimize" => Ok(ObjectiveType::Minimize), _ => Err(LibSBMLError::InvalidArgument(format!( - "Invalid objective type: {}. Only 'maximize' and 'minimize' are supported.", - s + "Invalid objective type: {s}. Only 'maximize' and 'minimize' are supported." ))), } } diff --git a/src/model.rs b/src/model.rs index 039b4cb..cd90b11 100644 --- a/src/model.rs +++ b/src/model.rs @@ -739,7 +739,7 @@ impl<'a> FromPtr for Model<'a> { match rule.rule_type() { Ok(RuleType::RateRule) => list_of_rate_rules.push(Rc::clone(&rule)), Ok(RuleType::AssignmentRule) => list_of_assignment_rules.push(Rc::clone(&rule)), - Err(e) => println!("{}", e), + Err(e) => println!("{e}"), } } @@ -1510,8 +1510,8 @@ mod tests { ]; for (i, operation) in operations.iter().enumerate() { - let id = format!("f{}", i); - let reaction_id = format!("r{}", i); + let id = format!("f{i}"); + let reaction_id = format!("r{i}"); model .create_flux_bound(id.as_str(), &reaction_id, *operation) .expect("Failed to create flux bound"); diff --git a/tests/e2e.rs b/tests/e2e.rs index d2c6a20..c871cff 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -5,7 +5,7 @@ mod tests { #[test] fn test_sbmldoc_debug() { let doc = create_doc(); - let debug_string = format!("{:?}", doc); + let debug_string = format!("{doc:?}"); insta::assert_snapshot!(debug_string, @r#"SBMLDocument { level: 3, version: 2, model: Some(Model { id: "test_model", name: "", list_of_species: [Species { id: "species", name: Some("species"), compartment: Some("compartment"), initial_amount: None, initial_concentration: Some(1.0), unit: Some("mole"), boundary_condition: Some(false), constant: false, has_only_substance_units: Some(false) }, Species { id: "product", name: Some("product"), compartment: Some("compartment"), initial_amount: None, initial_concentration: Some(1.0), unit: Some("mole"), boundary_condition: Some(false), constant: false, has_only_substance_units: Some(false) }], list_of_compartments: [Compartment { id: "compartment", name: Some("compartment"), spatial_dimensions: None, unit: Some("ml"), size: Some(1.0), volume: Some(1.0), outside: None, constant: Some(true) }], list_of_unit_definitions: [UnitDefinition { id: "ml", name: Some("milliliter"), units: [Unit { kind: Litre, exponent: 1, multiplier: 1.0, scale: -3, offset: 0.0 }] }, UnitDefinition { id: "mole", name: Some("mole"), units: [Unit { kind: Mole, exponent: 1, multiplier: 1.0, scale: 0, offset: 0.0 }, Unit { kind: Litre, exponent: -1, multiplier: 1.0, scale: 0, offset: 0.0 }] }, UnitDefinition { id: "kelvin", name: Some("kelvin"), units: [Unit { kind: Kelvin, exponent: 1, multiplier: 1.0, scale: 0, offset: 0.0 }] }], list_of_reactions: [Reaction { id: "reaction", name: Some("reaction"), reversible: None, compartment: None, reactants: RefCell { value: [SpeciesReference { species: "species", stoichiometry: 1.0, constant: false }] }, products: RefCell { value: [SpeciesReference { species: "product", stoichiometry: 1.0, constant: false }] }, modifiers: RefCell { value: [] } }], list_of_parameters: [Parameter { id: "T", name: None, value: Some(310.0), units: Some("kelvin"), constant: Some(true) }, Parameter { id: "Km", name: None, value: Some(1.0), units: Some("mole"), constant: Some(true) }], list_of_rate_rules: [Rule { type: Ok(RateRule), variable: "product", formula: "kcat * substrate / (substrate + Km)" }], list_of_assignment_rules: [Rule { type: Ok(AssignmentRule), variable: "x", formula: "T * kcat * substrate / (T + Km)" }], list_of_objectives: [Objective { id: "objective", obj_type: Maximize, flux_objectives: [FluxObjective { id: Some("fo1"), reaction: Some("reaction"), coefficient: Some(1.0) }] }], list_of_flux_bounds: [FluxBound { id: Some("fb1"), reaction: Some("reaction"), operation: LessEqual }] }) }"#); } From 1e61e867899ecaa1b3899f9ad440f110537dbee7 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Sat, 19 Jul 2025 00:57:20 +0200 Subject: [PATCH 23/24] Add XML namespace management to SBMLDocument Introduces methods to retrieve, add, and remove XML namespaces in SBMLDocument, enabling better handling of SBML package extension namespaces. Includes corresponding unit tests for these new methods. --- src/sbmldoc.rs | 112 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 2 deletions(-) diff --git a/src/sbmldoc.rs b/src/sbmldoc.rs index 4432ed9..d15da9a 100644 --- a/src/sbmldoc.rs +++ b/src/sbmldoc.rs @@ -5,7 +5,7 @@ //! computational models in systems biology. An SBMLDocument is the root container //! for all SBML content. -use std::{cell::RefCell, rc::Rc}; +use std::{cell::RefCell, collections::HashMap, rc::Rc}; use autocxx::WithinUniquePtr; use cxx::{let_cxx_string, UniquePtr}; @@ -16,7 +16,7 @@ use crate::{ model::Model, namespaces::SBMLNamespaces, packages::{Package, PackageSpec}, - pin_const_ptr, + pin_const_ptr, pin_ptr, prelude::SBMLErrorLog, sbmlcxx, traits::fromptr::FromPtr, @@ -90,6 +90,73 @@ impl SBMLDocument { &self.document } + /// Returns the XML namespaces defined in this SBML document. + /// + /// This method retrieves all namespace prefix-URI pairs that are defined + /// in the document's XML namespace declarations. This includes the core + /// SBML namespace as well as any package extension namespaces. + /// + /// # Returns + /// A HashMap where keys are namespace prefixes and values are namespace URIs. + /// An empty prefix string represents the default namespace. + pub fn namespaces(&self) -> HashMap { + let ns_ptr = self.inner().borrow_mut().getNamespaces(); + let namespaces = pin_ptr!(ns_ptr, sbmlcxx::XMLNamespaces); + + let mut ns_map = HashMap::new(); + let num_namespaces = namespaces.getNumNamespaces().into(); + for i in 0..num_namespaces { + let prefix = namespaces.getPrefix(i.into()); + let uri = namespaces.getURI(i.into()); + ns_map.insert(prefix.to_string(), uri.to_string()); + } + + ns_map + } + + /// Adds a namespace declaration to this SBML document. + /// + /// This method adds a new XML namespace prefix-URI pair to the document's + /// namespace declarations. This is useful when working with SBML package + /// extensions that require specific namespace declarations. + /// + /// # Arguments + /// * `prefix` - The namespace prefix to associate with the URI + /// * `uri` - The namespace URI to be declared + pub fn add_namespace(&self, prefix: &str, uri: &str) { + let ns_ptr = self.inner().borrow_mut().getNamespaces(); + let mut namespaces = pin_ptr!(ns_ptr, sbmlcxx::XMLNamespaces); + + let_cxx_string!(uri = uri); + namespaces.as_mut().add(&uri, prefix); + } + + /// Removes a namespace declaration from this SBML document. + /// + /// This method removes an XML namespace prefix-URI pair from the document's + /// namespace declarations. This is useful when you need to clean up or modify + /// the namespace declarations in an SBML document. + /// + /// # Arguments + /// * `prefix` - The namespace prefix to remove from the document + /// + /// # Returns + /// Result indicating success or containing an error message if the removal failed + pub fn remove_namespace(&self, prefix: &str) -> Result<(), String> { + let ns_ptr = self.inner().borrow_mut().getNamespaces(); + let mut namespaces = pin_ptr!(ns_ptr, sbmlcxx::XMLNamespaces); + + let_cxx_string!(prefix_cpp = prefix); + let res = namespaces.as_mut().remove1(&prefix_cpp); + + match res.0 { + n if n < 0 => Err(format!( + "The namespace '{prefix}' could not be removed. The prefix may not be present." + )), + _ => Ok(()), + } + } + /// Returns the SBML level of the document. pub fn level(&self) -> u32 { let base = unsafe { @@ -340,4 +407,45 @@ mod tests { let _xml = doc.to_xml_string(); assert!(!_xml.is_empty()); } + + #[test] + fn test_retrieve_namespaces() { + let doc = SBMLDocument::default(); + assert!(!doc.namespaces().is_empty()); + assert!(doc.namespaces().contains_key("")); + assert!(doc.namespaces().contains_key("fbc")); + } + + #[test] + fn test_add_namespace() { + let doc = SBMLDocument::default(); + doc.add_namespace("enzymeml", "https://www.enzymeml.org/version2"); + + // Check if the ns has been added + let namespaces = doc.namespaces(); + assert!(namespaces.contains_key("enzymeml")); + assert_eq!(namespaces["enzymeml"], "https://www.enzymeml.org/version2"); + } + + #[test] + fn test_remove_namespace() { + let doc = SBMLDocument::default(); + doc.add_namespace("enzymeml", "https://www.enzymeml.org/version2"); + + doc.remove_namespace("enzymeml") + .expect("Could not remove namespace"); + + // Check if the ns has been removed + let namespaces = doc.namespaces(); + assert!(!namespaces.contains_key("enzymeml")); + } + + #[test] + #[should_panic] + fn test_remove_namespace_non_existent() { + let doc = SBMLDocument::default(); + + doc.remove_namespace("enzymeml") + .expect("Could not remove namespace"); + } } From cd3a95914e00d2a402ed14ca1e3d745286bd65b8 Mon Sep 17 00:00:00 2001 From: Jan Range <30547301+JR-1991@users.noreply.github.com> Date: Sat, 19 Jul 2025 01:08:04 +0200 Subject: [PATCH 24/24] Allow mut_from_ref lint in SBase trait Added #[allow(clippy::mut_from_ref)] to the base() method in the SBase trait to suppress Clippy lint warning. This is necessary due to the method's signature returning a mutable reference from an immutable one, which is required for interoperability with C++ code. --- src/traits/sbase.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/traits/sbase.rs b/src/traits/sbase.rs index e64ebfe..83db712 100644 --- a/src/traits/sbase.rs +++ b/src/traits/sbase.rs @@ -6,5 +6,6 @@ pub(crate) trait SBase<'a, T>: Inner<'a, T> { /// Returns a pinned reference to the underlying SBase object. /// /// This is useful when you need to pass a pinned reference to C++ code. + #[allow(clippy::mut_from_ref)] fn base(&self) -> std::pin::Pin<&mut sbmlcxx::SBase>; }