diff --git a/Cargo.lock b/Cargo.lock index 04ce844..2fad452 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" @@ -742,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", @@ -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..adc3873 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,9 +17,10 @@ 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" [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" 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 38ef863..3f4a6fc 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(); @@ -56,7 +56,24 @@ 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"); + + // 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(()) } 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" + ); + } +} diff --git a/src/combine/combinearchive.rs b/src/combine/combinearchive.rs new file mode 100644 index 0000000..373442a --- /dev/null +++ b/src/combine/combinearchive.rs @@ -0,0 +1,1612 @@ +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. + /// + /// # 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, + 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::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 - 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, + 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") + /// + /// # 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 + 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 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 + /// 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))?; + + // Check if manifest.xml exists in the archive + let mut manifest_buf = Vec::new(); + 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) + } + + /// 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 crate::combine::KnownFormats; + + 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(), 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] + 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() + .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() + .replace("\r\n", "\n"); + 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(), 3); + 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(), 5); + 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(), 4); + 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(), 5); + + // Remove an entry + loaded_archive.remove_entry("./data1.csv").unwrap(); + assert_eq!(loaded_archive.list_entries().len(), 4); + 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(), 5); + 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(), 5); + 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{i}.txt"), + "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(), 7); + 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 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] + 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(), 3); + } + + #[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{i:03}.txt"), + "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(), 102); + + // Verify random entries + for i in [0, 25, 50, 75, 99] { + let entry = loaded.entry(&format!("./file{i:03}.txt")).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{i:03}.txt")).unwrap(); + } + + 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(), 52); + } + + #[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(), 3); + 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 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"); + 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(), 3); + + // 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 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"); + 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(), 3); + 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 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"); + 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(), 4); + + 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); + } + + #[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"); + } + + #[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 new file mode 100644 index 0000000..1a256af --- /dev/null +++ b/src/combine/error.rs @@ -0,0 +1,43 @@ +/// 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, + + /// 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, +} diff --git a/src/combine/manifest.rs b/src/combine/manifest.rs new file mode 100644 index 0000000..e75c3cd --- /dev/null +++ b/src/combine/manifest.rs @@ -0,0 +1,460 @@ +//! 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::{se::Serializer, 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, + 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 { + 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 + /// + /// # 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)] +#[allow(clippy::upper_case_acronyms)] +pub enum KnownFormats { + /// Systems Biology Markup Language (SBML) + SBML, + /// Simulation Experiment Description Markup Language (SED-ML) + SEDML, + /// Systems Biology Graphical Notation (SBGN) + SBGN, + /// Tab-separated values (TSV) + TSV, + /// Comma-separated values (CSV) + CSV, +} + +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), + "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}")), + } + } +} + +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"), + KnownFormats::TSV => write!( + f, + "https://purl.org/NET/mediatypes/text/tab-separated-values" + ), + KnownFormats::CSV => write!(f, "https://purl.org/NET/mediatypes/text/csv"), + } + } +} + +#[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!(manifest.content[2].master); + 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!(manifest.content[2].master); + 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!(!manifest.content[0].master); + + 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!(!manifest.content[1].master); + + 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!(!manifest.content[2].master); + } + + #[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/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/lib.rs b/src/lib.rs index 00f5681..369defc 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::*; @@ -165,6 +166,14 @@ pub mod prelude { pub use crate::unitdef::*; } +pub mod combine { + pub use crate::combine::combinearchive::*; + pub use crate::combine::manifest::KnownFormats; + pub mod combinearchive; + pub mod error; + pub 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. 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..3202a40 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -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(()) } } @@ -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) 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/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..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, @@ -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. @@ -102,6 +90,73 @@ impl<'a> SBMLDocument<'a> { &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 { @@ -147,15 +202,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()?.isSetModel(); + + 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 +265,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 +275,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 +388,64 @@ 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()); + } + + #[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"); + } } 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>; } 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 0000000..087704d Binary files /dev/null and b/tests/data/test.omex differ diff --git a/tests/e2e.rs b/tests/e2e.rs index f305f42..c871cff 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -5,8 +5,8 @@ mod tests { #[test] 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 }] }) }"#); + 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 }] }) }"#); } #[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");