diff --git a/.cargo/config.toml b/.cargo/config.toml index 583ce601..c1e18913 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,8 +1,8 @@ [build] rustdocflags = ["--document-private-items"] # rustflags = "-C target-cpu=native -D warnings" -rustflags = "-D warnings" -# incremental = true +# rustflags = ["-Dwarnings"] +incremental = false [alias] xtask = "run --package xtask --" diff --git a/.cliffignore b/.cliffignore new file mode 100644 index 00000000..32170366 --- /dev/null +++ b/.cliffignore @@ -0,0 +1,3 @@ +# skip commits by their SHA1 + +6082708b48b2b9015bb25308666d1fe33f863aa7 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ba95b0f8..c51bfb5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,9 +58,9 @@ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] name = "ahash" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42cd52102d3df161c77a887b608d7a4897d7cc112886a9537b738a887a03aaff" +checksum = "d713b3834d76b85304d4d525563c1276e2e30dc97cc67bfb4585a4a29fc2c89f" dependencies = [ "cfg-if", "once_cell", @@ -159,7 +159,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed72493ac66d5804837f480ab3766c72bdfab91a65e565fc54fa9e42db0073a8" dependencies = [ "anstyle", - "bstr", + "bstr 1.9.0", "doc-comment", "predicates", "predicates-core", @@ -167,12 +167,6 @@ dependencies = [ "wait-timeout", ] -[[package]] -name = "atomic" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" - [[package]] name = "autocfg" version = "1.1.0" @@ -206,6 +200,17 @@ version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +[[package]] +name = "bstr" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" +dependencies = [ + "lazy_static", + "memchr", + "regex-automata 0.1.10", +] + [[package]] name = "bstr" version = "1.9.0" @@ -219,9 +224,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.15.0" +version = "3.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d32a994c2b3ca201d9b263612a374263f05e7adde37c4707f693dcd375076d1f" +checksum = "a3b1be7772ee4501dba05acbe66bb1e8760f6a6c474a36035631638e4415f130" [[package]] name = "canonical-path" @@ -231,12 +236,9 @@ checksum = "e6e9e01327e6c86e92ec72b1c798d4a94810f147209bbe3ffab6a86954937a6f" [[package]] name = "cc" -version = "1.0.83" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" -dependencies = [ - "libc", -] +checksum = "7f9fa1897e4325be0d68d48df6aa1a71ac2ed4d27723887e7754192705350730" [[package]] name = "cfg-if" @@ -300,7 +302,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.49", + "syn 2.0.50", ] [[package]] @@ -347,6 +349,31 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" + [[package]] name = "dialoguer" version = "0.11.0" @@ -396,7 +423,7 @@ checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.49", + "syn 2.0.50", ] [[package]] @@ -553,7 +580,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.49", + "syn 2.0.50", ] [[package]] @@ -608,8 +635,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -648,9 +677,9 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.8.4" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +checksum = "692eaaf7f7607518dd3cef090f1474b61edc5301d8012f09579920df68b725ee" dependencies = [ "hashbrown", ] @@ -722,6 +751,21 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "insta" +version = "1.35.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c985c1bef99cf13c58fade470483d81a2bfe846ebde60ed28cc2dddec2df9e2" +dependencies = [ + "console", + "lazy_static", + "linked-hash-map", + "serde", + "similar", + "toml 0.5.11", + "yaml-rust", +] + [[package]] name = "itertools" version = "0.12.1" @@ -765,15 +809,21 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" dependencies = [ "cc", "pkg-config", "vcpkg", ] +[[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.4.13" @@ -924,12 +974,14 @@ dependencies = [ "directories", "eyre", "human-panic", + "insta", "once_cell", "pace_cli", "pace_core", "predicates", "serde", "serde_derive", + "similar-asserts", "tempfile", "thiserror", "toml 0.8.10", @@ -959,16 +1011,18 @@ dependencies = [ "itertools", "log", "merge", + "rayon", "rstest", "rusqlite", "serde", "serde_derive", + "similar-asserts", "strum", "strum_macros", "thiserror", "toml 0.8.10", "typed-builder", - "uuid", + "ulid", ] [[package]] @@ -1101,6 +1155,26 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rayon" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7237101a77a10773db45d62004a272517633fbcc3df19d96455ede1122e051" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -1196,15 +1270,15 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.49", + "syn 2.0.50", "unicode-ident", ] [[package]] name = "rusqlite" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a78046161564f5e7cd9008aff3b2990b3850dc8e0349119b98e8f251e099f24d" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ "bitflags 2.4.2", "chrono", @@ -1286,7 +1360,7 @@ checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.49", + "syn 2.0.50", ] [[package]] @@ -1323,6 +1397,27 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +[[package]] +name = "similar" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32fea41aca09ee824cc9724996433064c89f7777e60762749a4170a14abbfa21" +dependencies = [ + "bstr 0.2.17", + "unicode-segmentation", +] + +[[package]] +name = "similar-asserts" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e041bb827d1bfca18f213411d51b665309f1afb37a04a5d1464530e13779fc0f" +dependencies = [ + "console", + "serde", + "similar", +] + [[package]] name = "slab" version = "0.4.9" @@ -1360,7 +1455,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.49", + "syn 2.0.50", ] [[package]] @@ -1376,9 +1471,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.49" +version = "2.0.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915aea9e586f80826ee59f8453c1101f9d1c4b3964cd2460185ee8e299ada496" +checksum = "74f1bdc9872430ce9b75da68329d1c1746faf50ffac5f19e02b71e37ff881ffb" dependencies = [ "proc-macro2", "quote", @@ -1451,14 +1546,14 @@ checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" dependencies = [ "proc-macro2", "quote", - "syn 2.0.49", + "syn 2.0.50", ] [[package]] name = "thread_local" -version = "1.1.7" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" dependencies = [ "cfg-if", "once_cell", @@ -1528,7 +1623,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.49", + "syn 2.0.50", ] [[package]] @@ -1598,7 +1693,19 @@ checksum = "563b3b88238ec95680aef36bdece66896eaa7ce3c0f1b4f39d38fb2435261352" dependencies = [ "proc-macro2", "quote", - "syn 2.0.49", + "syn 2.0.50", +] + +[[package]] +name = "ulid" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34778c17965aa2a08913b57e1f34db9b4a63f5de31768b55bf20d2795f921259" +dependencies = [ + "getrandom", + "rand", + "serde", + "web-time", ] [[package]] @@ -1607,6 +1714,12 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + [[package]] name = "unicode-width" version = "0.1.11" @@ -1631,10 +1744,7 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f00cc9702ca12d3c81455259621e676d0f7251cec66a21e98fe2e9a37db93b2a" dependencies = [ - "atomic", "getrandom", - "rand", - "serde", ] [[package]] @@ -1691,7 +1801,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.49", + "syn 2.0.50", "wasm-bindgen-shared", ] @@ -1713,7 +1823,7 @@ checksum = "642f325be6301eb8107a83d12a8ac6c1e1c54345a7ef1a9261962dfefda09e66" dependencies = [ "proc-macro2", "quote", - "syn 2.0.49", + "syn 2.0.50", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1724,6 +1834,16 @@ version = "0.2.91" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f186bd2dcf04330886ce82d6f33dd75a7bfcf69ecf5763b89fcde53b6ac9838" +[[package]] +name = "web-time" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee269d72cc29bf77a2c4bc689cc750fb39f5cbd493d2205bbb3f5c7779cf7b0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1898,16 +2018,16 @@ checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" [[package]] name = "winnow" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d90f4e0f530c4c69f62b80d839e9ef3855edc9cba471a160c4d692deed62b401" +checksum = "7a4191c47f15cc3ec71fcb4913cb83d58def65dd3787610213c649283b5ce178" dependencies = [ "memchr", ] [[package]] name = "xtask" -version = "0.1.0" +version = "0.0.0" dependencies = [ "clap", "dialoguer", @@ -1917,6 +2037,15 @@ dependencies = [ "glob", ] +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "zerocopy" version = "0.7.32" @@ -1934,7 +2063,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.49", + "syn 2.0.50", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a7925656..034801cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ eyre = "0.6.12" pace_cli = { path = "crates/cli", version = "0" } pace_core = { path = "crates/core", version = "0" } pace_server = { path = "crates/server", version = "0" } +similar-asserts = { version = "1.5.0", features = ["serde"] } [package] name = "pace-rs" @@ -50,10 +51,15 @@ dialoguer = { version = "0.11.0", features = ["history", "fuzzy-select"] } directories = "5.0.1" eyre = { workspace = true } human-panic = "1.2.3" +insta = { version = "1.35.1", features = ["toml"] } pace_cli = { workspace = true } pace_core = { workspace = true } serde = "1" serde_derive = "1" + +# Better error messages for Serde +# serde_path_to_error = "0.1.15" + thiserror = "1.0.57" toml = { version = "0.8.10", features = ["preserve_order"] } @@ -68,6 +74,7 @@ abscissa_core = { version = "0.7.0", features = ["testing"] } assert_cmd = "2.0.14" once_cell = "1.19" predicates = "3.1.0" +similar-asserts = { workspace = true } tempfile = "3.10.0" # The profile that 'cargo dist' will build with @@ -144,15 +151,15 @@ assets = [ { source = "target/release/pace", dest = "/usr/bin/pace", mode = "0755", config = false, doc = false }, # user = "root", group = "root" }, ] -[lints.rust] +[workspace.lints.rust] unsafe_code = "forbid" -missing_docs = "warn" +# missing_docs = "warn" rust_2018_idioms = "warn" trivial_casts = "warn" unused_lifetimes = "warn" unused_qualifications = "warn" bad_style = "warn" -dead_code = "warn" +dead_code = "allow" # TODO: "warn" improper_ctypes = "warn" # missing_copy_implementations = "warn" # missing_debug_implementations = "warn" @@ -172,7 +179,7 @@ unused_comparisons = "warn" unused_parens = "warn" while_true = "warn" -[lints.clippy] +[workspace.lints.clippy] # pedantic = "warn" # nursery = "warn" enum_glob_use = "warn" diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 00000000..58fd7f11 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,19 @@ +# Testing strategy + +## Unit tests + +- TODO + +## Snapshot tests + +- Use toml snapshots for checking if the formatting is right and not the weird + double table thing + +## Property-based tests + +- Implement Arbitrary for all serializable, storage-related types + +## Integration tests + +- CLI +- API diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..e71507fd --- /dev/null +++ b/cliff.toml @@ -0,0 +1,86 @@ +# git-cliff ~ configuration file +# https://git-cliff.org/docs/configuration + +# [remote.github] +# owner = "orhun" +# repo = "git-cliff" +# token = "" + +[changelog] +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +## What's Changed + +{%- if version %} in {{ version }}{%- endif -%} +{% for commit in commits %} + {% if commit.github.pr_title -%} + {%- set commit_message = commit.github.pr_title -%} + {%- else -%} + {%- set commit_message = commit.message -%} + {%- endif -%} + * {{ commit_message | split(pat="\n") | first | trim }}\ + {% if commit.github.username %} by @{{ commit.github.username }}{%- endif -%} + {% if commit.github.pr_number %} in \ + [#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}) \ + {%- endif %} +{%- endfor -%} + +{% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} + {% raw %}\n{% endraw -%} + ## New Contributors +{%- endif %}\ +{% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} + * @{{ contributor.username }} made their first contribution + {%- if contributor.pr_number %} in \ + [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ + {%- endif %} +{%- endfor -%} + +{% if version %} + {% if previous.version %} + **Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }} + {% endif %} +{% else -%} + {% raw %}\n{% endraw %} +{% endif %} + +{%- macro remote_url() -%} + https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} +{%- endmacro -%} +""" +# remove the leading and trailing whitespace from the template +trim = true +# changelog footer +footer = """ + +""" +# postprocessors +postprocessors = [] + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = false +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # remove issue numbers from commits + { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }, +] +# protect breaking changes from being skipped due to matching a skipping commit_parser +protect_breaking_commits = false +# filter out the commits that are not matched by commit parsers +filter_commits = false +# regex for matching git tags +tag_pattern = "v[0-9].*" +# regex for skipping tags +skip_tags = "beta|alpha" +# regex for ignoring tags +ignore_tags = "rc" +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "newest" diff --git a/config/pace.toml b/config/pace.toml index 25f59a79..d053f0fb 100644 --- a/config/pace.toml +++ b/config/pace.toml @@ -1,16 +1,16 @@ [general] # Define where to store the activity log: options include "file", "database", etc. -log_storage = "file" +activity_log_storage = "file" # Path to the activity log file, used if log_storage is set to "file" -activity_log_file_path = "C:\\Users\\dailyuse\\backup\\PARA\\Personal\\2 Areas\\Developer\\Maintenance\\pace\\pace-rs\\pace\\data\\activity_2024-02.toml" +activity_log_path = "C:\\Users\\dailyuse\\backup\\PARA\\Personal\\2 Areas\\Developer\\Maintenance\\pace\\pace-rs\\pace\\data\\activity_2024-02.toml" # Specify the default format for new activity logs: "toml" or "yaml" -log_format = "toml" +activity_log_format = "toml" # Autogenerate identifiers for tasks, projects and activities that have been manually created autogenerate_ids = true # Category separator used in the cli category_separator = "::" # Default priority for new tasks -default_priority = "Medium" +default_priority = "medium" [reviews] # Format of the review generated by the pace: "pdf", "html", "markdown", etc. @@ -25,7 +25,7 @@ export_time_format = "%Y-%m-%d %H:%M" [database] # Database configurations are used if log_storage is set to "database" -type = "sqlite" # only option supported for now is "sqlite" +engine = "sqlite" # only option supported for now is "sqlite" connection_string = "path/to/your/database.db" [pomodoro] @@ -39,7 +39,7 @@ sessions_before_long_break = 4 # Maximum number of items the inbox can hold max_size = 100 # Default priority for new tasks added to the inbox -default_priority = "Medium" +default_priority = "medium" # Specifies whether new tasks should be auto-archived after a certain period auto_archive_after_days = 30 diff --git a/config/projects.pace.toml b/config/projects.pace.toml index 5654e42d..3226c848 100644 --- a/config/projects.pace.toml +++ b/config/projects.pace.toml @@ -7,7 +7,7 @@ # TODO: Add a `pace projects init` command to generate a new project configuration file. [project] -id = "018d84a0-7847-7b2b-9cdb-6ba213f99a1d" +id = "01HPY7F03JBVKSWDNTM2RSBXSJ" name = "Pace Project" description = "An example project managed with Pace." root_tasks_file = "tasks.toml" # Path to the root tasks file @@ -15,30 +15,30 @@ filters = ["*pace*"] # Optional: Define default filters for your project [defaults] # Optional: Define a default category for your project -category = { id = "018d85c0-a46f-7e95-8b95-f0bc961d4ee9", name = "Uncategorized", description = "Uncategorized Content" } +category = { id = "01HPY7F03K4AZMA0DVW3A1M0TG", name = "Uncategorized", description = "Uncategorized Content" } [[categories]] # Optional: Define categories for your project -id = "018d85c0-7823-7038-8e96-bc9c9330174a" +id = "01HPY7F03K3JCWK5ZJJ02TT12G" name = "Development" description = "Development related tasks" # Optional: Define subcategories for your category # TODO: Add support for subcategories subcategories = [ - { id = "018d85d5-e29d-7995-afc9-34175e0ce7b6", name = "Frontend", description = "Frontend Development" }, - { id = "018d85d5-fa35-7bc3-a347-8284843c4188", name = "Backend", description = "Backend Development" }, - { id = "018d85d6-110b-771b-828d-8cd68934fb61", name = "Fullstack", description = "Fullstack Development" }, + { id = "01HPY7F03K1H1A8A7S0K1ZCFX3", name = "Frontend", description = "Frontend Development" }, + { id = "01HPY7F03KSF8TXQQWZDF63DFD", name = "Backend", description = "Backend Development" }, + { id = "01HPY7F03KK3FGAJTHP2MBZA37", name = "Fullstack", description = "Fullstack Development" }, ] [[categories]] # Optional: Define categories for your project -id = "018d85c0-629b-737b-8f53-c307504d1c0a" +id = "01HPY7F03KS1YHKT86BXSMMEMX" name = "Design" description = "Design related tasks" [[subprojects]] # Optional: Define subprojects or directories with their own tasks -id = "018d84a0-a2d1-7450-98ef-8b47e0ff42b5" +id = "01HPY7F03K6TT2KKFEYVJT79ZB" name = "Pace Subproject A" description = "" tasks_file = "subproject-a/tasks.toml" @@ -49,7 +49,7 @@ filters = [ [[subprojects]] # Optional: Define subprojects or directories with their own tasks -id = "018d84a0-cc74-71b4-8dd4-d7d26d1f5924" +id = "01HPY7F03KF7VE3K9E51P0H1TB" name = "Pace Subproject B" description = "" tasks_file = "subproject-b/tasks.toml" diff --git a/config/tasks.pace.toml b/config/tasks.pace.toml index 2ac288e8..23779ec6 100644 --- a/config/tasks.pace.toml +++ b/config/tasks.pace.toml @@ -6,20 +6,20 @@ # TODO: Add a `pace tasks init` command to generate a new project configuration file. [[tasks]] -id = "018d84a1-4540-7992-89f2-d87d9ee73524" +id = "01HPY7H596FT2R880SEKH7KN25" title = "Implement feature X" created_at = "2024-02-04T12:34:56" finished_at = "2024-02-05T13:34:56" description = "Detailed description of feature X to be implemented." -priority = "High" -status = "Pending" +priority = "high" +status = "pending" tags = ["feature", "X"] [[tasks]] -id = "018d84a1-6cc8-7791-a92c-a077ef09de7f" +id = "01HPY7F03JQ6SJF5C97H7G7E0E" title = "Fix bug Y" created_at = "2024-02-06T12:34:56" description = "Detailed description of bug Y to be fixed." -priority = "Medium" -status = "InProgress" +priority = "medium" +status = "wip" tags = ["bug", "Y"] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 88129b4e..e50e8d86 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -27,47 +27,5 @@ pace_core = { workspace = true } tracing = { version = "0.1.40", features = ["log"] } typed-builder = "0.18.1" -[lints.rust] -unsafe_code = "forbid" -missing_docs = "warn" -rust_2018_idioms = "warn" -trivial_casts = "warn" -unused_lifetimes = "warn" -unused_qualifications = "warn" -bad_style = "warn" -dead_code = "warn" -improper_ctypes = "warn" -# missing_copy_implementations = "warn" -# missing_debug_implementations = "warn" -non_shorthand_field_patterns = "warn" -no_mangle_generic_items = "warn" -overflowing_literals = "warn" -path_statements = "warn" -patterns_in_fns_without_body = "warn" -trivial_numeric_casts = "warn" -unused_results = "warn" -unused_extern_crates = "warn" -unused_import_braces = "warn" -unconditional_recursion = "warn" -unused = "warn" -unused_allocation = "warn" -unused_comparisons = "warn" -unused_parens = "warn" -while_true = "warn" - -[lints.clippy] -# pedantic = "warn" -# nursery = "warn" -enum_glob_use = "warn" -correctness = "warn" -suspicious = "warn" -complexity = "warn" -perf = "warn" -cast_lossless = "warn" -default_trait_access = "warn" -doc_markdown = "warn" -manual_string_new = "warn" -match_same_arms = "warn" -semicolon_if_nothing_returned = "warn" -trivially_copy_pass_by_ref = "warn" -module_name_repetitions = "allow" +[lints] +workspace = true diff --git a/crates/cli/src/setup.rs b/crates/cli/src/setup.rs index 9830743c..81185c1f 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/setup.rs @@ -306,7 +306,7 @@ pub(crate) fn confirmation_or_break(prompt: &str) -> Result<()> { /// /// Returns `Ok(())` if the setup assistant succeeds pub fn craft_setup(term: &Term) -> Result<()> { - let default_config_content = PaceConfig::default(); + let mut config = PaceConfig::default(); let config_paths = get_config_paths("pace.toml") .into_iter() @@ -328,7 +328,7 @@ pub fn craft_setup(term: &Term) -> Result<()> { let final_paths = prompt_activity_log_path(&activity_log_paths)?; - let config = default_config_content.with_activity_log(final_paths.activity_log_path()); + config.add_activity_log_path(final_paths.activity_log_path()); let final_paths = prompt_config_file_path(final_paths, config_paths.as_slice())?; diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index d3c3cccf..9fdedb00 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -18,6 +18,12 @@ include = [ "Cargo.toml", ] +# TODO!: Use features for adding optional dependencies for testing and merging etc. +[features] +default = [] +sqlite = ["dep:rusqlite"] +# testing = ["dep:arbitrary"] + [dependencies] chrono = { version = "0.4.34", features = ["serde"] } directories = "5.0.1" @@ -26,7 +32,8 @@ getset = "0.1.2" itertools = "0.12.1" log = "0.4.20" merge = "0.1.0" -rusqlite = { version = "0.30.0", features = ["bundled", "chrono", "uuid"] } +rayon = "1.8.1" +rusqlite = { version = "0.31.0", features = ["bundled", "chrono", "uuid"], optional = true } serde = "1.0.197" serde_derive = "1.0.197" strum = "0.26.1" @@ -34,52 +41,11 @@ strum_macros = "0.26.1" thiserror = "1.0.57" toml = { version = "0.8.10", features = ["indexmap", "preserve_order"] } typed-builder = "0.18.1" -uuid = { version = "1.7.0", features = ["serde", "fast-rng", "v7"] } +ulid = { version = "1.1.2", features = ["serde"] } [dev-dependencies] rstest = "0.18.2" +similar-asserts = { workspace = true } -[lints.rust] -unsafe_code = "forbid" -missing_docs = "allow" # TODO!: For now, we allow missing docs, but we should fix this -rust_2018_idioms = "warn" -trivial_casts = "warn" -unused_lifetimes = "warn" -unused_qualifications = "warn" -bad_style = "warn" -dead_code = "allow" # TODO!: For now, we allow dead code, but we should fix this -improper_ctypes = "warn" -# missing_copy_implementations = "warn" -# missing_debug_implementations = "warn" -non_shorthand_field_patterns = "warn" -no_mangle_generic_items = "warn" -overflowing_literals = "warn" -path_statements = "warn" -patterns_in_fns_without_body = "warn" -trivial_numeric_casts = "warn" -unused_results = "warn" -unused_extern_crates = "warn" -unused_import_braces = "warn" -unconditional_recursion = "warn" -unused = "warn" -unused_allocation = "warn" -unused_comparisons = "warn" -unused_parens = "warn" -while_true = "warn" - -[lints.clippy] -# pedantic = "warn" -# nursery = "warn" -enum_glob_use = "warn" -correctness = "warn" -suspicious = "warn" -complexity = "warn" -perf = "warn" -cast_lossless = "warn" -default_trait_access = "warn" -doc_markdown = "warn" -manual_string_new = "warn" -match_same_arms = "warn" -semicolon_if_nothing_returned = "warn" -trivially_copy_pass_by_ref = "warn" -module_name_repetitions = "allow" +[lints] +workspace = true diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index fcae4c1b..41dfbf79 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -8,7 +8,10 @@ use serde_derive::{Deserialize, Serialize}; use directories::ProjectDirs; -use crate::error::{PaceErrorKind, PaceResult}; +use crate::{ + domain::priority::ItemPriorityKind, + error::{PaceErrorKind, PaceResult}, +}; /// The pace configuration file /// @@ -22,142 +25,253 @@ pub struct PaceConfig { general: GeneralConfig, /// Review configuration for the pace application - reviews: ReviewConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[getset(get = "pub", get_mut = "pub")] + reviews: Option, /// Export configuration for the pace application - export: ExportConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[getset(get = "pub", get_mut = "pub")] + export: Option, /// Database configuration for the pace application + #[serde(default, skip_serializing_if = "Option::is_none")] + #[getset(get = "pub", get_mut = "pub")] database: Option, // Optional because it's only needed if log_storage is "database" /// Pomodoro configuration for the pace application - pomodoro: PomodoroConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[getset(get = "pub", get_mut = "pub")] + pomodoro: Option, /// Inbox configuration for the pace application - inbox: InboxConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[getset(get = "pub", get_mut = "pub")] + inbox: Option, /// Auto-archival configuration for the pace application - auto_archival: AutoArchivalConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[getset(get = "pub", get_mut = "pub")] + auto_archival: Option, } + impl PaceConfig { /// Create a new [`PaceConfig`] with the given path to an activity log file /// /// # Arguments /// /// `activity_log` - The path to the activity log file - #[must_use] - pub fn with_activity_log(self, activity_log: impl AsRef) -> Self { - let mut new_config = self; - new_config.general.activity_log_file_path = - activity_log.as_ref().to_string_lossy().to_string(); - new_config + pub fn add_activity_log_path(&mut self, activity_log: impl AsRef) { + *self + .general_mut() + .activity_log_options_mut() + .activity_log_path_mut() = activity_log.as_ref().to_path_buf(); } } /// The general configuration for the pace application -#[derive(Debug, Deserialize, Default, Serialize, Getters, MutGetters, Clone)] +#[derive(Debug, Deserialize, Serialize, Getters, MutGetters, Clone)] #[getset(get = "pub")] pub struct GeneralConfig { - /// The storage type for the activity log - log_storage: String, + #[serde(flatten)] + #[getset(get = "pub", get_mut = "pub")] + activity_log_options: ActivityLogOptions, + /// If IDs should be autogenerated for activities, otherwise it's a hard error + /// Default: `true` + autogenerate_ids: bool, + + /// The default category separator + /// Default: `::` + category_separator: String, + + /// The default priority + /// Default: `medium` + default_priority: ItemPriorityKind, +} + +#[derive(Debug, Deserialize, Serialize, Getters, MutGetters, Clone, Default)] +#[getset(get = "pub")] +pub struct ActivityLogOptions { /// The path to the activity log file + /// Default is operating system dependent + /// Use `pace craft setup` to set this value initially #[getset(get = "pub", get_mut = "pub")] - activity_log_file_path: String, + activity_log_path: PathBuf, /// The format for the activity log - log_format: String, + /// Default: `toml` + activity_log_format: Option, - /// If IDs should be autogenerated for activities - autogenerate_ids: bool, + /// The storage type for the activity log + /// Default: `file` + activity_log_storage: ActivityLogStorageKind, +} - /// The default category separator - category_separator: String, +/// The kind of activity log format +/// Default: `toml` +/// +/// Options: `toml`, `json`, `yaml` +#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum ActivityLogFormatKind { + #[default] + Toml, +} - /// The default category - default_priority: String, +/// The kind of log storage +/// Default: `file` +/// +/// Options: `file`, `database` +#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default)] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] +pub enum ActivityLogStorageKind { + #[default] + File, + Database, + #[cfg(test)] + InMemory, +} + +impl Default for GeneralConfig { + fn default() -> Self { + Self { + activity_log_options: ActivityLogOptions::default(), + autogenerate_ids: true, + category_separator: "::".to_string(), + default_priority: ItemPriorityKind::default(), + } + } +} + +/// The kind of review format +/// Default: `html` +/// +/// Options: `html`, `markdown`, `plain-text` +#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default)] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] +pub enum ReviewFormatKind { + #[default] + Html, + Csv, + #[serde(rename = "md")] + Markdown, + #[serde(rename = "txt")] + PlainText, } /// The review configuration for the pace application #[derive(Debug, Deserialize, Default, Serialize, Getters, Clone)] #[getset(get = "pub")] pub struct ReviewConfig { - /// The format for the review - review_format: String, - /// The directory to store the review files - review_directory: String, + review_directory: PathBuf, + + /// The format for the review + review_format: ReviewFormatKind, } /// The export configuration for the pace application #[derive(Debug, Deserialize, Default, Serialize, Getters, Clone)] #[getset(get = "pub")] pub struct ExportConfig { - /// If the export should include tags - export_include_tags: bool, - /// If the export should include descriptions export_include_descriptions: bool, + /// If the export should include tags + export_include_tags: bool, + /// The time format within the export export_time_format: String, } +/// The kind of database engine +/// Default: `sqlite` +/// +/// Options: `sqlite`, `postgres`, `mysql`, `sql-server` +#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default)] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] +pub enum DatabaseEngineKind { + #[default] + Sqlite, + Postgres, + Mysql, + SqlServer, +} + /// The database configuration for the pace application #[derive(Debug, Deserialize, Default, Serialize, Getters, Clone)] #[getset(get = "pub")] pub struct DatabaseConfig { - /// The type of database - #[serde(rename = "type")] - db_type: String, // `type` is a reserved keyword in Rust - /// The connection string for the database connection_string: String, + + /// The kind of database engine + engine: DatabaseEngineKind, } /// The pomodoro configuration for the pace application -#[derive(Debug, Deserialize, Default, Serialize, Getters, Clone, Copy)] +#[derive(Debug, Deserialize, Serialize, Getters, Clone, Copy)] #[getset(get = "pub")] pub struct PomodoroConfig { - /// The duration of a work session in minutes - work_duration_minutes: u32, - /// The duration of a short break in minutes + /// Default: `5` break_duration_minutes: u32, /// The duration of a long break in minutes + /// Default: `15` long_break_duration_minutes: u32, /// The number of work sessions before a long break + /// Default: `4` sessions_before_long_break: u32, + + /// The duration of a work session in minutes + /// Default: `25` + work_duration_minutes: u32, +} + +impl Default for PomodoroConfig { + fn default() -> Self { + Self { + break_duration_minutes: 5, + long_break_duration_minutes: 15, + sessions_before_long_break: 4, + work_duration_minutes: 25, + } + } } /// The inbox configuration for the pace application #[derive(Debug, Deserialize, Default, Serialize, Getters, Clone)] #[getset(get = "pub")] pub struct InboxConfig { - /// The maximum items the inbox should hold - max_size: u32, + /// The default time to auto-archive items in the inbox (in days) + auto_archive_after_days: u32, /// The default category for items in the inbox default_priority: String, - /// The default time to auto-archive items in the inbox (in days) - auto_archive_after_days: u32, + /// The maximum items the inbox should hold + max_size: u32, } /// The auto-archival configuration for the pace application #[derive(Debug, Deserialize, Default, Serialize, Getters, Clone)] #[getset(get = "pub")] pub struct AutoArchivalConfig { - /// If auto-archival is enabled - enabled: bool, - /// The default auto-archival time after which items should be archived (in days) archive_after_days: u32, /// The path to the archive file archive_path: String, + + /// If auto-archival is enabled + enabled: bool, } /// Get the current directory and then search upwards in the directory hierarchy for a file name diff --git a/crates/core/src/domain.rs b/crates/core/src/domain.rs index 2e3a6f03..7dfb5c79 100644 --- a/crates/core/src/domain.rs +++ b/crates/core/src/domain.rs @@ -30,12 +30,6 @@ struct Session { end_time: Option, // Unix timestamp } -struct TimeEntry { - task_id: usize, - start_time: u64, // Unix timestamp - end_time: Option, // Unix timestamp -} - struct Context { id: usize, name: String, diff --git a/crates/core/src/domain/activity.rs b/crates/core/src/domain/activity.rs index db08b2ba..2bc9e2dd 100644 --- a/crates/core/src/domain/activity.rs +++ b/crates/core/src/domain/activity.rs @@ -5,21 +5,20 @@ use core::fmt::Formatter; use getset::{Getters, MutGetters, Setters}; use merge::Merge; use serde_derive::{Deserialize, Serialize}; -use std::{fmt::Display, time::Duration}; +use std::fmt::Display; use typed_builder::TypedBuilder; -use uuid::Uuid; +use ulid::Ulid; use crate::{ - domain::{ - intermission::IntermissionPeriod, - time::{duration_to_str, BeginDateTime, PaceDuration}, - }, - error::PaceResult, + calculate_duration, + domain::time::{duration_to_str, BeginDateTime, PaceDuration}, + PaceResult, }; /// The kind of activity a user can track #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash, Copy)] -#[serde(rename_all = "snake_case")] +#[serde(rename_all = "kebab-case")] +// #[serde(untagged)] pub enum ActivityKind { /// A generic activity #[default] @@ -38,6 +37,48 @@ pub enum ActivityKind { PomodoroIntermission, } +impl ActivityKind { + /// Returns `true` if the activity kind is [`Activity`]. + /// + /// [`Activity`]: ActivityKind::Activity + #[must_use] + pub fn is_activity(&self) -> bool { + matches!(self, Self::Activity) + } + + /// Returns `true` if the activity kind is [`Task`]. + /// + /// [`Task`]: ActivityKind::Task + #[must_use] + pub fn is_task(&self) -> bool { + matches!(self, Self::Task) + } + + /// Returns `true` if the activity kind is [`Intermission`]. + /// + /// [`Intermission`]: ActivityKind::Intermission + #[must_use] + pub fn is_intermission(&self) -> bool { + matches!(self, Self::Intermission) + } + + /// Returns `true` if the activity kind is [`PomodoroWork`]. + /// + /// [`PomodoroWork`]: ActivityKind::PomodoroWork + #[must_use] + pub fn is_pomodoro_work(&self) -> bool { + matches!(self, Self::PomodoroWork) + } + + /// Returns `true` if the activity kind is [`PomodoroIntermission`]. + /// + /// [`PomodoroIntermission`]: ActivityKind::PomodoroIntermission + #[must_use] + pub fn is_pomodoro_intermission(&self) -> bool { + matches!(self, Self::PomodoroIntermission) + } +} + /// The cycle of pomodoro activity a user can track // TODO!: Optional: Track Pomodoro work/break cycles #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] @@ -60,44 +101,48 @@ enum PomodoroCycle { #[derive(Merge)] pub struct Activity { /// The activity's unique identifier - #[builder(default = Some(ActivityId::default()), setter(strip_option))] + #[builder(default = Some(ActivityGuid::default()), setter(strip_option))] #[getset(get_copy, get_mut = "pub")] - id: Option, + #[serde(rename = "id", skip_serializing_if = "Option::is_none")] + guid: Option, /// The category of the activity // TODO: We had it as a struct before with an ID, but it's questionable if we should go for this // TODO: Reconsider when we implement the project management part // category: Category, #[builder(default)] + #[getset(get = "pub", get_mut = "pub")] + #[serde(skip_serializing_if = "Option::is_none")] category: Option, /// The description of the activity // This needs to be an Optional, because we use the whole activity struct // as well for intermissions, which don't have a description #[builder(default, setter(strip_option))] + #[serde(skip_serializing_if = "Option::is_none")] description: Option, - /// The end date and time of the activity - #[builder(default, setter(strip_option))] - #[getset(get = "pub", get_mut = "pub")] - end: Option, - /// The start date and time of the activity #[getset(get = "pub")] #[builder(default)] #[merge(skip)] begin: BeginDateTime, - /// The duration of the activity - #[builder(default, setter(strip_option))] + #[serde(flatten, skip_serializing_if = "Option::is_none")] + #[builder(default)] #[getset(get = "pub", get_mut = "pub")] - duration: Option, + activity_end_options: Option, /// The kind of activity #[builder(default)] #[merge(skip)] kind: ActivityKind, + /// Optional attributes for the activity kind + #[builder(default, setter(strip_option))] + #[serde(flatten, skip_serializing_if = "Option::is_none")] + activity_kind_options: Option, + // TODO: How to better support subcategories // subcategory: Option, @@ -110,43 +155,79 @@ pub struct Activity { // Pomodoro-specific attributes /// The pomodoro cycle of the activity #[builder(default, setter(strip_option))] - pomodoro_cycle: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pomodoro_cycle_options: Option, +} - // Intermission-specific attributes - /// The intermission periods of the activity - #[builder(default, setter(strip_option))] - intermission_periods: Option>, +#[derive( + Debug, Serialize, Deserialize, TypedBuilder, Getters, Setters, MutGetters, Clone, Eq, PartialEq, +)] +#[getset(get = "pub")] +pub struct ActivityEndOptions { + /// The end date and time of the activity + #[builder(default)] + #[getset(get = "pub")] + end: NaiveDateTime, + + /// The duration of the activity + #[builder(default)] + #[getset(get = "pub")] + duration: PaceDuration, +} + +impl ActivityEndOptions { + pub fn new(end: NaiveDateTime, duration: PaceDuration) -> Self { + Self { end, duration } + } +} + +#[derive( + Debug, Serialize, Deserialize, TypedBuilder, Getters, Setters, MutGetters, Clone, Eq, PartialEq, +)] +#[getset(get = "pub")] +#[derive(Merge)] +#[serde(rename_all = "kebab-case")] +pub struct ActivityKindOptions { + #[serde(skip_serializing_if = "Option::is_none")] + parent_id: Option, +} + +impl ActivityKindOptions { + pub fn new(parent_id: impl Into>) -> Self { + Self { + parent_id: parent_id.into(), + } + } } impl Default for Activity { fn default() -> Self { Self { - id: Some(ActivityId::default()), + guid: Some(ActivityGuid::default()), category: Some("Uncategorized".to_string()), description: Some("This is an example activity".to_string()), - end: None, begin: BeginDateTime::default(), - duration: None, kind: ActivityKind::Activity, - pomodoro_cycle: None, - intermission_periods: None, + pomodoro_cycle_options: None, + activity_kind_options: None, + activity_end_options: None, } } } /// The unique identifier of an activity #[derive(Debug, Clone, Serialize, Deserialize, Ord, PartialEq, PartialOrd, Eq, Copy, Hash)] -pub struct ActivityId(Uuid); +pub struct ActivityGuid(Ulid); -impl Display for ActivityId { +impl Display for ActivityGuid { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } -impl Default for ActivityId { +impl Default for ActivityGuid { fn default() -> Self { - Self(Uuid::now_v7()) + Self(Ulid::new()) } } @@ -168,68 +249,151 @@ impl Display for Activity { } } -impl rusqlite::types::FromSql for ActivityId { +#[cfg(feature = "sqlite")] +impl rusqlite::types::FromSql for ActivityGuid { fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult { let bytes = <[u8; 16]>::column_result(value)?; - Ok(Self(uuid::Uuid::from_u128(u128::from_be_bytes(bytes)))) + Ok(Self(Ulid::from(u128::from_be_bytes(bytes)))) } } -impl rusqlite::types::ToSql for ActivityId { +#[cfg(feature = "sqlite")] +impl rusqlite::types::ToSql for ActivityGuid { fn to_sql(&self) -> rusqlite::Result> { - self.0.as_ref().to_sql() + Ok(rusqlite::types::ToSqlOutput::from(self.0.to_string())) } } impl Activity { /// If the activity is active, so if it is currently being tracked #[must_use] - pub const fn is_active(&self) -> bool { - self.end.is_none() + pub fn is_active(&self) -> bool { + self.activity_end_options().is_none() } /// If the activity has ended #[must_use] - pub const fn has_ended(&self) -> bool { - self.end.is_some() + pub fn has_ended(&self) -> bool { + self.activity_end_options().is_some() } - /// Calculate the duration of the activity + /// End the activity /// /// # Arguments /// /// * `end` - The end date and time of the activity - /// - /// # Errors - /// - /// Returns an error if the duration can't be calculated or is negative - /// - /// # Returns - /// - /// Returns the duration of the activity - pub fn calculate_duration(&self, end: NaiveDateTime) -> PaceResult { - let duration = end - .signed_duration_since(self.begin.naive_date_time()) - .to_std()?; - - Ok(duration) - } - - // pub fn start_intermission(&mut self, date: NaiveDate, time: NaiveTime) { - // let new_intermission = IntermissionPeriod::new(date, time); - // if let Some(ref mut periods) = self.intermission_periods { - // periods.push(new_intermission); - // } else { - // self.intermission_periods = Some(vec![new_intermission]); - // } - // } - - // pub fn end_intermission(&mut self, date: NaiveDate, time: NaiveTime) { - // if let Some(intermission_periods) = &mut self.intermission_periods { - // if let Some(last_period) = intermission_periods.last_mut() { - // // Assuming intermissions can't overlap, the last one is the one to end - // last_period.end(date, time); - // } - // } - // } + /// * `duration` - The [`PaceDuration`] of the activity + pub fn end_activity(&mut self, end_opts: ActivityEndOptions) { + self.activity_end_options = Some(end_opts); + } + + pub fn end_activity_with_duration_calc( + &mut self, + begin: BeginDateTime, + end: NaiveDateTime, + ) -> PaceResult<()> { + let end_opts = ActivityEndOptions::new(end, calculate_duration(&begin, end)?); + self.end_activity(end_opts); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + + use std::str::FromStr; + + use super::*; + + #[test] + fn test_parse_single_toml_activity_passes() { + let toml = r#" + id = "01F9Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3" + category = "Work" + description = "This is an example activity" + end = "2021-08-01T12:00:00" + begin = "2021-08-01T10:00:00" + duration = 5 + kind = "activity" + "#; + + let activity: Activity = toml::from_str(toml).unwrap(); + + assert_eq!( + activity.guid.unwrap().to_string(), + "01F9Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3" + ); + + assert_eq!(activity.category.as_ref().unwrap(), "Work"); + + assert_eq!( + activity.description.as_ref().unwrap(), + "This is an example activity" + ); + + let ActivityEndOptions { end, duration } = activity.activity_end_options().clone().unwrap(); + + assert_eq!( + end, + NaiveDateTime::parse_from_str("2021-08-01T12:00:00", "%Y-%m-%dT%H:%M:%S").unwrap() + ); + + assert_eq!( + activity.begin, + BeginDateTime::from( + NaiveDateTime::parse_from_str("2021-08-01T10:00:00", "%Y-%m-%dT%H:%M:%S").unwrap() + ) + ); + + assert_eq!(duration, PaceDuration::from_str("5").unwrap()); + + assert_eq!(activity.kind, ActivityKind::Activity); + } + + #[test] + fn test_parse_single_toml_intermission_passes() { + let toml = r#" + id = "01F9Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3" + end = "2021-08-01T12:00:00" + begin = "2021-08-01T10:00:00" + duration = 50 + kind = "intermission" + parent-id = "01F9Z4Z3Z3Z3Z4Z3Z3Z3Z3Z3Z4" + "#; + + let activity: Activity = toml::from_str(toml).unwrap(); + + assert_eq!( + activity.guid.unwrap().to_string(), + "01F9Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3" + ); + + let ActivityEndOptions { end, duration } = activity.activity_end_options().clone().unwrap(); + + assert_eq!( + end, + NaiveDateTime::parse_from_str("2021-08-01T12:00:00", "%Y-%m-%dT%H:%M:%S").unwrap() + ); + + assert_eq!(duration, PaceDuration::from_str("50").unwrap()); + + assert_eq!( + activity.begin, + BeginDateTime::from( + NaiveDateTime::parse_from_str("2021-08-01T10:00:00", "%Y-%m-%dT%H:%M:%S").unwrap() + ) + ); + + assert_eq!(activity.kind, ActivityKind::Intermission); + + assert_eq!( + activity + .activity_kind_options + .unwrap() + .parent_id + .unwrap() + .to_string(), + "01F9Z4Z3Z3Z3Z4Z3Z3Z3Z3Z3Z4" + ); + } } diff --git a/crates/core/src/domain/activity_log.rs b/crates/core/src/domain/activity_log.rs index 6d770216..db371145 100644 --- a/crates/core/src/domain/activity_log.rs +++ b/crates/core/src/domain/activity_log.rs @@ -7,17 +7,17 @@ use crate::domain::activity::Activity; /// The activity log entity /// /// The activity log entity is used to store and manage activities -#[derive(Debug, Clone, Serialize, Deserialize, Getters, MutGetters)] +#[derive(Debug, Clone, Serialize, Deserialize, Getters, MutGetters, Default)] pub struct ActivityLog { #[getset(get = "pub", get_mut = "pub")] activities: VecDeque, } -impl Default for ActivityLog { - fn default() -> Self { - Self { - activities: VecDeque::from(vec![Activity::default()]), - } +impl std::ops::Deref for ActivityLog { + type Target = VecDeque; + + fn deref(&self) -> &Self::Target { + &self.activities } } diff --git a/crates/core/src/domain/category.rs b/crates/core/src/domain/category.rs index 5e0567d1..94ec977a 100644 --- a/crates/core/src/domain/category.rs +++ b/crates/core/src/domain/category.rs @@ -2,18 +2,20 @@ use serde_derive::{Deserialize, Serialize}; use typed_builder::TypedBuilder; -use uuid::Uuid; +use ulid::Ulid; /// The category entity #[derive(Debug, Serialize, Deserialize, TypedBuilder, Clone)] pub struct Category { /// The category description #[builder(default, setter(strip_option))] + #[serde(skip_serializing_if = "Option::is_none")] description: Option, /// The category id - #[builder(default = Some(CategoryId::default()), setter(strip_option))] - id: Option, + #[builder(default = Some(CategoryGuid::default()), setter(strip_option))] + #[serde(rename = "id")] + guid: Option, /// The category name name: String, @@ -21,6 +23,7 @@ pub struct Category { /// The category's subcategories // TODO: Add support for subcategories #[builder(default, setter(strip_option))] + #[serde(skip_serializing_if = "Option::is_none")] subcategories: Option>, } @@ -55,21 +58,36 @@ pub fn extract_categories(category_string: &str, separator: &str) -> (Category, /// The category id #[derive(Debug, Serialize, Deserialize, Clone, Copy)] -pub struct CategoryId(Uuid); +pub struct CategoryGuid(Ulid); -impl Default for CategoryId { +impl Default for CategoryGuid { fn default() -> Self { - Self(Uuid::now_v7()) + Self(Ulid::new()) } } impl Default for Category { fn default() -> Self { Self { - id: Some(CategoryId::default()), + guid: Some(CategoryGuid::default()), name: "Uncategorized".to_string(), description: Some("Uncategorized category".to_string()), subcategories: Option::default(), } } } + +#[cfg(feature = "sqlite")] +impl rusqlite::types::FromSql for CategoryGuid { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult { + let bytes = <[u8; 16]>::column_result(value)?; + Ok(Self(Ulid::from(u128::from_be_bytes(bytes)))) + } +} + +#[cfg(feature = "sqlite")] +impl rusqlite::types::ToSql for CategoryGuid { + fn to_sql(&self) -> rusqlite::Result> { + Ok(rusqlite::types::ToSqlOutput::from(self.0.to_string())) + } +} diff --git a/crates/core/src/domain/intermission.rs b/crates/core/src/domain/intermission.rs index 8470455e..c9a2b7de 100644 --- a/crates/core/src/domain/intermission.rs +++ b/crates/core/src/domain/intermission.rs @@ -1,42 +1,5 @@ //! Intermission entity and business logic -use chrono::{Local, NaiveDateTime}; -use serde_derive::{Deserialize, Serialize}; +use crate::Activity; -use crate::domain::time::PaceDuration; - -#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] -pub struct IntermissionPeriod { - begin: NaiveDateTime, - end: Option, - duration: Option, -} - -impl Default for IntermissionPeriod { - fn default() -> Self { - Self { - begin: Local::now().naive_local(), - end: None, - duration: None, - } - } -} - -impl IntermissionPeriod { - pub fn new( - begin: NaiveDateTime, - end: Option, - duration: Option, - ) -> Self { - Self { - begin, - end, - duration, - } - } - - pub fn end(&mut self, end: NaiveDateTime) { - // TODO!: Calculate duration - self.end = Some(end); - } -} +impl Activity {} diff --git a/crates/core/src/domain/priority.rs b/crates/core/src/domain/priority.rs index cce5a243..c7c855a0 100644 --- a/crates/core/src/domain/priority.rs +++ b/crates/core/src/domain/priority.rs @@ -1,8 +1,10 @@ use serde_derive::{Deserialize, Serialize}; -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] -pub enum ItemPriority { +#[derive(Debug, Serialize, Default, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] +#[serde(rename_all = "lowercase")] +pub enum ItemPriorityKind { High, + #[default] Medium, Low, } diff --git a/crates/core/src/domain/project.rs b/crates/core/src/domain/project.rs index e9c4a250..36413f98 100644 --- a/crates/core/src/domain/project.rs +++ b/crates/core/src/domain/project.rs @@ -1,6 +1,6 @@ use serde_derive::{Deserialize, Serialize}; use typed_builder::TypedBuilder; -use uuid::Uuid; +use ulid::Ulid; use crate::domain::task::Task; @@ -11,28 +11,47 @@ pub struct ProjectConfig { } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ProjectId(Uuid); +pub struct ProjectGuid(Ulid); -impl Default for ProjectId { +impl Default for ProjectGuid { fn default() -> Self { - Self(Uuid::now_v7()) + Self(Ulid::new()) + } +} + +#[cfg(feature = "sqlite")] +impl rusqlite::types::FromSql for ProjectGuid { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult { + let bytes = <[u8; 16]>::column_result(value)?; + Ok(Self(Ulid::from(u128::from_be_bytes(bytes)))) + } +} + +#[cfg(feature = "sqlite")] +impl rusqlite::types::ToSql for ProjectGuid { + fn to_sql(&self) -> rusqlite::Result> { + Ok(rusqlite::types::ToSqlOutput::from(self.0.to_string())) } } #[derive(Debug, TypedBuilder, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct Project { #[builder(default, setter(strip_option))] - id: Option, + #[serde(rename = "id", skip_serializing_if = "Option::is_none")] + guid: Option, name: String, + #[serde(skip_serializing_if = "Option::is_none")] description: Option, // TODO: Broken Eq impl // #[serde(skip)] // next_actions: BinaryHeap, + #[serde(skip_serializing_if = "Option::is_none")] finished: Option>, + #[serde(skip_serializing_if = "Option::is_none")] archived: Option>, root_tasks_file: String, @@ -41,7 +60,7 @@ pub struct Project { #[derive(Serialize, Deserialize, Debug, TypedBuilder)] struct Subproject { #[builder(default, setter(strip_option))] - id: Option, + id: Option, name: String, description: String, tasks_file: String, diff --git a/crates/core/src/domain/status.rs b/crates/core/src/domain/status.rs index 4566dad6..4e36c0e1 100644 --- a/crates/core/src/domain/status.rs +++ b/crates/core/src/domain/status.rs @@ -1,10 +1,17 @@ use serde_derive::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] pub enum ItemStatus { Completed, - InProgress, + #[serde(rename = "wip")] + WorkInProgress, Paused, Pending, Scheduled, + Started, + Stopped, + #[default] + Todo, + Waiting, } diff --git a/crates/core/src/domain/tag.rs b/crates/core/src/domain/tag.rs index 4510ce09..00504e47 100644 --- a/crates/core/src/domain/tag.rs +++ b/crates/core/src/domain/tag.rs @@ -1,26 +1,43 @@ use serde_derive::{Deserialize, Serialize}; use typed_builder::TypedBuilder; -use uuid::Uuid; +use ulid::Ulid; #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone)] -pub struct TagId(Uuid); +pub struct TagGuid(Ulid); -impl Default for TagId { +impl Default for TagGuid { fn default() -> Self { - Self(Uuid::now_v7()) + Self(Ulid::new()) + } +} + +#[cfg(feature = "sqlite")] +impl rusqlite::types::FromSql for TagGuid { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult { + let bytes = <[u8; 16]>::column_result(value)?; + Ok(Self(Ulid::from(u128::from_be_bytes(bytes)))) + } +} + +#[cfg(feature = "sqlite")] +impl rusqlite::types::ToSql for TagGuid { + fn to_sql(&self) -> rusqlite::Result> { + Ok(rusqlite::types::ToSqlOutput::from(self.0.to_string())) } } #[derive(Debug, TypedBuilder, Serialize, Deserialize, PartialEq, Eq, Hash, Clone)] pub struct Tag { #[builder(default, setter(strip_option))] - id: Option, + #[serde(rename = "id", skip_serializing_if = "Option::is_none")] + guid: Option, + text: String, } impl Tag { - pub fn new(id: Option, text: String) -> Self { - Self { id, text } + pub fn new(guid: Option, text: String) -> Self { + Self { guid, text } } } diff --git a/crates/core/src/domain/task.rs b/crates/core/src/domain/task.rs index f40ddc96..32551063 100644 --- a/crates/core/src/domain/task.rs +++ b/crates/core/src/domain/task.rs @@ -3,29 +3,39 @@ use chrono::NaiveDateTime; use serde_derive::{Deserialize, Serialize}; use typed_builder::TypedBuilder; -use uuid::Uuid; +use ulid::Ulid; -use crate::domain::{priority::ItemPriority, status::ItemStatus}; +use crate::domain::{priority::ItemPriorityKind, status::ItemStatus}; #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord, Clone)] -pub struct TaskId(Uuid); +pub struct TaskId(Ulid); impl Default for TaskId { fn default() -> Self { - Self(Uuid::now_v7()) + Self(Ulid::new()) } } #[derive(Debug, TypedBuilder, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] pub struct Task { created_at: NaiveDateTime, + description: String, + + #[builder(default, setter(strip_option))] + #[serde(skip_serializing_if = "Option::is_none")] finished_at: Option, + #[builder(default, setter(strip_option))] - id: Option, - priority: ItemPriority, + #[serde(rename = "id", skip_serializing_if = "Option::is_none")] + guid: Option, + + priority: ItemPriorityKind, + status: ItemStatus, + tags: Vec, + title: String, // TODO: It would be nice to have a way to track the number of pomodoro cycles for each task } @@ -34,3 +44,18 @@ pub struct Task { pub struct TaskList { tasks: Vec, } + +#[cfg(feature = "sqlite")] +impl rusqlite::types::FromSql for TaskId { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult { + let bytes = <[u8; 16]>::column_result(value)?; + Ok(Self(Ulid::from(u128::from_be_bytes(bytes)))) + } +} + +#[cfg(feature = "sqlite")] +impl rusqlite::types::ToSql for TaskId { + fn to_sql(&self) -> rusqlite::Result> { + Ok(rusqlite::types::ToSqlOutput::from(self.0.to_string())) + } +} diff --git a/crates/core/src/domain/time.rs b/crates/core/src/domain/time.rs index 23f5f5dd..10d0ad00 100644 --- a/crates/core/src/domain/time.rs +++ b/crates/core/src/domain/time.rs @@ -1,10 +1,13 @@ -use std::fmt::{Display, Formatter}; +use std::{ + fmt::{Display, Formatter}, + str::FromStr, + time::Duration, +}; +use crate::error::{ActivityLogErrorKind, PaceErrorKind, PaceOptResult, PaceResult}; use chrono::{DateTime, Local, NaiveDateTime, NaiveTime, SubsecRound, TimeZone}; use serde_derive::{Deserialize, Serialize}; -use crate::error::{PaceErrorKind, PaceOptResult, PaceResult}; - pub enum TimeFrame { Custom { start: DateTime, @@ -110,8 +113,19 @@ pub fn parse_time_from_user_input(time: &Option) -> PaceOptResult for PaceDuration { - fn from(duration: std::time::Duration) -> Self { +impl FromStr for PaceDuration { + type Err = ActivityLogErrorKind; + + fn from_str(s: &str) -> Result { + match s.parse::() { + Ok(duration) => Ok(Self(duration)), + _ => Err(ActivityLogErrorKind::ParsingDurationFailed(s.to_string())), + } + } +} + +impl From for PaceDuration { + fn from(duration: Duration) -> Self { Self(duration.as_secs()) } } @@ -132,6 +146,10 @@ impl From for PaceDuration { pub struct BeginDateTime(NaiveDateTime); impl BeginDateTime { + pub fn new(time: NaiveDateTime) -> Self { + Self(time) + } + /// Convert to a naive date time pub fn naive_date_time(&self) -> NaiveDateTime { self.0 @@ -160,3 +178,164 @@ impl From for BeginDateTime { Self(time) } } + +impl From> for BeginDateTime { + fn from(time: Option) -> Self { + match time { + Some(time) => Self(time), + None => Self::default(), + } + } +} + +/// Calculate the duration of the activity +/// +/// # Arguments +/// +/// * `end` - The end date and time of the activity +/// +/// # Errors +/// +/// Returns an error if the duration can't be calculated or is negative +/// +/// # Returns +/// +/// Returns the duration of the activity +pub fn calculate_duration(begin: &BeginDateTime, end: NaiveDateTime) -> PaceResult { + let duration = end + .signed_duration_since(begin.naive_date_time()) + .to_std()?; + + Ok(duration.into()) +} + +#[cfg(test)] +mod tests { + + use chrono::NaiveDate; + + use super::*; + + #[test] + fn test_duration_to_str_passes() { + let initial_time = Local::now(); + let result = duration_to_str(initial_time); + assert_eq!(result, "just now"); + } + + #[test] + fn test_extract_time_or_now_passes() { + let time = Some("12:00".to_string()); + let result = extract_time_or_now(&time).expect("Time extraction failed"); + assert_eq!( + result, + NaiveDateTime::new( + Local::now().date_naive(), + NaiveTime::from_hms_opt(12, 0, 0).expect("Invalid date"), + ) + ); + } + + #[test] + fn test_parse_time_from_user_input_passes() { + let time = Some("12:00".to_string()); + let result = parse_time_from_user_input(&time).expect("Time parsing failed"); + assert_eq!( + result, + Some(NaiveDateTime::new( + Local::now().date_naive(), + NaiveTime::from_hms_opt(12, 0, 0).expect("Invalid date"), + )) + ); + } + + #[test] + fn test_calculate_duration_passes() { + let begin = BeginDateTime::new(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2021, 1, 1).expect("Invalid date"), + NaiveTime::from_hms_opt(0, 0, 0).expect("Invalid date"), + )); + let end = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2021, 1, 1).expect("Invalid date"), + NaiveTime::from_hms_opt(0, 0, 1).expect("Invalid date"), + ); + + let duration = calculate_duration(&begin, end).expect("Duration calculation failed"); + assert_eq!(duration, Duration::from_secs(1).into()); + } + + #[test] + fn test_calculate_duration_fails() { + let begin = BeginDateTime::new(NaiveDateTime::new( + NaiveDate::from_ymd_opt(2021, 1, 1).expect("Invalid date"), + NaiveTime::from_hms_opt(0, 0, 1).expect("Invalid date"), + )); + let end = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2021, 1, 1).expect("Invalid date"), + NaiveTime::from_hms_opt(0, 0, 0).expect("Invalid date"), + ); + + let duration = calculate_duration(&begin, end); + assert!(duration.is_err()); + } + + #[test] + fn test_pace_duration_from_duration_passes() { + let duration = Duration::from_secs(1); + let result = PaceDuration::from(duration); + assert_eq!(result, PaceDuration(1)); + } + + #[test] + fn test_pace_duration_from_chrono_duration_passes() { + let duration = chrono::Duration::seconds(1); + let result = PaceDuration::from(duration); + assert_eq!(result, PaceDuration(1)); + } + + #[test] + fn test_begin_date_time_new_passes() { + let time = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2021, 1, 1).expect("Invalid date"), + NaiveTime::from_hms_opt(0, 0, 0).expect("Invalid date"), + ); + let result = BeginDateTime::new(time); + assert_eq!(result, BeginDateTime(time)); + } + + #[test] + fn test_begin_date_time_naive_date_time_passes() { + let time = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2021, 1, 1).expect("Invalid date"), + NaiveTime::from_hms_opt(0, 0, 0).expect("Invalid date"), + ); + let begin_date_time = BeginDateTime::new(time); + let result = begin_date_time.naive_date_time(); + assert_eq!(result, time); + } + + #[test] + fn test_begin_date_time_default_passes() { + let result = BeginDateTime::default(); + assert_eq!( + result, + BeginDateTime(Local::now().naive_local().round_subsecs(0)) + ); + } + + #[test] + fn test_begin_date_time_from_naive_date_time_passes() { + let time = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2021, 1, 1).expect("Invalid date"), + NaiveTime::from_hms_opt(0, 0, 0).expect("Invalid date"), + ); + let result = BeginDateTime::from(time); + assert_eq!(result, BeginDateTime(time)); + } + + #[test] + fn test_pace_duration_default_passes() { + let result = PaceDuration::default(); + assert_eq!(result, PaceDuration(0)); + } +} diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index bd52bd5e..31727323 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -4,7 +4,7 @@ use displaydoc::Display; use std::{error::Error, path::PathBuf}; use thiserror::Error; -use crate::domain::activity::ActivityId; +use crate::domain::activity::ActivityGuid; /// Result type that is being returned from test functions and methods that can fail and thus have errors. pub type TestResult = Result>; @@ -60,6 +60,7 @@ pub enum PaceErrorKind { /// Activity log error: {0} #[error(transparent)] ActivityLog(#[from] ActivityLogErrorKind), + #[cfg(feature = "sqlite")] /// SQLite error: {0} #[error(transparent)] SQLite(#[from] rusqlite::Error), @@ -83,6 +84,8 @@ pub enum PaceErrorKind { DatabaseStorageNotImplemented, /// Failed to parse time '{0}' from user input, please use the format HH:MM ParsingTimeFromUserInputFailed(String), + /// There is no path available to store the activity log + NoPathAvailable, } /// [`ActivityLogErrorKind`] describes the errors that can happen while dealing with the activity log. @@ -92,7 +95,7 @@ pub enum ActivityLogErrorKind { /// No activities found in the activity log NoActivitiesFound, /// Activity with ID {0} not found - FailedToReadActivity(ActivityId), + FailedToReadActivity(ActivityGuid), /// Negative duration for activity NegativeDuration, /// There are no activities to hold @@ -108,13 +111,15 @@ pub enum ActivityLogErrorKind { /// Cache not available CacheNotAvailable, /// Activity with id '{0}' not found - ActivityNotFound(ActivityId), + ActivityNotFound(ActivityGuid), /// Activity with id '{0}' can't be removed from the activity log ActivityCantBeRemoved(usize), /// This activity has no id ActivityIdNotSet, /// Activity with id '{0}' already in use, can't create a new activity with the same id - ActivityIdAlreadyInUse(ActivityId), + ActivityIdAlreadyInUse(ActivityGuid), + /// Failed to parse duration '{0}' from activity log, please use only numbers >= 0 + ParsingDurationFailed(String), } trait PaceErrorMarker: Error {} @@ -122,6 +127,7 @@ trait PaceErrorMarker: Error {} impl PaceErrorMarker for std::io::Error {} impl PaceErrorMarker for toml::de::Error {} impl PaceErrorMarker for toml::ser::Error {} +#[cfg(feature = "sqlite")] impl PaceErrorMarker for rusqlite::Error {} impl PaceErrorMarker for chrono::ParseError {} impl PaceErrorMarker for chrono::OutOfRangeError {} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 1dc6ff01..74ded18c 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,3 +1,5 @@ +//! # Pace Core + pub(crate) mod config; pub(crate) mod domain; pub(crate) mod error; @@ -17,17 +19,20 @@ pub use crate::{ ReviewConfig, }, domain::{ - activity::{Activity, ActivityId, ActivityKind}, + activity::{Activity, ActivityEndOptions, ActivityGuid, ActivityKind, ActivityKindOptions}, activity_log::ActivityLog, filter::{ActivityFilter, FilteredActivities}, - time::{extract_time_or_now, parse_time_from_user_input}, + time::{ + calculate_duration, duration_to_str, extract_time_or_now, parse_time_from_user_input, + BeginDateTime, PaceDuration, + }, }, - error::{PaceError, PaceOptResult, PaceResult, TestResult}, + error::{PaceError, PaceErrorKind, PaceOptResult, PaceResult, TestResult}, service::activity_store::ActivityStore, storage::{ file::TomlActivityStorage, get_storage_from_config, in_memory::InMemoryActivityStorage, ActivityQuerying, ActivityReadOps, ActivityStateManagement, ActivityStorage, ActivityWriteOps, SyncStorage, }, - util::overwrite, + util::overwrite_left_with_right, }; diff --git a/crates/core/src/service/activity_store.rs b/crates/core/src/service/activity_store.rs index fc5a96f9..01d536e2 100644 --- a/crates/core/src/service/activity_store.rs +++ b/crates/core/src/service/activity_store.rs @@ -1,11 +1,12 @@ use std::collections::{BTreeMap, HashSet, VecDeque}; -use chrono::NaiveDateTime; +use chrono::{prelude::NaiveDate, NaiveDateTime}; use serde_derive::{Deserialize, Serialize}; use crate::{ domain::{ - activity::{Activity, ActivityId}, + activity::{Activity, ActivityGuid}, + activity_log::ActivityLog, filter::FilteredActivities, }, error::{PaceOptResult, PaceResult}, @@ -27,9 +28,9 @@ pub struct ActivityStore { /// TODO: Optimization for later to make lookup faster #[derive(Serialize, Deserialize, Debug, Default)] struct ActivityStoreCache { - activity_ids: HashSet, - activities_by_id: BTreeMap, - last_entries: VecDeque, + activity_ids: HashSet, + activities_by_id: BTreeMap, + last_entries: VecDeque, } impl ActivityStore { @@ -56,7 +57,7 @@ impl SyncStorage for ActivityStore { } impl ActivityReadOps for ActivityStore { - fn read_activity(&self, activity_id: ActivityId) -> PaceResult { + fn read_activity(&self, activity_id: ActivityGuid) -> PaceResult { self.storage.read_activity(activity_id) } @@ -69,29 +70,33 @@ impl ActivityReadOps for ActivityStore { } impl ActivityWriteOps for ActivityStore { - fn create_activity(&self, activity: Activity) -> PaceResult { + fn create_activity(&self, activity: Activity) -> PaceResult { self.storage.create_activity(activity) } - fn update_activity(&self, activity_id: ActivityId, activity: Activity) -> PaceResult { + fn update_activity( + &self, + activity_id: ActivityGuid, + activity: Activity, + ) -> PaceResult { self.storage.update_activity(activity_id, activity) } - fn delete_activity(&self, activity_id: ActivityId) -> PaceResult { + fn delete_activity(&self, activity_id: ActivityGuid) -> PaceResult { self.storage.delete_activity(activity_id) } } impl ActivityStateManagement for ActivityStore { - fn begin_activity(&self, activity: Activity) -> PaceResult { + fn begin_activity(&self, activity: Activity) -> PaceResult { self.storage.begin_activity(activity) } fn end_single_activity( &self, - activity_id: ActivityId, + activity_id: ActivityGuid, end_time: Option, - ) -> PaceResult { + ) -> PaceResult { self.storage.end_single_activity(activity_id, end_time) } @@ -117,13 +122,18 @@ impl ActivityStateManagement for ActivityStore { impl ActivityQuerying for ActivityStore { fn find_activities_in_date_range( &self, - _start_date: chrono::prelude::NaiveDate, - _end_date: chrono::prelude::NaiveDate, - ) -> PaceResult { - todo!("Implement find_activities_in_date_range for ActivityStore") + start_date: NaiveDate, + end_date: NaiveDate, + ) -> PaceResult { + self.storage + .find_activities_in_date_range(start_date, end_date) + } + + fn list_activities_by_id(&self) -> PaceOptResult> { + self.storage.list_activities_by_id() } - fn list_activities_by_id(&self) -> PaceOptResult> { - todo!("Implement list_activities_by_id for ActivityStore") + fn latest_active_activity(&self) -> PaceOptResult { + self.storage.latest_active_activity() } } diff --git a/crates/core/src/service/activity_tracker.rs b/crates/core/src/service/activity_tracker.rs index ff312679..2d8c197e 100644 --- a/crates/core/src/service/activity_tracker.rs +++ b/crates/core/src/service/activity_tracker.rs @@ -2,11 +2,11 @@ use std::collections::BTreeMap; -use crate::domain::activity::{Activity, ActivityId}; +use crate::domain::activity::{Activity, ActivityGuid}; // This struct represents the overall structure for tracking activities and their intermissions. #[derive(Default, Debug, Clone)] struct ActivityTracker { - activities: BTreeMap, - intermissions: BTreeMap>, + activities: BTreeMap, + intermissions: BTreeMap>, } diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 4f647455..6099ff66 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -3,9 +3,9 @@ use std::collections::BTreeMap; use chrono::{NaiveDate, NaiveDateTime}; use crate::{ - config::PaceConfig, + config::{ActivityLogStorageKind, PaceConfig}, domain::{ - activity::{Activity, ActivityId}, + activity::{Activity, ActivityGuid}, activity_log::ActivityLog, filter::{ActivityFilter, FilteredActivities}, review::ActivityStats, @@ -21,6 +21,7 @@ pub mod file; /// An in-memory storage backend for activities. pub mod in_memory; // TODO: Implement conversion FromSQL and ToSQL +// #[cfg(feature = "sqlite")] // pub mod sqlite; /// Get the storage backend from the configuration. @@ -37,13 +38,22 @@ pub mod in_memory; /// /// The storage backend. pub fn get_storage_from_config(config: &PaceConfig) -> PaceResult> { - let storage = match config.general().log_storage().as_str() { - "file" => TomlActivityStorage::new(config.general().activity_log_file_path())?, - "database" => return Err(PaceErrorKind::DatabaseStorageNotImplemented.into()), - _ => TomlActivityStorage::new(config.general().activity_log_file_path())?, + let storage: Box = match config + .general() + .activity_log_options() + .activity_log_storage() + { + ActivityLogStorageKind::File => Box::new(TomlActivityStorage::new( + config.general().activity_log_options().activity_log_path(), + )?), + ActivityLogStorageKind::Database => { + return Err(PaceErrorKind::DatabaseStorageNotImplemented.into()) + } + #[cfg(test)] + ActivityLogStorageKind::InMemory => Box::new(in_memory::InMemoryActivityStorage::new()), }; - Ok(Box::new(storage)) + Ok(storage) } /// A type of storage that can be synced to a persistent medium. @@ -107,7 +117,7 @@ pub trait ActivityReadOps { /// # Returns /// /// The activity that was read from the storage backend. If no activity is found, it should return `Ok(None)`. - fn read_activity(&self, activity_id: ActivityId) -> PaceResult; + fn read_activity(&self, activity_id: ActivityGuid) -> PaceResult; /// List activities from the storage backend. /// @@ -143,7 +153,7 @@ pub trait ActivityWriteOps: ActivityReadOps { /// # Returns /// /// If the activity was created successfully it should return the ID of the created activity. - fn create_activity(&self, activity: Activity) -> PaceResult; + fn create_activity(&self, activity: Activity) -> PaceResult; /// Update an existing activity in the storage backend. /// @@ -168,7 +178,11 @@ pub trait ActivityWriteOps: ActivityReadOps { /// # Returns /// /// If the activity was updated successfully it should return the activity before it was updated. - fn update_activity(&self, activity_id: ActivityId, activity: Activity) -> PaceResult; + fn update_activity( + &self, + activity_id: ActivityGuid, + activity: Activity, + ) -> PaceResult; /// Delete an activity from the storage backend. /// @@ -183,7 +197,7 @@ pub trait ActivityWriteOps: ActivityReadOps { /// # Returns /// /// If the activity was deleted successfully it should return the activity that was deleted. - fn delete_activity(&self, activity_id: ActivityId) -> PaceResult; + fn delete_activity(&self, activity_id: ActivityGuid) -> PaceResult; } /// Managing Activity State @@ -206,7 +220,7 @@ pub trait ActivityStateManagement: ActivityReadOps + ActivityWriteOps { /// # Returns /// /// If the activity was started successfully it should return the ID of the started activity. - fn begin_activity(&self, activity: Activity) -> PaceResult { + fn begin_activity(&self, activity: Activity) -> PaceResult { self.create_activity(activity) } @@ -226,9 +240,9 @@ pub trait ActivityStateManagement: ActivityReadOps + ActivityWriteOps { /// If the activity was ended successfully it should return the ID of the ended activity. fn end_single_activity( &self, - activity_id: ActivityId, + activity_id: ActivityGuid, end_time: Option, - ) -> PaceResult; + ) -> PaceResult; /// End all unfinished activities in the storage backend. /// @@ -343,7 +357,19 @@ pub trait ActivityQuerying: ActivityReadOps { /// /// A collection of the activities that were loaded from the storage backend by their ID in a `BTreeMap`. /// If no activities are found, it should return `Ok(None)`. - fn list_activities_by_id(&self) -> PaceOptResult>; + fn list_activities_by_id(&self) -> PaceOptResult>; + + /// Get the latest active activity. + /// + /// # Errors + /// + /// This function should return an error if the activity cannot be loaded. + /// + /// # Returns + /// + /// The latest active activity. + /// If no activity is found, it should return `Ok(None)`. + fn latest_active_activity(&self) -> PaceOptResult; } /// Tagging Activities @@ -366,7 +392,7 @@ pub trait ActivityTagging { /// # Returns /// /// If the tag was added successfully it should return `Ok(())`. - fn add_tag_to_activity(&self, activity_id: ActivityId, tag: &str) -> PaceResult<()>; + fn add_tag_to_activity(&self, activity_id: ActivityGuid, tag: &str) -> PaceResult<()>; /// Remove a tag from an activity. /// @@ -382,7 +408,7 @@ pub trait ActivityTagging { /// # Returns /// /// If the tag was removed successfully it should return `Ok(())`. - fn remove_tag_from_activity(&self, activity_id: ActivityId, tag: &str) -> PaceResult<()>; + fn remove_tag_from_activity(&self, activity_id: ActivityGuid, tag: &str) -> PaceResult<()>; } /// Archiving Activities @@ -406,7 +432,7 @@ pub trait ActivityArchiving { /// # Returns /// /// If the activity was archived successfully it should return `Ok(())`. - fn archive_activity(&self, activity_id: ActivityId) -> PaceResult<()>; + fn archive_activity(&self, activity_id: ActivityGuid) -> PaceResult<()>; /// Unarchive an activity. /// @@ -421,7 +447,7 @@ pub trait ActivityArchiving { /// # Returns /// /// If the activity was unarchived successfully it should return `Ok(())`. - fn unarchive_activity(&self, activity_id: ActivityId) -> PaceResult<()>; + fn unarchive_activity(&self, activity_id: ActivityGuid) -> PaceResult<()>; } /// Generate Statistics for Activities diff --git a/crates/core/src/storage/file.rs b/crates/core/src/storage/file.rs index c72a8b62..d0db7005 100644 --- a/crates/core/src/storage/file.rs +++ b/crates/core/src/storage/file.rs @@ -9,7 +9,7 @@ use chrono::{NaiveDate, NaiveDateTime}; use crate::{ domain::{ - activity::{Activity, ActivityId}, + activity::{Activity, ActivityGuid}, activity_log::ActivityLog, filter::{ActivityFilter, FilteredActivities}, }, @@ -120,7 +120,7 @@ impl ActivityStorage for TomlActivityStorage { } impl ActivityReadOps for TomlActivityStorage { - fn read_activity(&self, activity_id: ActivityId) -> PaceResult { + fn read_activity(&self, activity_id: ActivityGuid) -> PaceResult { self.cache.read_activity(activity_id) } @@ -143,9 +143,9 @@ impl ActivityStateManagement for TomlActivityStorage { fn end_single_activity( &self, - activity_id: ActivityId, + activity_id: ActivityGuid, end_time: Option, - ) -> PaceResult { + ) -> PaceResult { self.cache.end_single_activity(activity_id, end_time) } @@ -158,29 +158,38 @@ impl ActivityStateManagement for TomlActivityStorage { } impl ActivityWriteOps for TomlActivityStorage { - fn create_activity(&self, activity: Activity) -> PaceResult { + fn create_activity(&self, activity: Activity) -> PaceResult { self.cache.create_activity(activity) } - fn update_activity(&self, activity_id: ActivityId, activity: Activity) -> PaceResult { + fn update_activity( + &self, + activity_id: ActivityGuid, + activity: Activity, + ) -> PaceResult { self.cache.update_activity(activity_id, activity) } - fn delete_activity(&self, activity_id: ActivityId) -> PaceResult { + fn delete_activity(&self, activity_id: ActivityGuid) -> PaceResult { self.cache.delete_activity(activity_id) } } impl ActivityQuerying for TomlActivityStorage { - fn list_activities_by_id(&self) -> PaceOptResult> { - todo!("Implement `activities_by_id` for `TomlActivityStorage`") + fn list_activities_by_id(&self) -> PaceOptResult> { + self.cache.list_activities_by_id() } fn find_activities_in_date_range( &self, - _start_date: NaiveDate, - _end_date: NaiveDate, + start_date: NaiveDate, + end_date: NaiveDate, ) -> PaceResult { - todo!("Implement `find_activities_in_date_range` for `TomlActivityStorage`") + self.cache + .find_activities_in_date_range(start_date, end_date) + } + + fn latest_active_activity(&self) -> PaceOptResult { + self.cache.latest_active_activity() } } diff --git a/crates/core/src/storage/in_memory.rs b/crates/core/src/storage/in_memory.rs index 01bcdbe8..7a708783 100644 --- a/crates/core/src/storage/in_memory.rs +++ b/crates/core/src/storage/in_memory.rs @@ -1,12 +1,16 @@ use std::sync::{Arc, Mutex}; -use chrono::{Local, NaiveDateTime}; +use chrono::{Local, NaiveDateTime, SubsecRound}; +use rayon::prelude::{ + IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, +}; use crate::{ domain::{ - activity::{Activity, ActivityId}, + activity::{Activity, ActivityEndOptions, ActivityGuid}, activity_log::ActivityLog, filter::{ActivityFilter, FilteredActivities}, + time::calculate_duration, }, error::{ActivityLogErrorKind, PaceOptResult, PaceResult}, storage::{ @@ -26,9 +30,7 @@ pub struct InMemoryActivityStorage { impl From for InMemoryActivityStorage { fn from(activities: ActivityLog) -> Self { - Self { - activities: Arc::new(Mutex::new(activities)), - } + Self::new_with_activity_log(activities) } } @@ -89,23 +91,25 @@ impl SyncStorage for InMemoryActivityStorage { } impl ActivityReadOps for InMemoryActivityStorage { - fn read_activity(&self, activity_id: ActivityId) -> PaceResult { + fn read_activity(&self, activity_id: ActivityGuid) -> PaceResult { let Ok(activities) = self.activities.lock() else { return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); }; let activity = activities .activities() - .iter() - .find(|activity| { + .par_iter() + .find_first(|activity| { activity - .id() + .guid() .as_ref() .map_or(false, |orig_activity_id| *orig_activity_id == activity_id) }) .cloned() .ok_or(ActivityLogErrorKind::ActivityNotFound(activity_id))?; + drop(activities); + Ok(activity) } @@ -130,47 +134,52 @@ impl ActivityReadOps for InMemoryActivityStorage { return Ok(None); } + drop(activities); + match filter { - ActivityFilter::All => Ok(Some(FilteredActivities::All(activities.clone()))), - ActivityFilter::Active => Ok(Some(FilteredActivities::Active(activities.clone()))), - ActivityFilter::Archived => Ok(Some(FilteredActivities::Archived(activities.clone()))), - ActivityFilter::Ended => Ok(Some(FilteredActivities::Ended(activities.clone()))), + ActivityFilter::All => Ok(Some(FilteredActivities::All(filtered.clone()))), + ActivityFilter::Active => Ok(Some(FilteredActivities::Active(filtered.clone()))), + ActivityFilter::Archived => Ok(Some(FilteredActivities::Archived(filtered.clone()))), + ActivityFilter::Ended => Ok(Some(FilteredActivities::Ended(filtered.clone()))), } } } impl ActivityWriteOps for InMemoryActivityStorage { - fn create_activity(&self, activity: Activity) -> PaceResult { + fn create_activity(&self, activity: Activity) -> PaceResult { let Ok(mut activities) = self.activities.lock() else { return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); }; - let Some(activity_id) = activity.id() else { + let Some(activity_id) = activity.guid() else { return Err(ActivityLogErrorKind::ActivityIdNotSet.into()); }; // Search for the activity in the list of activities to see if the ID is already in use. - if activities - .activities() - .iter() - .any(|activity| activity.id().as_ref().map_or(false, |id| id == activity_id)) - { + if activities.activities().par_iter().any(|activity| { + activity + .guid() + .as_ref() + .map_or(false, |id| id == activity_id) + }) { return Err(ActivityLogErrorKind::ActivityIdAlreadyInUse(*activity_id).into()); } activities.activities_mut().push_front(activity.clone()); + drop(activities); + Ok(*activity_id) } fn update_activity( &self, - activity_id: ActivityId, + activity_id: ActivityGuid, mut activity: Activity, ) -> PaceResult { // First things, first. Replace new activity's id with the original ID we are looking for. // To make sure we are not accidentally changing the ID. - let _ = activity.id_mut().replace(activity_id); + let _ = activity.guid_mut().replace(activity_id); let Ok(mut activities) = self.activities.lock() else { return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); @@ -178,10 +187,10 @@ impl ActivityWriteOps for InMemoryActivityStorage { let og_activity = activities .activities_mut() - .iter_mut() - .find(|activity| { + .par_iter_mut() + .find_first(|activity| { activity - .id() + .guid() .as_ref() .map_or(false, |orig_activity_id| *orig_activity_id == activity_id) }) @@ -191,20 +200,22 @@ impl ActivityWriteOps for InMemoryActivityStorage { *og_activity = activity; + drop(activities); + Ok(original_activity) } - fn delete_activity(&self, activity_id: ActivityId) -> PaceResult { + fn delete_activity(&self, activity_id: ActivityGuid) -> PaceResult { let Ok(mut activities) = self.activities.lock() else { return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); }; let activity_index = activities .activities_mut() - .iter() - .position(|activity| { + .par_iter() + .position_first(|activity| { activity - .id() + .guid() .as_ref() .map_or(false, |orig_activity_id| *orig_activity_id == activity_id) }) @@ -215,6 +226,8 @@ impl ActivityWriteOps for InMemoryActivityStorage { .remove(activity_index) .ok_or(ActivityLogErrorKind::ActivityCantBeRemoved(activity_index))?; + drop(activities); + Ok(activity) } } @@ -222,9 +235,9 @@ impl ActivityWriteOps for InMemoryActivityStorage { impl ActivityStateManagement for InMemoryActivityStorage { fn end_single_activity( &self, - activity_id: ActivityId, + activity_id: ActivityGuid, end_time: Option, - ) -> PaceResult { + ) -> PaceResult { let Ok(mut activities) = self.activities.lock() else { return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); }; @@ -233,19 +246,22 @@ impl ActivityStateManagement for InMemoryActivityStorage { let activity = activities .activities_mut() - .iter_mut() - .find(|activity| { + .par_iter_mut() + .find_first(|activity| { activity - .id() + .guid() .as_ref() .map_or(false, |orig_activity_id| *orig_activity_id == activity_id) }) .ok_or(ActivityLogErrorKind::ActivityNotFound(activity_id))?; - let duration = activity.calculate_duration(end_time)?; + let duration = calculate_duration(activity.begin(), end_time)?; + + let end_opts = ActivityEndOptions::new(end_time, duration); + + activity.end_activity(end_opts); - _ = activity.end_mut().replace(end_time); - _ = activity.duration_mut().replace(duration.into()); + drop(activities); Ok(activity_id) } @@ -258,22 +274,20 @@ impl ActivityStateManagement for InMemoryActivityStorage { return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); }; - let end_time = end_time.unwrap_or_else(|| Local::now().naive_local()); + let end_time = end_time.unwrap_or_else(|| Local::now().naive_local().round_subsecs(0)); let Some(last_unfinished_activity) = activities .activities_mut() - .iter_mut() - .find(|activity| activity.is_active()) + .par_iter_mut() + .find_first(|activity| activity.is_active()) else { return Ok(None); }; - let duration = last_unfinished_activity.calculate_duration(end_time)?; + let duration = calculate_duration(last_unfinished_activity.begin(), end_time)?; - _ = last_unfinished_activity.end_mut().replace(end_time); - _ = last_unfinished_activity - .duration_mut() - .replace(duration.into()); + let end_opts = ActivityEndOptions::new(end_time, duration); + last_unfinished_activity.end_activity(end_opts); Ok(Some(last_unfinished_activity.clone())) } @@ -284,7 +298,7 @@ impl ActivityStateManagement for InMemoryActivityStorage { ) -> PaceOptResult> { let mut ended_activities = vec![]; - let end_time = end_time.unwrap_or_else(|| Local::now().naive_local()); + let end_time = end_time.unwrap_or_else(|| Local::now().naive_local().round_subsecs(0)); let Ok(mut activities) = self.activities.lock() else { return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); @@ -295,10 +309,10 @@ impl ActivityStateManagement for InMemoryActivityStorage { .iter_mut() .filter(|activity| activity.is_active()) .for_each(|activity| { - match activity.calculate_duration(end_time) { + match calculate_duration(activity.begin(), end_time) { Ok(duration) => { - _ = activity.end_mut().replace(end_time); - _ = activity.duration_mut().replace(duration.into()); + let end_opts = ActivityEndOptions::new(end_time, duration); + activity.end_activity(end_opts); ended_activities.push(activity.clone()); } @@ -312,6 +326,8 @@ impl ActivityStateManagement for InMemoryActivityStorage { }; }); + drop(activities); + if ended_activities.is_empty() { return Ok(None); } @@ -338,7 +354,27 @@ impl ActivityQuerying for InMemoryActivityStorage { fn list_activities_by_id( &self, - ) -> PaceOptResult> { + ) -> PaceOptResult> { todo!("Implement list_activities_by_id for InMemoryActivityStorage") } + + fn latest_active_activity(&self) -> PaceOptResult { + let Ok(activities) = self.activities.lock() else { + return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); + }; + + let activity = activities + .activities() + .par_iter() + .find_first(|activity| { + activity.is_active() + && !activity.kind().is_intermission() + && !activity.kind().is_pomodoro_intermission() + }) + .cloned(); + + drop(activities); + + Ok(activity) + } } diff --git a/crates/core/src/util.rs b/crates/core/src/util.rs index 9e0a2faa..b847d770 100644 --- a/crates/core/src/util.rs +++ b/crates/core/src/util.rs @@ -6,6 +6,39 @@ /// /// * `left` - The left value /// * `right` - The right value -pub fn overwrite(left: &mut T, right: T) { +pub fn overwrite_left_with_right(left: &mut T, right: T) { *left = right; } + +#[cfg(test)] +mod tests { + + use crate::Activity; + + use super::*; + + #[test] + fn test_overwrite_i32_passes() { + let mut left = 1; + let right = 2; + overwrite_left_with_right(&mut left, right); + assert_eq!(left, 2); + } + + #[test] + fn test_overwrite_string_passes() { + let mut left = String::from("left"); + let right = String::from("right"); + overwrite_left_with_right(&mut left, right); + assert_eq!(left, "right"); + } + + #[test] + fn test_overwrite_activity_passes() { + let mut left = Activity::default(); + let mut right = Activity::default(); + _ = right.category_mut().replace("right".to_string()); + overwrite_left_with_right(&mut left, right); + assert_eq!(left.category(), &Some("right".to_string())); + } +} diff --git a/crates/core/tests/activity_store.rs b/crates/core/tests/activity_store.rs index e7e96bd9..9aa3420c 100644 --- a/crates/core/tests/activity_store.rs +++ b/crates/core/tests/activity_store.rs @@ -1,11 +1,12 @@ // Test the ActivityStore implementation with a InMemoryStorage backend. +use chrono::{Local, NaiveDateTime}; use pace_core::{ - Activity, ActivityFilter, ActivityId, ActivityLog, ActivityReadOps, ActivityStore, - ActivityWriteOps, InMemoryActivityStorage, TestResult, + Activity, ActivityFilter, ActivityGuid, ActivityLog, ActivityReadOps, ActivityStore, + ActivityWriteOps, BeginDateTime, InMemoryActivityStorage, PaceResult, TestResult, }; - use rstest::{fixture, rstest}; +use similar_asserts::assert_eq; #[fixture] fn activity_log_empty() -> ActivityLog { @@ -14,6 +15,33 @@ fn activity_log_empty() -> ActivityLog { ActivityLog::from_iter(activities) } +#[fixture] +fn activity_log_with_variety_content() -> (Vec, ActivityLog) { + let begin_time = BeginDateTime::new(NaiveDateTime::new( + NaiveDateTime::from_timestamp_opt(0, 0).unwrap().date(), + NaiveDateTime::from_timestamp_opt(0, 0).unwrap().time(), + )); + + let mut ended_activity = Activity::builder() + .description("Test Description".to_string()) + .begin(begin_time) + .build(); + ended_activity + .end_activity_with_duration_calc(begin_time, Local::now().naive_local()) + .expect("Creating ended activity should not fail."); + + let activities = vec![ + Activity::default(), + Activity::default(), + ended_activity, + Activity::default(), + Activity::default(), + Activity::default(), + ]; + + (activities.clone(), ActivityLog::from_iter(activities)) +} + #[fixture] fn activity_log_with_content() -> (Vec, ActivityLog) { let activities = vec![ @@ -31,7 +59,7 @@ fn activity_log_with_content() -> (Vec, ActivityLog) { #[fixture] fn activity_store_with_item( activity_log_empty: ActivityLog, -) -> TestResult<(ActivityId, Activity, ActivityStore)> { +) -> TestResult<(ActivityGuid, Activity, ActivityStore)> { let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( activity_log_empty, ))); @@ -58,7 +86,7 @@ fn test_activity_store_create_activity_passes(activity_log_empty: ActivityLog) - .build(); let og_activity = activity.clone(); - let og_activity_id = activity.id().expect("Activity ID should be set."); + let og_activity_id = activity.guid().expect("Activity ID should be set."); let activity_id = store.create_activity(activity)?; @@ -80,10 +108,10 @@ fn test_activity_store_create_activity_fails( activity_log, ))); - let id = activities[0].id().expect("Activity ID should be set."); + let id = activities[0].guid().expect("Activity ID should be set."); let activity = Activity::builder() - .id(id) + .guid(id) .description("Test Description".to_string()) .category(Some("Test::Category".to_string())) .build(); @@ -93,7 +121,7 @@ fn test_activity_store_create_activity_fails( #[rstest] fn test_activity_store_read_activity_passes( - activity_store_with_item: TestResult<(ActivityId, Activity, ActivityStore)>, + activity_store_with_item: TestResult<(ActivityGuid, Activity, ActivityStore)>, ) -> TestResult<()> { let (og_activity_id, og_activity, store) = activity_store_with_item?; @@ -110,15 +138,16 @@ fn test_activity_store_read_activity_fails(activity_log_empty: ActivityLog) { activity_log_empty, ))); - let activity_id = ActivityId::default(); + let activity_id = ActivityGuid::default(); assert!(store.read_activity(activity_id).is_err()); } +// TODO!: Test the list_activities method with all the other filters. // List activities can hardly fail, as it returns an empty list if no activities are found. // Therefore, we only test the success case. It would fail if the mutex is poisoned. #[rstest] -fn test_activity_store_list_activities_passes( +fn test_activity_store_list_active_activities_passes( activity_log_with_content: (Vec, ActivityLog), ) -> TestResult<()> { let (activities, activity_log) = activity_log_with_content; @@ -138,9 +167,69 @@ fn test_activity_store_list_activities_passes( Ok(()) } +#[rstest] +fn test_activity_store_list_ended_activities_passes( + activity_log_with_variety_content: (Vec, ActivityLog), +) -> TestResult<()> { + let (_activities, activity_log) = activity_log_with_variety_content; + let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( + activity_log, + ))); + + let loaded_activities = store + .list_activities(ActivityFilter::Ended)? + .expect("Should have activities."); + + assert_eq!(1, loaded_activities.into_log().activities().len()); + + Ok(()) +} + +#[rstest] +#[ignore = "We need to implement the archiving feature first."] +fn test_activity_store_list_archived_activities_passes() -> TestResult<()> { + // TODO!: We need to implement the archiving feature first. + todo!() +} + +#[rstest] +fn test_activity_store_list_all_activities_passes( + activity_log_with_variety_content: (Vec, ActivityLog), +) -> TestResult<()> { + let (activities, activity_log) = activity_log_with_variety_content; + let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( + activity_log, + ))); + + let loaded_activities = store + .list_activities(ActivityFilter::All)? + .expect("Should have activities."); + + assert_eq!( + activities.len(), + loaded_activities.into_log().activities().len() + ); + + Ok(()) +} + +#[rstest] +fn test_activity_store_list_all_activities_empty_result_passes( + activity_log_empty: ActivityLog, +) -> TestResult<()> { + let activity_log = activity_log_empty; + let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( + activity_log, + ))); + + assert!(store.list_activities(ActivityFilter::All)?.is_none()); + + Ok(()) +} + #[rstest] fn test_activity_store_update_activity_passes( - activity_store_with_item: TestResult<(ActivityId, Activity, ActivityStore)>, + activity_store_with_item: TestResult<(ActivityGuid, Activity, ActivityStore)>, ) -> TestResult<()> { let (og_activity_id, og_activity, store) = activity_store_with_item?; @@ -158,7 +247,7 @@ fn test_activity_store_update_activity_passes( let stored_activity = store.read_activity(og_activity_id)?; - _ = new_activity.id_mut().replace(og_activity_id); + _ = new_activity.guid_mut().replace(og_activity_id); assert_eq!(stored_activity, new_activity); @@ -167,7 +256,7 @@ fn test_activity_store_update_activity_passes( #[rstest] fn test_activity_store_delete_activity_passes( - activity_store_with_item: TestResult<(ActivityId, Activity, ActivityStore)>, + activity_store_with_item: TestResult<(ActivityGuid, Activity, ActivityStore)>, ) -> TestResult<()> { let (og_activity_id, og_activity, store) = activity_store_with_item?; @@ -189,7 +278,7 @@ fn test_activity_store_delete_activity_fails( activity_log, ))); - let activity_id = ActivityId::default(); + let activity_id = ActivityGuid::default(); assert!(store.delete_activity(activity_id).is_err()); } @@ -208,7 +297,36 @@ fn test_activity_store_update_activity_fails( .category(Some("test".to_string())) .build(); - let activity_id = ActivityId::default(); + let activity_id = ActivityGuid::default(); assert!(store.update_activity(activity_id, new_activity).is_err()); } + +#[rstest] +fn test_activity_store_begin_intermission_passes() -> PaceResult<()> { + let toml_string = r#" +[[activities]] +id = "01HQ8B27751H7QPBD2V7CZD1B7" +description = "Intermission Test" +begin = "2024-02-22T13:01:25" +kind = "intermission" +parent-id = "01HQ8B1WS5X0GZ660738FNED91" + +[[activities]] +id = "01HQ8B1WS5X0GZ660738FNED91" +category = "MyCategory::SubCategory" +description = "Intermission Test" +begin = "2024-02-22T13:01:14" +kind = "activity" +"#; + + let activity_log = toml::from_str::(toml_string)?; + + let _store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( + activity_log, + ))); + + // TODO!: Implement intermission handling. + + Ok(()) +} diff --git a/crates/core/tests/find_configs.rs b/crates/core/tests/find_configs.rs index 68c0d5a4..cc07e567 100644 --- a/crates/core/tests/find_configs.rs +++ b/crates/core/tests/find_configs.rs @@ -1,5 +1,5 @@ use pace_core::{find_root_config_file_path, TestResult}; - +use similar_asserts::assert_eq; use std::env; use rstest::rstest; diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 258d2149..31609d25 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -19,3 +19,6 @@ include = [ ] [dependencies] + +[lints] +workspace = true diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 8b137891..ff16137a 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -1 +1 @@ - +//! Server Library for pace diff --git a/data/activity_2024-02.pace.toml b/data/activity_2024-02.pace.toml index dca37a0b..b27e7220 100644 --- a/data/activity_2024-02.pace.toml +++ b/data/activity_2024-02.pace.toml @@ -1,58 +1,62 @@ [[activities]] -id = "018d9086-4028-7a41-a084-48d386cab766" +id = "01HPY705770QKPC8D7AA8W4FNT" category = "MyCategory::SubCategory" description = "This is my task description" begin = "2024-02-10T01:58:54" kind = "activity" [[activities]] -id = "018d9086-35a3-7e56-b2f7-7d3b50045fb2" +id = "01HPY70577HNJPA7MC2NKW5JT1" category = "MyCategory::SubCategory" description = "This is my task description" begin = "2024-02-10T01:58:51" kind = "activity" [[activities]] -id = "018d9086-2f8f-7c9d-b94c-2ff4ba96aabc" +id = "01HPY7057730PYEPN7R3Q9TWC7" category = "MyCategory::SubCategory" description = "This is my task description" begin = "2024-02-10T01:58:50" kind = "activity" [[activities]] -id = "018d9065-7020-7ee8-a14b-f92b23078479" +id = "01HPY7057761DVHMFW64P1YXQ8" category = "MyCategory::SubCategory" description = "This is my task description" begin = "2024-02-10T01:23:03" kind = "activity" [[activities]] -id = "018d84a4-f5d0-7dd8-a170-c8febe6d93e4" -category = "intermission" -end = "2024-02-04T00:15:00" -begin = "2024-02-03T22:30:00" +id = "01HPY70577MQYQXTR4YFJ6NB1Y" +end = "2024-02-04T00:00:00" +begin = "2024-02-03T23:30:00" +duration = 1800 kind = "intermission" +parent-id = "01HPY70577HJBZ20NQR15AR9G0" [[activities]] -id = "018d84a5-03f4-755a-943a-5572a846f347" +id = "01HPY70577HJBZ20NQR15AR9G0" category = "design::pace" description = "Initial design process and requirements analysis." end = "2024-02-04T00:15:00" +duration = 6300 begin = "2024-02-03T22:30:00" kind = "task" [[activities]] -id = "018d84a5-134a-7809-803b-7017ae638055" +id = "01HPY70577H375FDKT9XXAT7VB" category = "development::pace" description = "Implemented the login feature." end = "2024-02-04T10:30:00" begin = "2024-02-04T09:00:00" +duration = 5400 kind = "task" [[activities]] -id = "018d84a5-233f-7f41-b0a7-535c76b85afc" +id = "01HPY70577PMEY35A8V8FV30VC" category = "research::pace" description = "Researched secure authentication methods." end = "2024-02-04T12:00:00" begin = "2024-02-04T11:00:00" +duration = 3600 kind = "task" diff --git a/src/commands.rs b/src/commands.rs index 03ab787e..4af810f1 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -10,18 +10,18 @@ //! See the `impl Configurable` below for how to specify the path to the //! application's configuration file. -mod begin; -mod end; -mod export; -// TODO: mod import; -mod craft; -mod hold; -mod now; -mod pomo; -mod resume; -mod review; -mod set; -mod tasks; +pub mod begin; +pub mod end; +pub mod export; +// TODO: pub mod import; +pub mod craft; +pub mod hold; +pub mod now; +pub mod pomo; +pub mod resume; +pub mod review; +pub mod set; +pub mod tasks; use abscissa_core::{config::Override, Command, Configurable, FrameworkError, Runnable}; use clap::builder::{styling::AnsiColor, Styles}; @@ -116,8 +116,10 @@ impl Override for EntryPoint { // Override the activity log file if it's set if let Some(activity_log_file) = &self.activity_log_file { if activity_log_file.exists() { - *config.general_mut().activity_log_file_path_mut() = - activity_log_file.to_string_lossy().to_string(); + *config + .general_mut() + .activity_log_options_mut() + .activity_log_path_mut() = activity_log_file.to_path_buf(); } }; diff --git a/src/commands/begin.rs b/src/commands/begin.rs index 240ea46f..18e4cbd1 100644 --- a/src/commands/begin.rs +++ b/src/commands/begin.rs @@ -8,7 +8,7 @@ use crate::prelude::PACE_APP; use pace_core::{ extract_time_or_now, get_storage_from_config, Activity, ActivityKind, ActivityStateManagement, - ActivityStorage, ActivityStore, SyncStorage, + ActivityStorage, ActivityStore, PaceConfig, SyncStorage, }; /// `begin` subcommand @@ -41,7 +41,7 @@ pub struct BeginCmd { impl Runnable for BeginCmd { /// Start the application. fn run(&self) { - if let Err(err) = self.inner_run() { + if let Err(err) = self.inner_run(&PACE_APP.config()) { status_err!("{}", err); PACE_APP.shutdown(Shutdown::Crash); }; @@ -49,7 +49,25 @@ impl Runnable for BeginCmd { } impl BeginCmd { - pub fn inner_run(&self) -> Result<()> { + /// Create a new instance of the `begin` subcommand + pub fn new( + category: impl Into>, + time: impl Into>, + description: String, + tags: impl Into>>, + projects: impl Into>>, + ) -> Self { + Self { + category: category.into(), + time: time.into(), + description, + tags: tags.into(), + _projects: projects.into(), + } + } + + /// Inner run implementation for the begin command + pub fn inner_run(&self, config: &PaceConfig) -> Result<()> { let Self { category, time, @@ -87,14 +105,53 @@ impl BeginCmd { .category(category.clone()) .build(); - let activity_store = ActivityStore::new(get_storage_from_config(&PACE_APP.config())?); + let activity_store = ActivityStore::new(get_storage_from_config(config)?); activity_store.setup_storage()?; - let _activity = activity_store.begin_activity(activity.clone())?; - activity_store.sync()?; + let activity_id = activity_store.begin_activity(activity.clone())?; - println!("{activity}"); + if let Some(og_activity_id) = activity.guid() { + if activity_id == *og_activity_id { + activity_store.sync()?; + println!("{activity}"); + return Ok(()); + } + } - Ok(()) + eyre::bail!("Failed to start {activity}"); } } + +// TODO!: Test pace-rs begin command +// #[cfg(test)] +// mod tests { + +// use std::env::temp_dir; + +// use abscissa_core::fs::create_dir_all; +// use eyre::Ok; + +// use super::*; + +// #[test] +// fn test_begin_subcommand_creates_activity_passes() -> Result<()> { +// let temp_dir = temp_dir(); +// let temp_file = temp_dir.join("activity_log.toml"); +// create_dir_all(temp_dir)?; + +// let cmd = BeginCmd::new( +// "Test::Category".to_string(), +// "22:00".to_string(), +// "Test description".to_string(), +// None, +// None, +// ); + +// let mut pace_config = PaceConfig::default(); +// pace_config.add_activity_log_path(temp_file.to_string_lossy().to_string()); + +// cmd.inner_run(&pace_config)?; + +// Ok(()) +// } +// } diff --git a/src/commands/craft.rs b/src/commands/craft.rs index 92ea9d4f..efdad050 100644 --- a/src/commands/craft.rs +++ b/src/commands/craft.rs @@ -24,6 +24,7 @@ pub enum CraftSubCmd { Completions(completions::CompletionsCmd), } +/// `craft` subcommand #[derive(Command, Debug, Parser, Runnable)] pub struct CraftCmd { #[clap(subcommand)] diff --git a/src/commands/end.rs b/src/commands/end.rs index f5523bd1..3e50c214 100644 --- a/src/commands/end.rs +++ b/src/commands/end.rs @@ -33,7 +33,7 @@ impl Runnable for EndCmd { } impl EndCmd { - pub fn inner_run(&self) -> Result<()> { + fn inner_run(&self) -> Result<()> { let Self { time, only_last, .. } = self; @@ -46,13 +46,13 @@ impl EndCmd { if *only_last { if let Some(last_activity) = activity_store.end_last_unfinished_activity(time)? { - println!("Ended {last_activity} ({:?})", last_activity.duration()); + println!("Ended {last_activity}"); } } else if let Some(unfinished_activities) = activity_store.end_all_unfinished_activities(time)? { for activity in &unfinished_activities { - println!("Ended {activity} ({:?})", activity.duration()); + println!("Ended {activity}"); } } else { println!("No unfinished activities to end."); diff --git a/src/commands/hold.rs b/src/commands/hold.rs index 8f2ae782..9475c2b2 100644 --- a/src/commands/hold.rs +++ b/src/commands/hold.rs @@ -4,6 +4,11 @@ use abscissa_core::{status_err, Application, Command, Runnable, Shutdown}; use clap::Parser; use eyre::Result; +use pace_core::{ + get_storage_from_config, parse_time_from_user_input, Activity, ActivityKind, + ActivityKindOptions, ActivityQuerying, ActivityStateManagement, ActivityStorage, ActivityStore, + SyncStorage, +}; use crate::prelude::PACE_APP; @@ -13,6 +18,13 @@ pub struct HoldCmd { /// The time the activity has been holded (defaults to the current time if not provided). Format: HH:MM #[clap(long)] time: Option, + + /// The Category of the activity you want to start + /// + /// You can use the separator you setup in the configuration file + /// to specify a subcategory. + #[clap(short, long)] + category: Option, } impl Runnable for HoldCmd { @@ -26,25 +38,51 @@ impl Runnable for HoldCmd { } impl HoldCmd { + /// Inner run implementation for the hold command pub fn inner_run(&self) -> Result<()> { - // TODO!: Implement hold command - // - // let HoldCmd { time } = self; + let HoldCmd { time, category } = self; + + let time = parse_time_from_user_input(time)?; + + let activity_store = ActivityStore::new(get_storage_from_config(&PACE_APP.config())?); + + // Get id from last activity that is not ended + let Some(active_activity) = activity_store.latest_active_activity()? else { + eyre::bail!("No activity to hold."); + }; + + let Some(parent_id) = active_activity.guid() else { + eyre::bail!( + "Activity {active_activity} has no valid ID, can't identify uniquely. Stopping." + ); + }; + + let activity_kind_opts = ActivityKindOptions::new(*parent_id); - // let time = parse_time_from_user_input(time)?; + let activity = Activity::builder() + .begin(time.into()) + .kind(ActivityKind::Intermission) + .description( + active_activity + .description() + .clone() + .unwrap_or_else(|| format!("Holding {active_activity}")), + ) + .category(category.clone()) + .activity_kind_options(activity_kind_opts) + .build(); - // let activity_store = ActivityStore::new(get_storage_from_config(&PACE_APP.config())?); + activity_store.setup_storage()?; - // activity_store.setup_storage()?; + let activity_id = activity_store.begin_activity(activity.clone())?; - // if let Some(held_activity) = activity_store - // .end_or_hold_activities(ActivityEndKind::Hold, time)? - // .try_into_hold()? - // { - // println!("Held {held_activity}"); - // } else { - // println!("No unfinished activities to hold."); - // } + if let Some(og_activity_id) = activity.guid() { + if activity_id == *og_activity_id { + activity_store.sync()?; + println!("Held {activity}"); + return Ok(()); + } + } Ok(()) } diff --git a/src/commands/now.rs b/src/commands/now.rs index 0151a893..25d25735 100644 --- a/src/commands/now.rs +++ b/src/commands/now.rs @@ -23,6 +23,7 @@ impl Runnable for NowCmd { } impl NowCmd { + /// Inner run implementation for the now command pub fn inner_run(&self) -> Result<()> { let activity_store = ActivityStore::new(get_storage_from_config(&PACE_APP.config())?); diff --git a/tests/cli.rs b/tests/cli.rs index 0b12cdb3..a474fad6 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,9 @@ use assert_cmd::Command; use predicates::prelude::predicate; -// use tempfile::{tempdir, TempDir}; +// use similar_asserts::assert_eq; +// use tempfile::tempdir; + +// use pace_core::ActivityLog; pub type TestResult = Result>; @@ -43,21 +46,52 @@ fn test_help_command_passes() -> TestResult<()> { Ok(()) } -// TODO: Test begin command +// TODO!: Test begin command // #[test] // fn test_begin_command_passes() -> TestResult<()> { -// pace_runner()? +// let activity_log_file = tempdir()?.into_path().join("activity_log.toml"); + +// let desc = "Test description"; +// let category = "Test::Category"; +// let time = "22:00"; + +// if activity_log_file.exists() { +// std::fs::remove_file(&activity_log_file)?; +// } + +// std::fs::write(&activity_log_file, "")?; + +// _ = pace_runner()? // .args([ // "-a", -// "./activity_log.toml", +// activity_log_file.as_path().to_str().unwrap(), // "begin", -// "This is my task description", +// desc, // "-c", -// "MyCategory::SubCategory", +// category, +// "-t", +// time, // ]) // .assert() // .success() -// .stdout(predicate::str::contains("started")); // TODO +// .stdout(predicate::str::contains("started")); + +// let contents = std::fs::read_to_string(&activity_log_file)?; + +// let activity_log = toml::from_str::(&contents)?; + +// insta::assert_toml_snapshot!(activity_log); +// assert_eq!(activity_log.activities().len(), 1); + +// let activity = activity_log.activities().front().unwrap(); + +// assert_eq!(activity.description(), &Some(desc.to_string())); +// assert_eq!(activity.category(), &Some(category.to_string())); +// assert_eq!(format!("{:?}", activity.begin()), time); + +// if activity_log_file.exists() { +// std::fs::remove_file(&activity_log_file)?; +// } // Ok(()) // } diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index d4c6ba08..5f38147e 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xtask" -version = "0.1.0" +version = "0.0.0" edition = "2021" publish = false