diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 01507a7..dffa44f 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -6,9 +6,10 @@ on: paths: - "**/Cargo.toml" - "**/Cargo.lock" + - "**/deny.toml" jobs: security_audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: EmbarkStudios/cargo-deny-action@v1 + - uses: EmbarkStudios/cargo-deny-action@v2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2498f05..a33a308 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: with: components: rustfmt - name: Enforce formatting - run: cargo fmt --all--check + run: cargo fmt --all --check clippy: name: Clippy diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml new file mode 100644 index 0000000..dec080c --- /dev/null +++ b/.github/workflows/release-plz.yml @@ -0,0 +1,27 @@ +name: Release-plz + +permissions: + pull-requests: write + contents: write + +on: + push: + branches: + - main + +jobs: + release-plz: + name: Release-plz + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + - name: Run release-plz + uses: MarcoIeni/release-plz-action@v0.5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 6d1d877..9986117 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,863 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.13.3+wasi-0.2.2", + "windows-targets", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[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 = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.170" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" + +[[package]] +name = "log" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" + +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" + +[[package]] +name = "oncemutex" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d11de466f4a3006fe8a5e7ec84e93b79c70cb992ae0aa0eb631ad2df8abfe2" + +[[package]] +name = "phonenumber" +version = "0.3.7+8.13.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2247167dc3741816fdd4d3690e97f56a892a264b44f4c702078b72d1f8b6bd40" +dependencies = [ + "bincode", + "either", + "fnv", + "nom", + "once_cell", + "quick-xml", + "regex", + "regex-cache", + "serde", + "serde_derive", + "strum", + "thiserror 1.0.69", +] + [[package]] name = "planter-core" version = "0.0.1" +dependencies = [ + "chrono", + "phonenumber", + "proptest", + "serde-email", + "thiserror 2.0.12", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14cae93065090804185d3b75f0bf93b8eeda30c7a9b4a33d3bdb3988d6229e50" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "lazy_static", + "num-traits", + "proptest-macro", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax 0.8.5", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "proptest-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37f29eb7ffe1011ed152b761e866c717244d37c10032f8cbdc08388d733a31b7" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-xml" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165859e9e55f79d67b96c5d96f4e88b6f2695a1972849c15a6a3f5c59fc2c003" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1f1914ce909e1658d9907913b4b91947430c7d9be598b15a1912935b8c04801" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "rand_xorshift" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-cache" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f7b62d69743b8b94f353b6b7c3deb4c5582828328bcb8d5fedf214373808793" +dependencies = [ + "lru-cache", + "oncemutex", + "regex", + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustix" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dade4812df5c384711475be5fcd8c162555352945401aed22a35bffeab61f657" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "rusty-fork" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "serde" +version = "1.0.218" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-email" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f8652ec4a59c7e1a4881c50339a711a8ef9b1fe2e87a84c6fff58eee242a2" +dependencies = [ + "email_address", + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.218" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02e925281e18ffd9d640e234264753c43edc62d64b2d4cf898f1bc5e75f3fc2" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c317e0a526ee6120d8dabad239c8dadca62b24b6f168914bbbc8e2fb1f0e567" +dependencies = [ + "cfg-if", + "fastrand", + "getrandom 0.3.1", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.13.3+wasi-0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +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", + "rustversion", + "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", + "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", + "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 = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-link" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 361aa04..2b90043 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,19 +2,31 @@ name = "planter-core" version = "0.0.1" edition = "2024" +authors = ["Sebastiano Giordano"] +description = "Domain logic for PlanTer, a project management application" +documentation = "https://docs.rs/planter-core" +keywords = ["project_management"] +license = "MIT OR Apache-2.0" +readme = "README.md" +repository = "https://github.com/krahos/planter-core" [lib] path = "src/lib.rs" [dependencies] +chrono = "0.4.40" +phonenumber = "0.3.7" +serde-email = "3.1.0" +thiserror = "2.0.12" +[dev-dependencies] +proptest = { version = "1.6.0", features = ["proptest-macro"] } [lints.rust] unsafe_code = "forbid" missing_docs = "warn" [lints.clippy] - unwrap_in_result = "warn" unwrap_used = "warn" expect_used = "warn" diff --git a/README.md b/README.md new file mode 100644 index 0000000..3428fd3 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# PlanTer Core +Domain logic for PlanTer, a project management application. diff --git a/deny.toml b/deny.toml index 71649b7..841cff5 100644 --- a/deny.toml +++ b/deny.toml @@ -88,7 +88,7 @@ ignore = [ # List of explicitly allowed licenses # See https://spdx.org/licenses/ for list of possible licenses # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. -allow = ["MIT", "Apache-2.0"] +allow = ["MIT", "Apache-2.0", "Unicode-3.0"] # The confidence threshold for detecting a license from license text. # The higher the value, the more closely the license text must be to the # canonical license text of a valid SPDX license file. diff --git a/flake.nix b/flake.nix index 312b21c..547019e 100644 --- a/flake.nix +++ b/flake.nix @@ -21,6 +21,7 @@ buildInputs = [ bacon cargo-udeps + cargo-edit rust-analyzer cargo-deny rust-bin.stable.latest.default diff --git a/src/duration.rs b/src/duration.rs new file mode 100644 index 0000000..8a38087 --- /dev/null +++ b/src/duration.rs @@ -0,0 +1,45 @@ +use std::ops::Deref; + +use chrono::Duration; +use thiserror::Error; + +/// A duration is a unit of time that represents the amount of time required to complete a task. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct PositiveDuration(Duration); + +/// Represents an error that occurs when trying to parse a negative duration. +#[derive(Error, Debug)] +pub enum DurationError { + /// Used when the wanted duration would be negative. + #[error("Negative values are not allowed for durations")] + NegativeDuration, + /// Used when the wanted duration would exceed the maximum allowed value. + #[error("Duration would exceed maximum allowed value")] + ExceedsMaximumDuration, +} + +/// Maximum duration allowed is 31.68809 years. +pub const MAX_DURATION: i64 = 1_000_000_000_000; + +impl TryFrom for PositiveDuration { + type Error = DurationError; + + /// Creates a new `PositiveDuration` from a `chrono::Duration`. + fn try_from(value: Duration) -> Result { + if value < Duration::milliseconds(0) { + Err(DurationError::NegativeDuration) + } else if value > Duration::milliseconds(MAX_DURATION) { + Err(DurationError::ExceedsMaximumDuration) + } else { + Ok(PositiveDuration(value)) + } + } +} + +impl Deref for PositiveDuration { + type Target = Duration; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/src/lib.rs b/src/lib.rs index d626790..e6fa8a6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,24 @@ -//! This is a library which contains the core types and behaviour for the PlanTer project management application. +//! This is a library with types and behaviour inspired by the PMBOK Guide 7th edition. + +/// A duration is a unit of time that represents the amount of time required to complete a task. +pub mod duration; +/// A person can either be a resource, a team member or a stakeholder. +pub mod person; +/// A project is a set of activities required to transform ideas into reality. +/// It is composed by tasks, team, stakeholders, resources and so on. +/// It is usually divided into different phases: +/// - Feasibility, where the project is evaluated to understand if the project is valid, and the organization is capable of delivering the expected outcome. +/// - Design. Planning and analysis lead to the design of the project deliverable that will be developed. +/// - Build. Construction of the deliverable with integrated quality assurance activities is conducted. +/// - Test. Final quality review and inspection of deliverables are carried out before transition, go-live, or acceptance by the customer. +/// - Deploy. Project deliverables are put into use and transitional activities required for sustainment, benefits realization, and organizational change management are completed. +/// - Close. The project is closed, project knowledge and artifacts are archived, project team members are released, and contracts are closed. +/// Depending on the kind of project (adaptive, predictive, hybrid), these phases can occur one after the other sequentially or in a loop. +/// In a predictive approach, each phase comes right after the previous one. In an adaptive approach, there are blocks of planning, design, build that go one after the other, until the project is done. At the end of each iteration (sprint), there is feedback on the work done, and a prioritisation of the backlog. +pub mod project; +/// A resource is a material required in the project to carry out tasks and provide deliverables. Resources have a cost, the sum of which, will concur with the total cost of the project. Project team members can also be considered resources. +pub mod resources; +/// A stakeholder is a person or organization that has an interest in the project. Stakeholders have a level of influence and a level of interest in the project. +pub mod stakeholders; +/// A task is a unit of work that needs to be completed in order to achieve the project's objectives. Tasks have a duration, a start date, an end date, and a status. +pub mod task; diff --git a/src/person.rs b/src/person.rs new file mode 100644 index 0000000..f94eacf --- /dev/null +++ b/src/person.rs @@ -0,0 +1,411 @@ +use phonenumber::PhoneNumber; +use serde_email::Email; + +#[derive(Debug, Clone)] +/// Represents a person with a name and contact information. +pub struct Person { + /// The name of the person. + name: Name, + /// The contact information of the person. + contacts: Contacts, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +/// Represents the name of a person. +pub struct Name { + /// The first name of the person. + first: String, + /// The last name of the person. + last: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +/// The contact information available for a person. All the fields are optional. +pub struct Contacts { + /// The email address of the person. + email: Option, + /// The phone number of the person. + phone: Option, +} + +impl Person { + /// Create a new `Person` with the given name. + /// + /// # Arguments + /// * `name` - The name of the person. + /// + /// # Returns + /// A new `Person` instance. + /// + /// # Examples + /// ``` + /// use planter_core::person::{Person, Name}; + /// + /// let name = Name::parse("Margherita".to_string(), "Hack".to_string()).unwrap(); + /// let person = Person::new(name); + /// ``` + pub fn new(name: Name) -> Self { + Person { + name, + contacts: Contacts { + email: None, + phone: None, + }, + } + } + + /// Add or edit the email address of the person. + /// + /// # Arguments + /// * `email` - The new email address of the person. + /// + /// # Examples + /// ``` + /// use planter_core::person::{Person, Name, Contacts}; + /// use serde_email::Email; + /// use std::str::FromStr; + /// + /// let name = Name::parse("Margherita".to_string(), "Hack".to_string()).unwrap(); + /// let mut person = Person::new(name); + /// let email = Email::from_str("margherita.hack@example.com").unwrap(); + /// person.add_email(email.clone()); + /// assert_eq!(person.contacts().email(), Some(&email)); + /// ``` + pub fn add_email(&mut self, email: Email) { + self.contacts.email = Some(email); + } + + /// Add or edit the phone number of the person. + /// + /// # Arguments + /// * `phone` - The new phone number of the person. + /// + /// # Examples + /// ``` + /// use planter_core::person::{Person, Name, Contacts}; + /// use serde_email::Email; + /// use std::str::FromStr; + /// use phonenumber::PhoneNumber; + /// + /// let name = Name::parse("Margherita".to_string(), "Hack".to_string()).unwrap(); + /// let mut person = Person::new(name); + /// let phone = PhoneNumber::from_str("+1234567890").unwrap(); + /// person.add_phone(phone.clone()); + /// assert_eq!(person.contacts().phone(), Some(&phone)); + /// ``` + pub fn add_phone(&mut self, phone: PhoneNumber) { + self.contacts.phone = Some(phone); + } + + /// Get the contacts of the person. + /// + /// # Examples + /// ``` + /// use planter_core::person::{Person, Name, Contacts}; + /// use phonenumber::PhoneNumber; + /// use std::str::FromStr; + /// + /// let name = Name::parse("Margherita".to_string(), "Hack".to_string()).unwrap(); + /// let mut person = Person::new(name); + /// let phone = PhoneNumber::from_str("+1234567890").unwrap(); + /// person.add_phone(phone.clone()); + /// assert_eq!(person.contacts().phone(), Some(&phone)); + /// ``` + pub fn contacts(&self) -> &Contacts { + &self.contacts + } + + /// Get the name of the person. + /// + /// # Examples + /// ``` + /// use planter_core::person::{Person, Name}; + /// + /// let name = Name::parse("Margherita".to_string(), "Hack".to_string()).unwrap(); + /// let mut person = Person::new(name.clone()); + /// assert_eq!(person.name(), &name); + /// ``` + pub fn name(&self) -> &Name { + &self.name + } + + /// Get the first name of the person. + /// + /// # Examples + /// ``` + /// use planter_core::person::{Person, Name}; + /// + /// let name = Name::parse("Margherita".to_string(), "Hack".to_string()).unwrap(); + /// let mut person = Person::new(name); + /// assert_eq!(person.first_name(), "Margherita"); + /// ``` + pub fn first_name(&self) -> &str { + self.name.first() + } + + /// Get the last name of the person. + /// + /// # Examples + /// ``` + /// use planter_core::person::{Person, Name}; + /// + /// let name = Name::parse("Margherita".to_string(), "Hack".to_string()).unwrap(); + /// let mut person = Person::new(name); + /// assert_eq!(person.last_name(), "Hack"); + /// ``` + pub fn last_name(&self) -> &str { + self.name.last() + } +} + +const NAME_LEN: usize = 50; + +impl Name { + /// Parse a name from a first and last name. + /// + /// # Arguments + /// * `first` - The first name of the person. + /// * `last` - The last name of the person. + /// + /// # Returns + /// An `Option` containing the parsed `Name` if the input is valid, or + /// `None` otherwise. + /// + /// # Examples + /// ``` + /// use planter_core::person::Name; + /// + /// let name = Name::parse("Margherita".to_string(), "Hack".to_string()); + /// assert!(name.is_some()); + /// ``` + pub fn parse(first: String, last: String) -> Option { + if first.is_empty() || last.is_empty() || first.len() > NAME_LEN || last.len() > NAME_LEN { + None + } else { + Some(Name { first, last }) + } + } + + /// Get the first name of the person. + /// + /// # Examples + /// ``` + /// use planter_core::person::{Name}; + /// + /// let name = Name::parse("Margherita".to_string(), "Hack".to_string()).unwrap(); + /// assert_eq!(name.first(), "Margherita"); + /// ``` + pub fn first(&self) -> &str { + &self.first + } + + /// Get the last name of the person. + /// + /// # Examples + /// ``` + /// use planter_core::person::{Name}; + /// + /// let name = Name::parse("Margherita".to_string(), "Hack".to_string()).unwrap(); + /// assert_eq!(name.last(), "Hack"); + /// ``` + pub fn last(&self) -> &str { + &self.last + } +} + +impl Contacts { + /// Create a new `Contacts`. By default, the email and phone number are set to `None`. + /// + /// # Returns + /// A new `Contacts` instance. + /// + /// # Examples + /// ``` + /// use planter_core::person::Contacts; + /// + /// let contacts = Contacts::new(); + /// assert!(contacts.email().is_none()); + /// assert!(contacts.phone().is_none()); + /// ``` + pub fn new() -> Self { + Contacts::default() + } + + /// Set the email address of the person. + /// + /// # Arguments + /// * `email` - The email address of the person. + /// + /// # Returns + /// A new `Contacts` instance with the email address set. + /// + /// # Examples + /// ``` + /// use serde_email::Email; + /// use planter_core::person::Contacts; + /// use std::str::FromStr; + /// + /// let email = Email::from_str("margherita.hack@example.com").unwrap(); + /// let contacts = Contacts::new().with_email(email); + /// assert!(contacts.email().is_some()); + /// ``` + pub fn with_email(mut self, email: Email) -> Self { + self.email = Some(email); + self + } + + /// Get the email address from the contacts. + /// + /// # Returns + /// An `Option` containing a reference to the email address of the person. + /// + /// # Examples + /// ``` + /// use serde_email::Email; + /// use planter_core::person::Contacts; + /// + /// let email = Email::from_str("margherita.hack@example.com").unwrap(); + /// let contacts = Contacts::new().with_email(email.clone()); + /// assert_eq!(contacts.email(), Some(&email)); + /// ``` + pub fn email(&self) -> Option<&Email> { + self.email.as_ref() + } + + /// Set the phone number of the person. + /// + /// # Arguments + /// * `phone` - The phone number of the person. + /// + /// # Returns + /// A new `Contacts` instance with the phone number set. + /// + /// # Examples + /// ``` + /// use phonenumber::PhoneNumber; + /// use planter_core::person::Contacts; + /// use std::str::FromStr; + /// + /// let phone = PhoneNumber::from_str("+1234567890").unwrap(); + /// let contacts = Contacts::new().with_phone(phone); + /// assert!(contacts.phone().is_some()); + /// ``` + pub fn with_phone(mut self, phone: PhoneNumber) -> Self { + self.phone = Some(phone); + self + } + + /// Get the phone number from the contacts. + /// + /// # Returns + /// An `Option` containing a reference to the phone number of the person. + /// + /// # Examples + /// ``` + /// use phonenumber::PhoneNumber; + /// use planter_core::person::Contacts; + /// use std::str::FromStr; + /// + /// let phone = PhoneNumber::from_str("+1234567890").unwrap(); + /// let contacts = Contacts::new().with_phone(phone.clone()); + /// assert_eq!(contacts.phone().unwrap(), &phone); + /// ``` + pub fn phone(&self) -> Option<&PhoneNumber> { + self.phone.as_ref() + } +} + +#[cfg(test)] +#[allow(clippy::missing_panics_doc, clippy::unwrap_used)] +/// Test utilities for the `person` module. +pub mod test_utils { + use std::str::FromStr; + + use phonenumber::PhoneNumber; + use proptest::prelude::Strategy; + use serde_email::Email; + + use super::{Contacts, NAME_LEN, Name}; + + /// Generate a random alphabetic string of length between 1 and `NAME_LEN`. + pub fn alphabetic_string() -> impl Strategy { + ".*".prop_map(|s: String| { + let s = s + .chars() + .filter(|c| c.is_alphabetic()) + .take(NAME_LEN) + .collect::(); + if s.is_empty() { String::from("a") } else { s } + }) + } + + /// Generate a random name. + pub fn name() -> impl Strategy { + (alphabetic_string(), alphabetic_string()) + .prop_map(|(first, last)| Name::parse(first, last).unwrap()) + } + + /// Generate a random email address. + pub fn email() -> impl Strategy { + r"^\+?[1-9][0-9]{7,14}$".prop_map(|s: String| Email::from_str(&s).unwrap()) + } + + /// Generate a random phone number. + pub fn phone_number() -> impl Strategy { + r"^\d{3}-\d{3}-\d{4}$".prop_map(|s: String| PhoneNumber::from_str(&s).unwrap()) + } + + /// Generate a random contact information. + pub fn contact() -> impl Strategy { + (email(), phone_number()) + .prop_map(|(email, phone)| Contacts::default().with_email(email).with_phone(phone)) + } +} + +#[cfg(test)] +mod tests { + use crate::person::test_utils::alphabetic_string; + + use super::*; + use proptest::prelude::*; + + proptest! { + #[test] + fn test_parse_name(first in alphabetic_string(), last in alphabetic_string()) { + let name = Name::parse(first.clone(), last.clone()); + assert!(name.is_some(), "Name was {first} {last}"); + } + } + + #[test] + fn parse_name_returns_none_for_empty_first_name() { + let first = String::new(); + let last = String::from("something"); + let name = Name::parse(first, last); + assert!(name.is_none()); + } + + #[test] + fn parse_name_returns_none_for_empty_last_name() { + let first = String::from("something"); + let last = String::new(); + let name = Name::parse(first, last); + assert!(name.is_none()); + } + + #[test] + fn parse_name_returns_none_for_long_last_name() { + let first = "a".repeat(NAME_LEN); + let last = "b".repeat(NAME_LEN + 1); + let name = Name::parse(first, last); + assert!(name.is_none()); + } + + #[test] + fn parse_name_returns_none_for_long_first_name() { + let first = "a".repeat(NAME_LEN + 1); + let last = "b".repeat(NAME_LEN); + let name = Name::parse(first, last); + assert!(name.is_none()); + } +} diff --git a/src/project.rs b/src/project.rs new file mode 100644 index 0000000..98b6f65 --- /dev/null +++ b/src/project.rs @@ -0,0 +1,250 @@ +use chrono::{DateTime, Utc}; + +use crate::{resources::Resource, stakeholders::Stakeholder, task::Task}; + +/// Represents a project with a name and a list of resources. +pub struct Project { + /// The name of the project. + name: String, + /// The description of the project. + description: Option, + /// The start date of the project. + start_date: Option>, + /// The list of tasks associated with the project. + tasks: Vec, + /// The list of resources associated with the project. + resources: Vec, + /// The list of stakeholders associated with the project. + stakeholders: Vec, +} + +impl Project { + /// Creates a new project with the given name. + /// + /// # Arguments + /// + /// * `name` - The name of the project. + /// + /// # Returns + /// + /// A new `Project` instance. + /// + /// # Example + /// + /// ``` + /// use planter_core::project::Project; + /// + /// let project = Project::new("World domination".to_string()); + /// assert_eq!(project.name(), "World domination"); + /// ``` + pub fn new(name: String) -> Self { + Self { + name, + description: None, + start_date: None, + resources: Vec::new(), + stakeholders: Vec::new(), + tasks: Vec::new(), + } + } + + /// Returns the name of the project. + /// + /// # Example + /// + /// ``` + /// use planter_core::project::Project; + /// + /// let project = Project::new("World domination".to_string()); + /// assert_eq!(project.name(), "World domination"); + /// ``` + pub fn name(&self) -> &str { + &self.name + } + + /// Adds a description to the project. + /// + /// # Arguments + /// + /// * `description` - The description to add to the project. + /// + /// # Example + /// + /// ``` + /// use planter_core::project::Project; + /// + /// let project = Project::new("World domination".to_string()).with_description("This is a project".to_string()); + /// assert_eq!(project.description(), Some("This is a project")); + /// ``` + pub fn with_description(mut self, description: String) -> Self { + self.description = Some(description); + self + } + + /// Adds a start date to the project. + /// + /// # Arguments + /// + /// * `start_date` - The start date to add to the project. + /// + /// # Example + /// + /// ``` + /// use planter_core::project::Project; + /// use chrono::Utc; + /// + /// let start_date = Utc::now(); + /// let project = Project::new("World domination".to_string()).with_start_date(start_date); + /// assert_eq!(project.start_date(), Some(start_date)); + /// ``` + pub fn with_start_date(mut self, start_date: DateTime) -> Self { + self.start_date = Some(start_date); + self + } + + /// Returns the description of the project. + /// + /// # Example + /// + /// ``` + /// use planter_core::project::Project; + /// + /// let project = Project::new("World domination".to_string()); + /// assert_eq!(project.description(), None); + /// ``` + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + /// Adds a task to the project. + /// + /// # Arguments + /// + /// * `task` - The task to add to the project. + /// + /// # Example + /// + /// ``` + /// use planter_core::{project::Project, task::Task}; + /// + /// let mut project = Project::new("World domination".to_string()); + /// assert_eq!(project.tasks().len(), 0); + /// project.add_task(Task::new("Become world leader".to_string())); + /// assert_eq!(project.tasks().len(), 1); + /// ``` + pub fn add_task(&mut self, task: Task) { + self.tasks.push(task); + } + + /// Returns the tasks of the project. + /// + /// # Example + /// + /// ``` + /// use planter_core::{project::Project, task::Task}; + /// + /// let mut project = Project::new("World domination".to_string()); + /// project.add_task(Task::new("Become world leader".to_string())); + /// assert_eq!(project.tasks().len(), 1); + /// ``` + pub fn tasks(&self) -> &Vec { + &self.tasks + } + + /// Returns the start date of the project. + /// + /// # Example + /// + /// ``` + /// use planter_core::{project::Project}; + /// use chrono::Utc; + /// + /// let start_date = Utc::now(); + /// let project = Project::new("World domination".to_string()).with_start_date(start_date); + /// assert_eq!(project.start_date(), Some(start_date)); + /// ``` + pub fn start_date(&self) -> Option> { + self.start_date + } + + /// Adds a resource to the project. + /// + /// # Arguments + /// + /// * `resource` - The resource to add to the project. + /// + /// # Example + /// + /// ``` + /// use planter_core::{resources::Resource, project::Project, person::{Person, Name}}; + /// + /// let mut project = Project::new("World domination".to_string()); + /// project.add_resource(Resource::Personnel { + /// person: Person::new(Name::parse("Sebastiano".to_string(), "Giordano".to_string()).unwrap()), + /// hourly_rate: None, + /// }); + /// assert_eq!(project.resources().len(), 1); + /// ``` + pub fn add_resource(&mut self, resource: Resource) { + self.resources.push(resource); + } + + /// Returns a reference to the list of resources associated with the project. + /// + /// # Example + /// + /// ``` + /// use planter_core::{resources::{Resource, Material, NonConsumable}, project::Project}; + /// + /// let mut project = Project::new("World domination".to_string()); + /// project.add_resource(Resource::Material(Material::NonConsumable( + /// NonConsumable::new("Crowbar".to_string()), + /// ))); + /// assert_eq!(project.resources().len(), 1); + /// ``` + pub fn resources(&self) -> &[Resource] { + &self.resources + } + + /// Adds a stakeholder to the project. + /// + /// # Arguments + /// + /// * `stakeholder` - The stakeholder to add to the project. + /// + /// # Example + /// + /// ``` + /// use planter_core::{stakeholders::Stakeholder, project::Project, person::{Person, Name}}; + /// + /// let mut project = Project::new("World domination".to_string()); + /// let person = Person::new(Name::parse("Margherita".to_string(), "Hack".to_string()).unwrap()); + /// project.add_stakeholder(Stakeholder::Individual { + /// person, + /// description: None, + /// }); + /// assert_eq!(project.stakeholders().len(), 1); + /// ``` + pub fn add_stakeholder(&mut self, stakeholder: Stakeholder) { + self.stakeholders.push(stakeholder); + } + + /// Returns a reference to the list of stakeholders associated with the project. + /// + /// # Example + /// + /// ``` + /// use planter_core::{stakeholders::Stakeholder, project::Project, person::{Person, Name}}; + /// + /// let mut project = Project::new("World domination".to_string()); + /// let person = Person::new(Name::parse("Margherita".to_string(), "Hack".to_string()).unwrap()); + /// project.add_stakeholder(Stakeholder::Individual { + /// person, + /// description: None, + /// }); + /// assert_eq!(project.stakeholders().len(), 1); + /// ``` + pub fn stakeholders(&self) -> &[Stakeholder] { + &self.stakeholders + } +} diff --git a/src/resources.rs b/src/resources.rs new file mode 100644 index 0000000..eff748b --- /dev/null +++ b/src/resources.rs @@ -0,0 +1,141 @@ +use crate::person::Person; + +#[derive(Debug, Clone)] +/// Represents a resource that can be used in a project. A resource can be either a material or personnel. +pub enum Resource { + /// Represents a material resource that can be used in a project. + Material(Material), + /// Represents a personnel resource. Personnel is usually a resource that can complete tasks + Personnel { + /// Information about the person. + person: Person, + /// Hourly rate of the person. + hourly_rate: Option, + }, +} + +#[derive(Debug, Clone)] +/// Represents a material resource that can be used in a project. +/// It can be either consumable or non-consumable. +pub enum Material { + /// A consumable resource is a material that needs to be resupplied after use. + Consumable(Consumable), + /// A non-consumable resource is a material that does not need to be resupplied after use. + NonConsumable(NonConsumable), +} + +#[derive(Debug, Clone, Default)] +/// Represents a consumable material resource that can be used in a project. +pub struct Consumable { + /// Name of the consumable material. + name: String, + /// Available quantity of the consumable material. + quantity: Option, + /// Cost per unit of the consumable material used. + cost_per_unit: Option, +} + +#[derive(Debug, Clone, Default)] +/// Represents a non-consumable material resource that can be used in a project. +pub struct NonConsumable { + /// Name of the non-consumable material. + name: String, + /// Available quantity of the non-consumable material. + quantity: Option, + /// Some non consumable materials can have a hourly rate. For example, due to energy consumption. + hourly_rate: Option, +} + +impl Consumable { + /// Creates a new consumable material resource. + pub fn new(name: String) -> Self { + Consumable { + name, + quantity: None, + cost_per_unit: None, + } + } + + /// Returns the name of the consumable material. + /// # Example + /// ``` + /// use planter_core::resources::Consumable; + /// + /// let consumable = Consumable::new("Steel".to_string()); + /// assert_eq!(consumable.name(), "Steel"); + /// ``` + pub fn name(&self) -> &str { + &self.name + } + + /// Returns the quantity of the consumable material. + /// # Example + /// ``` + /// use planter_core::resources::Consumable; + /// + /// let consumable = Consumable::new("Steel".to_string()); + /// assert_eq!(consumable.quantity(), None); + /// ``` + pub fn quantity(&self) -> Option { + self.quantity + } + + /// Returns the cost per unit of the consumable material. + /// # Example + /// ``` + /// use planter_core::resources::Consumable; + /// + /// let consumable = Consumable::new("Steel".to_string()); + /// assert_eq!(consumable.cost_per_unit(), None); + /// ``` + pub fn cost_per_unit(&self) -> Option { + self.cost_per_unit + } +} + +impl NonConsumable { + /// Creates a new non-consumable material resource. + pub fn new(name: String) -> Self { + NonConsumable { + name, + quantity: None, + hourly_rate: None, + } + } + + /// Returns the name of the non-consumable material. + /// # Example + /// ``` + /// use planter_core::resources::NonConsumable; + /// + /// let non_consumable = NonConsumable::new("Steel".to_string()); + /// assert_eq!(non_consumable.name(), "Steel"); + /// ``` + pub fn name(&self) -> &str { + &self.name + } + + /// Returns the quantity of the non-consumable material. + /// # Example + /// ``` + /// use planter_core::resources::NonConsumable; + /// + /// let non_consumable = NonConsumable::new("Steel".to_string()); + /// assert_eq!(non_consumable.quantity(), None); + /// ``` + pub fn quantity(&self) -> Option { + self.quantity + } + + /// Returns the hourly rate of the non-consumable material. + /// # Example + /// ``` + /// use planter_core::resources::NonConsumable; + /// + /// let non_consumable = NonConsumable::new("Steel".to_string()); + /// assert_eq!(non_consumable.hourly_rate(), None); + /// ``` + pub fn hourly_rate(&self) -> Option { + self.hourly_rate + } +} diff --git a/src/stakeholders.rs b/src/stakeholders.rs new file mode 100644 index 0000000..18897bc --- /dev/null +++ b/src/stakeholders.rs @@ -0,0 +1,20 @@ +use crate::person::Person; + +/// Stakeholders are all those individuals, organizations or entities who have an interest in the project. +/// Their interest could be constructive or destructive. +pub enum Stakeholder { + /// A person who has an interest in the project. + Individual { + /// The personal information of the individual. + person: Person, + /// A description of the individual's interest in the project. + description: Option, + }, + /// An organization that has an interest in the project. + Organization { + /// The name of the organization. + name: String, + /// A description of the organization's interest in the project. + description: Option, + }, +} diff --git a/src/task.rs b/src/task.rs new file mode 100644 index 0000000..3cb1017 --- /dev/null +++ b/src/task.rs @@ -0,0 +1,422 @@ +use crate::{duration::PositiveDuration, resources::Resource}; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone, Default)] +/// A task is a unit of work that can be completed by a person or a group of people. +/// It can be assigned resources and can have a start, finish, and duration. +pub struct Task { + /// The title of the task. + title: String, + /// The description of the task. + description: String, + /// Whether the task is completed. + completed: bool, + /// The start time of the task. + start: Option>, + /// The finish time of the task. + finish: Option>, + /// The duration of the task. + duration: Option, + /// The resources assigned to the task. + resources: Vec, +} + +impl Task { + /// Creates a new task with the given title. + /// + /// # Arguments + /// + /// * `title` - The title of the task. + /// + /// # Returns + /// + /// A new task with the given title. + /// + /// # Example + /// + /// ``` + /// use planter_core::task::Task; + /// + /// let task = Task::new("Become world leader".to_string()); + /// assert_eq!(task.title(), "Become world leader"); + /// ``` + pub fn new(title: String) -> Self { + Task { + title, + description: String::new(), + completed: false, + start: None, + finish: None, + duration: None, + resources: Vec::new(), + } + } + + /// Edits the start time of the task. + /// If a duration is already set, the finish time will be updated accordingly. + /// If there is a finish time set, but not a duration, the duration will be updated accordingly. + /// The finish time will be pushed ahead if the start time is after the finish time. + /// + /// # Arguments + /// + /// * `start` - The new start time of the task. + /// + /// # Panics + /// + /// Panics if start and end times are too far apart see [`duration`] for details. + /// + /// # Example + /// + /// ``` + /// use chrono::{Utc, Duration}; + /// use planter_core::task::Task; + /// + /// let mut task = Task::new("Become world leader".to_string()); + /// let start_time = Utc::now(); + /// task.edit_start(start_time); + /// assert_eq!(task.start().unwrap(), start_time); + /// ``` + #[allow(clippy::expect_used)] + pub fn edit_start(&mut self, start: DateTime) { + self.start = Some(start); + + if let Some(duration) = self.duration { + self.finish = Some(start + *duration); + } + + if let Some(finish) = self.finish { + if finish < start { + self.finish = Some(start); + } + if self.duration().is_none() { + let duration = finish - start; + self.duration = Some( + duration + .try_into() + .expect("Start time and end time were too far apart"), + ); + } + } + } + + /// Returns the start time of the task. It's None by default. + /// + /// # Example + /// + /// ``` + /// use chrono::{Utc}; + /// use planter_core::task::Task; + /// + /// let mut task = Task::new("Become world leader".to_string()); + /// assert!(task.start().is_none()); + /// + /// let start_time = Utc::now(); + /// task.edit_start(start_time); + /// assert_eq!(task.start().unwrap(), start_time); + /// ``` + pub fn start(&self) -> Option> { + self.start + } + + /// Edits the finish time of the task. + /// If there is a start time already set, duration will be updated accordingly. + /// Start time will be pushed back if it's after the finish time. + /// + /// # Arguments + /// + /// * `finish` - The new finish time of the task. + /// + /// # Panics + /// + /// Panics if start and end times are too far apart see [`duration`] for details. + /// + /// # Example + /// + /// ``` + /// use chrono::{Utc}; + /// use planter_core::task::Task; + /// + /// let mut task = Task::new("Become world leader".to_string()); + /// assert!(task.start().is_none()); + /// + /// let mut finish_time = Utc::now(); + /// task.edit_finish(finish_time); + /// assert_eq!(task.finish().unwrap(), finish_time); + /// ``` + #[allow(clippy::expect_used)] + pub fn edit_finish(&mut self, finish: DateTime) { + self.finish = Some(finish); + + if let Some(start) = self.start() { + let start = if finish < start { + self.start = Some(finish); + finish + } else { + start + }; + let duration = finish - start; + self.duration = Some( + duration + .try_into() + .expect("Start time and end time were too far apart"), + ); + } + } + + /// Returns the finish time of the task. It's None by default. + /// + /// # Example + /// + /// ``` + /// use chrono::{Utc}; + /// use planter_core::task::Task; + /// + /// let mut task = Task::new("Become world leader".to_string()); + /// assert!(task.finish().is_none()); + /// let finish_time = Utc::now(); + /// task.edit_finish(finish_time); + /// assert_eq!(task.finish().unwrap(), finish_time); + /// ``` + pub fn finish(&self) -> Option> { + self.finish + } + + /// Edits the duration of the task. If the task has a start time, finish time will be updated accordingly. + /// + /// # Arguments + /// + /// * `duration` - The new duration of the task. + /// + /// # Example + /// + /// ``` + /// use chrono::{Utc, Duration}; + /// use planter_core::{task::Task, duration::PositiveDuration}; + /// + /// let mut task = Task::new("Become world leader".to_string()); + /// task.edit_duration(Duration::minutes(30).try_into().unwrap()); + /// assert!(task.duration().is_some()); + /// assert_eq!(task.duration().unwrap(), Duration::minutes(30).try_into().unwrap()); + /// ``` + pub fn edit_duration(&mut self, duration: PositiveDuration) { + self.duration = Some(duration); + + if let Some(start) = self.start() { + let finish = start + *duration; + self.finish = Some(finish); + } + } + + /// Adds a [`Resource`] to the task. + /// + /// # Arguments + /// + /// * `resource` - The resource to add to the task. + /// + /// # Example + /// + /// ``` + /// use planter_core::{resources::{Resource, Material, NonConsumable}, task::Task}; + /// + /// let mut task = Task::new("Become world leader".to_string()); + /// let resource = Resource::Material(Material::NonConsumable( + /// NonConsumable::new("Crowbar".to_string()), + /// )); + /// task.add_resource(resource); + /// + /// assert_eq!(task.resources().len(), 1); + /// ``` + pub fn add_resource(&mut self, resource: Resource) { + self.resources.push(resource); + } + + /// Returns the list of [`Resource`] assigned to the task. + /// + /// # Example + /// + /// ``` + /// use planter_core::task::Task; + /// use planter_core::resources::{Resource, Material, NonConsumable}; + /// + /// let mut task = Task::new("Become world leader".to_string()); + /// assert!(task.resources().is_empty()); + /// let resource = Resource::Material(Material::NonConsumable( + /// NonConsumable::new("Crowbar".to_string()), + /// )); + /// task.add_resource(resource); + /// assert_eq!(task.resources().len(), 1); + /// ``` + pub fn resources(&self) -> &[Resource] { + &self.resources + } + + /// Returns the title of the task. + /// + /// # Example + /// + /// ``` + /// use planter_core::task::Task; + /// + /// let mut task = Task::new("Become world leader".to_string()); + /// assert_eq!(task.title(), "Become world leader"); + /// ``` + pub fn title(&self) -> &str { + &self.title + } + + /// Edits the description of the task. + /// + /// # Arguments + /// + /// * `description` - The new description of the task. + /// + /// # Example + /// + /// ``` + /// use planter_core::task::Task; + /// + /// let mut task = Task::new("Become world leader".to_string()); + /// task.edit_description("Description".to_string()); + /// assert_eq!(task.description(), "Description"); + /// ``` + pub fn edit_description(&mut self, description: String) { + self.description = description; + } + + /// Returns the description of the task. + /// + /// # Example + /// + /// ``` + /// use planter_core::task::Task; + /// + /// let mut task = Task::new("Become world leader".to_string()); + /// task.edit_description("Description".to_string()); + /// assert_eq!(task.description(), "Description"); + /// ``` + pub fn description(&self) -> &str { + &self.description + } + + /// Whether the task is completed. It's false by default. + /// + /// # Example + /// + /// ``` + /// use planter_core::task::Task; + /// + /// let mut task = Task::new("Become world leader".to_string()); + /// assert!(!task.completed()); + /// task.mark_completed(); + /// assert!(task.completed()); + /// ``` + pub fn completed(&self) -> bool { + self.completed + } + + /// Marks the task as completed. + /// + /// # Example + /// + /// ``` + /// use planter_core::task::Task; + /// + /// let mut task = Task::new("Become world leader".to_string()); + /// assert!(!task.completed()); + /// task.mark_completed(); + /// assert!(task.completed()); + /// ``` + pub fn mark_completed(&mut self) { + self.completed = true; + } + + /// Returns the duration of the task. It's None by default. + /// + /// # Example + /// + /// ``` + /// use chrono::{Utc, Duration}; + /// use planter_core::task::Task; + /// + /// let mut task = Task::new("Become world leader".to_string()); + /// assert!(task.duration().is_none()); + /// + /// task.edit_duration(Duration::hours(1).try_into().unwrap()); + /// assert!(task.duration().unwrap() == Duration::hours(1).try_into().unwrap()); + /// ``` + pub fn duration(&self) -> Option { + self.duration + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use chrono::Duration; + use proptest::prelude::*; + + use crate::duration::MAX_DURATION; + + use super::*; + + proptest! { + #[test] + fn duration_is_properly_set_when_adding_start_and_finish_time(milliseconds in 0..MAX_DURATION) { + let start = Utc::now(); + let finish = start + Duration::milliseconds(milliseconds); + let mut task = Task::new("World domination".to_string()); + + task.edit_start(start); + task.edit_finish(finish); + + assert!(task.duration().unwrap() == Duration::milliseconds(milliseconds).try_into().unwrap()); + } + + #[test] + fn task_times_stay_none_when_adding_duration(milliseconds in 0..MAX_DURATION) { + let mut task = Task::new("World domination".to_string()); + + let duration = Duration::milliseconds(milliseconds).try_into().unwrap(); + task.edit_duration(duration); + assert!(task.finish().is_none()); + assert!(task.start().is_none()); + } + + #[test] + fn finish_time_is_properly_set_when_adding_duration(milliseconds in 0..MAX_DURATION) { + let start = Utc::now(); + let mut task = Task::new("World domination".to_string()); + + task.edit_start(start); + let duration = Duration::milliseconds(milliseconds).try_into().unwrap(); + task.edit_duration(duration); + assert!(task.finish().unwrap() == start + *duration); + } + + #[test] + fn finish_time_is_properly_pushed_ahead_when_adding_duration(milliseconds in 0..MAX_DURATION) { + let start = Utc::now(); + let finish = start + Duration::milliseconds(milliseconds); + let mut task = Task::new("World domination".to_string()); + + task.edit_start(start); + task.edit_finish(finish); + + let duration = Duration::milliseconds(milliseconds + 1).try_into().unwrap(); + task.edit_duration(duration); + assert!(task.finish().unwrap() == start + *duration); + } + + #[test] + fn start_time_is_properly_pushed_back_when_adding_earlier_end_time(milliseconds in 0..MAX_DURATION) { + let start = Utc::now(); + let finish = start - Duration::milliseconds(milliseconds); + let mut task = Task::new("World domination".to_string()); + + task.edit_start(start); + task.edit_finish(finish); + + assert!(task.start().unwrap() == task.finish().unwrap()); + } + } +} diff --git a/tests/project.rs b/tests/project.rs new file mode 100644 index 0000000..b82494d --- /dev/null +++ b/tests/project.rs @@ -0,0 +1,58 @@ +//! Module containing tests for the `Project` struct. + +#![allow(clippy::unwrap_used)] + +use chrono::Utc; +use planter_core::{ + person::{Name, Person}, + project::Project, + resources::{Consumable, Material, NonConsumable, Resource}, + stakeholders::Stakeholder, + task::Task, +}; + +#[test] +/// The standard workflow when creating a project involves initializing it with a name, optionally a description, and a start date. +/// The project is kept mutable and the user can add/remove tasks, resources, stakeholders, and other relevant information. +fn test_project() { + // Initialize a project with a name, description, and start date. + let start_date = Utc::now(); + let mut project = Project::new("World domination".to_string()) + .with_description( + "My second attempt to conquer the world with a crowbar and a stimpack".to_string(), + ) + .with_start_date(start_date); + + // Add tasks to the project + project.add_task(Task::new("Become world leader".to_string())); + project.add_task(Task::new("Profit".to_string())); + assert_eq!(project.tasks().len(), 2); + + // Add a non consumable material to the project + project.add_resource(Resource::Material(Material::NonConsumable( + NonConsumable::new("Crowbar".to_string()), + ))); + // Add a consumable material to the project + project.add_resource(Resource::Material(Material::Consumable(Consumable::new( + "Stimpack".to_string(), + )))); + + // Add a personnel resource to the project + project.add_resource(Resource::Personnel { + person: Person::new(Name::parse("Sebastiano".to_string(), "Giordano".to_string()).unwrap()), + hourly_rate: None, + }); + assert_eq!(project.resources().len(), 3); + + // Add stakeholders to the project + let person = Person::new(Name::parse("Margherita".to_string(), "Hack".to_string()).unwrap()); + project.add_stakeholder(Stakeholder::Individual { + person, + description: Some("She could try to stop me".to_string()), + }); + project.add_stakeholder(Stakeholder::Organization { + name: "Acme".to_string(), + description: Some("They might decide to buy me more stimpacks".to_string()), + }); + assert_eq!(project.stakeholders().len(), 2); +}