diff --git a/Cargo.lock b/Cargo.lock index ffd77db..eb7c53c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,19 +3,25 @@ version = 4 [[package]] -name = "aho-corasick" -version = "1.1.3" +name = "addr2line" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ - "memchr", + "gimli", ] [[package]] -name = "android-tzdata" -version = "0.1.1" +name = "adler2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" @@ -27,61 +33,26 @@ dependencies = [ ] [[package]] -name = "anstream" -version = "0.6.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.4" +name = "autocfg" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" -dependencies = [ - "windows-sys", -] +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "anstyle-wincon" -version = "3.0.10" +name = "backtrace" +version = "0.3.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", ] -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - [[package]] name = "bitflags" version = "2.9.4" @@ -94,11 +65,26 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" -version = "1.2.35" +version = "1.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590f9024a68a8c40351881787f1934dc11afd69090f5edb6831464694d836ea3" +checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" dependencies = [ "find-msvc-tools", "shlex", @@ -112,11 +98,10 @@ checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", @@ -126,69 +111,181 @@ dependencies = [ ] [[package]] -name = "clap" -version = "4.5.47" +name = "color-eyre" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" dependencies = [ - "clap_builder", - "clap_derive", + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", ] [[package]] -name = "clap_builder" -version = "4.5.47" +name = "color-spantrace" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", ] [[package]] -name = "clap_derive" -version = "4.5.47" +name = "compact_str" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" dependencies = [ - "heck", + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.2", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", "proc-macro2", "quote", + "strsim", "syn", ] [[package]] -name = "clap_lex" -version = "0.7.5" +name = "darling_macro" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] [[package]] -name = "colorchoice" -version = "1.0.4" +name = "deranged" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +dependencies = [ + "powerfmt", +] [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "derive_more" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] [[package]] name = "devlog" version = "0.1.0" dependencies = [ "chrono", - "clap", + "color-eyre", + "crossterm 0.29.0", "dirs", - "regex", + "ratatui", "serde", - "serde_json", "tempfile", - "uuid", + "walkdir", ] [[package]] @@ -209,17 +306,48 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.61.0", ] +[[package]] +name = "document-features" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.0", +] + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", ] [[package]] @@ -230,9 +358,21 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.0" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + +[[package]] +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e178e4fba8a2726903f6ba98a6d221e76f9c12c650d5dc0e6afdc50677b49650" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "getrandom" @@ -254,7 +394,24 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.3+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", ] [[package]] @@ -265,9 +422,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -288,10 +445,44 @@ dependencies = [ ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.1" +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indenter" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + +[[package]] +name = "instability" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] [[package]] name = "itoa" @@ -301,14 +492,20 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" dependencies = [ "once_cell", "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.175" @@ -317,9 +514,9 @@ checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libredox" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags", "libc", @@ -327,9 +524,31 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litrs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] [[package]] name = "log" @@ -337,12 +556,48 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "memchr" version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -352,6 +607,24 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -359,16 +632,57 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] -name = "once_cell_polyfill" -version = "1.70.1" +name = "option-ext" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] -name = "option-ext" +name = "owo-colors" +version = "4.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "proc-macro2" @@ -394,6 +708,37 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "time", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -406,45 +751,35 @@ dependencies = [ ] [[package]] -name = "regex" -version = "1.11.2" +name = "rustc-demangle" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] -name = "regex-automata" -version = "0.4.10" +name = "rustix" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", ] -[[package]] -name = "regex-syntax" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" - [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.0", ] [[package]] @@ -459,20 +794,45 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.225" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" dependencies = [ "proc-macro2", "quote", @@ -480,15 +840,12 @@ dependencies = [ ] [[package]] -name = "serde_json" -version = "1.0.143" +name = "sharded-slab" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", + "lazy_static", ] [[package]] @@ -497,12 +854,76 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "2.0.106" @@ -516,15 +937,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix", - "windows-sys", + "rustix 1.1.2", + "windows-sys 0.61.0", ] [[package]] @@ -547,27 +968,126 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] -name = "utf8parse" -version = "0.2.2" +name = "unicode-segmentation" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] -name = "uuid" -version = "1.18.1" +name = "unicode-truncate" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "getrandom 0.3.3", - "js-sys", - "wasm-bindgen", + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", ] [[package]] @@ -578,30 +1098,40 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.3+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" dependencies = [ "bumpalo", "log", @@ -613,9 +1143,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -623,9 +1153,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" dependencies = [ "proc-macro2", "quote", @@ -636,18 +1166,49 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" -version = "0.61.2" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" dependencies = [ "windows-implement", "windows-interface", @@ -680,44 +1241,52 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" [[package]] name = "windows-result" -version = "0.3.4" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" dependencies = [ "windows-link", ] [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets", ] [[package]] -name = "windows-targets" -version = "0.53.3" +name = "windows-sys" +version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" dependencies = [ "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", @@ -730,54 +1299,54 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "wit-bindgen" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/Cargo.toml b/Cargo.toml index ef23f19..be4eb8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,11 +4,13 @@ version = "0.1.0" edition = "2024" [dependencies] -chrono = { version = "0.4.41", features = ["serde"] } -clap = { version = "4.5.47", features = ["derive"] } +chrono = { version = "0.4.42", features = ["serde"] } +color-eyre = "0.6.5" +crossterm = "0.29.0" dirs = "6.0.0" -regex = "1.11.2" -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.143" -tempfile = "3.21.0" -uuid = { version = "1.18.1", features = ["v4"] } +ratatui = { version = "0.29.0", features = ["all-widgets"] } +serde = { version = "1.0.225", features = ["derive"] } +walkdir = "2.5.0" + +[dev-dependencies] +tempfile = "3.22.0" diff --git a/docs/event-sourcing-architecture.md b/docs/event-sourcing-architecture.md deleted file mode 100644 index 3c8edc1..0000000 --- a/docs/event-sourcing-architecture.md +++ /dev/null @@ -1,244 +0,0 @@ -# DevLog Event Sourcing Architecture - -## Overview - -DevLog is a developer journal CLI tool built on **event sourcing principles**. The system captures developer activities, parses annotations from natural language, and maintains complete historical state through immutable events stored in append-only logs. - -## Core Event Sourcing Design - -### Events as Source of Truth - -All state changes are captured as immutable events: - -```rust -enum EntryEvent { - Created { id, content, timestamp }, - ContentUpdated { content, timestamp }, - AnnotationParsed { tags, people, projects, timestamp }, -} -``` - -**Key Properties:** - -- **Immutable**: Events never change once written -- **Timestamped**: Complete chronological ordering -- **Serializable**: JSON Lines format for storage -- **Append-only**: New events added, old events preserved - -### Uniform Event Processing - -Both new entries and loaded entries use identical event replay logic: - -```rust -// Creation: applies events uniformly -let mut entry = Entry::new("content"); - -// Loading: reconstructs state from events -let entry = Entry::from_events(stored_events); -``` - -This ensures **consistent behavior** and **single source of truth** for state transitions. - -## Architecture Components - -### 1. Entry Aggregate (`src/entry.rs`) - -Central business logic component managing events and state: - -```rust -struct Entry { - events: Vec, // Complete event history - state: EntryState, // Current derived state - annotation_parser: AnnotationParser, -} -``` - -**Responsibilities:** - -- Generates events for all state changes -- Maintains current state via `apply_event()` -- Automatic annotation parsing on content changes -- Provides clean API for business operations - -### 2. Dual Storage Strategy (`src/storage.rs`) - -``` -~/.devlog/ -├── events/20250905.jsonl # Event sourcing (append-only) -└── entries/20250905.md # Current state (overwritten) -``` - -**Technical Choices:** - -- **JSONL Format**: One JSON event per line for streaming/recovery -- **PathBuf**: Cross-platform file path handling -- **Dual Persistence**: Events for history, markdown for user convenience -- **Automatic Cleanup**: Events enable state reconstruction if markdown corrupted - -### 3. Annotation System (`src/annotations.rs`) - -Regex-based parsing extracting structured metadata from natural text: - -``` -@alice → people: ["alice"] -::project → projects: ["project"] -+rust → tags: ["rust"] -``` - -**Implementation Details:** - -- **Vec**: Preserves order and allows duplicates (vs HashSet) -- **Generic Extraction**: Single `extract_with_regex()` for all annotation types -- **DRY Principle**: Eliminates code duplication across parsers - -## Key Technical Decisions - -### Why Vec over HashSet? - -```rust -// Vec preserves order and frequency -"Met @alice then @bob then @alice" → ["alice", "bob", "alice"] - -// HashSet would lose information -"Met @alice then @bob then @alice" → ["alice", "bob"] -``` - -**Benefits:** - -- Order preservation (first mention vs later mentions) -- Frequency tracking (how often someone is mentioned) -- Simpler serialization (direct JSON arrays) - -### Why Event Sourcing? - -1. **Complete Audit Trail**: Every change tracked with timestamps -2. **Data Recovery**: State can always be rebuilt from events -3. **Future Analytics**: Rich historical data for insights -4. **Scalability**: Append-only writes are fast and scalable - -### Error Handling Strategy - -```rust -Result> -``` - -- **Flexible**: Handles any error type implementing `std::error::Error` -- **Composable**: File I/O, JSON parsing, and business logic errors -- **Ergonomic**: `?` operator for clean error propagation - -## Event Flow Example - -### Creating New Entry - -```rust -Entry::new("Worked with @alice on ::search using +rust") -``` - -1. **Created Event**: `{ content: "...", timestamp: now }` -2. **Apply Event**: Update state with content and metadata -3. **Parse Annotations**: Extract @alice, ::search, +rust -4. **AnnotationParsed Event**: `{ people: ["alice"], ... }` -5. **Persist**: Save events to `.jsonl`, state to `.md` - -### Loading Existing Entry - -```rust -Entry::load("20250905", storage) -``` - -1. **Load Events**: Read from `events/20250905.jsonl` -2. **Replay Events**: Apply each event in chronological order -3. **Reconstruct State**: Final state matches original creation -4. **Return Entry**: Ready for further operations - -## Performance Characteristics - -### Storage - -- **Write Performance**: O(1) append operations -- **Read Performance**: O(n) event replay (cached in memory) -- **Space Efficiency**: Events are compact JSON, markdown is human-readable - -### Memory Usage - -- **Current State**: Kept in memory for fast access -- **Event History**: Stored on disk, loaded on demand -- **Annotation Parsing**: Compiled regexes cached per entry - -## Extensibility Points - -### New Event Types - -```rust -// Easy to add new events -EntryEvent::InsightsGenerated { insights, timestamp } -EntryEvent::TagsUpdated { added, removed, timestamp } -``` - -### New Annotation Types - -```rust -// Add location mentions: #san-francisco -locations_regex: Regex::new(r"#([\w-]+)") -``` - -### Storage Backends - -```rust -// Interface allows database, cloud storage -trait EventStorage { - fn save_events(&self, date: &str, events: &[EntryEvent]) -> Result<()>; - fn load_events(&self, date: &str) -> Result>; -} -``` - -## Testing Strategy - -### Event Sourcing Validation - -- **Consistency Tests**: `new()` and `from_events()` produce identical state -- **Replay Tests**: Complex event sequences reconstruct correctly -- **Edge Cases**: Empty events, malformed data, missing files - -### Isolation with TempDir - -```rust -let temp_dir = TempDir::new()?; -let storage = EntryStorage::new(Some(temp_dir.path().to_path_buf()))?; -``` - -- **Test Isolation**: Each test gets clean directory -- **Automatic Cleanup**: No test artifacts left behind -- **Parallel Safety**: Tests can run concurrently - -## Future Enhancements Enabled - -### Advanced Querying - -- "Show all entries mentioning @alice in the last month" -- "What projects used +rust this quarter?" -- Cross-entry pattern analysis - -### Real-time Features - -- Event streaming for live collaboration -- WebSocket updates for team dashboards -- Conflict resolution through event ordering - -### AI Integration - -- `InsightsGenerated` events from LLM analysis -- Automated tagging based on content patterns -- Trend analysis across historical data - -## Summary - -This event sourcing implementation provides: - -- **Data Integrity**: Immutable events prevent corruption -- **Complete History**: Every change preserved with context -- **Clean Architecture**: Clear separation of concerns -- **Extensible Foundation**: Easy to add new features -- **Rust Safety**: Ownership system prevents data races - -The system demonstrates production-ready event sourcing while solving the practical problem of developer activity tracking with structured, searchable, and historically complete data. diff --git a/docs/plan/01-project-setup.md b/docs/plan/01-project-setup.md new file mode 100644 index 0000000..ba490b2 --- /dev/null +++ b/docs/plan/01-project-setup.md @@ -0,0 +1,179 @@ +# Project Setup and Dependencies + +## Overview + +This document outlines the initial project setup and dependencies needed for the Engineering Journal app. Since you're new to Rust, this includes explanations of why we choose each dependency and how they work together. + +## Current Cargo.toml Analysis + +```toml +[package] +name = "todos" +version = "0.1.0" +edition = "2024" + +[dependencies] +color-eyre = "0.6.5" +ratatui = { version = "0.29.0", features = ["all-widgets"] } +``` + +## Recommended Dependencies + +### Core Dependencies + +#### 1. **ratatui** (Already included) + +```toml +ratatui = { version = "0.29.0", features = ["all-widgets"] } +``` + +- **Purpose**: Terminal UI framework +- **Why**: Industry standard for Rust TUI applications +- **Learning**: Great for understanding Rust's widget patterns + +#### 2. **color-eyre** (Already included) + +```toml +color-eyre = "0.6.5" +``` + +- **Purpose**: Better error handling and reporting +- **Why**: Follows your preference for using libraries instead of custom errors +- **Learning**: Shows Rust's Result pattern in action + +#### 3. **crossterm** (Add this) + +```toml +crossterm = "0.28" +``` + +- **Purpose**: Cross-platform terminal manipulation +- **Why**: ratatui recommends it for input handling +- **Learning**: Good for understanding Rust's cross-platform approach + +#### 4. **chrono** (Add this) + +```toml +chrono = { version = "0.4", features = ["serde"] } +``` + +- **Purpose**: Date and time handling +- **Why**: Essential for YYYYMMDD entry format +- **Learning**: Rust's approach to time handling + +#### 5. **serde** (Add this) + +```toml +serde = { version = "1.0", features = ["derive"] } +``` + +- **Purpose**: Serialization/deserialization +- **Why**: Useful for future config files or data formats +- **Learning**: Rust's powerful derive macros + +#### 6. **dirs** (Add this) + +```toml +dirs = "5.0" +``` + +- **Purpose**: Cross-platform directory paths +- **Why**: Finding ~/.config/engineering-journal directory +- **Learning**: Platform-specific path handling + +#### 7. **tokio** (Optional for now) + +```toml +tokio = { version = "1.0", features = ["full"] } +``` + +- **Purpose**: Async runtime +- **Why**: Might be useful for file I/O later +- **Learning**: Rust's async/await patterns + +## Updated Cargo.toml + +```toml +[package] +name = "engineering-journal" # Rename from "todos" +version = "0.1.0" +edition = "2021" # More stable than 2024 for learning + +[dependencies] +# UI Framework +ratatui = { version = "0.29.0", features = ["all-widgets"] } +crossterm = "0.28" + +# Error Handling +color-eyre = "0.6.5" + +# Date/Time +chrono = { version = "0.4", features = ["serde"] } + +# File System +dirs = "5.0" + +# Serialization (for future config) +serde = { version = "1.0", features = ["derive"] } + +# Optional: Async runtime +# tokio = { version = "1.0", features = ["full"] } +``` + +## Learning Resources + +### Rust Concepts You'll Learn + +1. **Ownership & Borrowing**: Through file handling +2. **Pattern Matching**: With Result and Option +3. **Traits**: ratatui's Widget trait +4. **Error Handling**: color-eyre integration +5. **Modules**: Organizing code into logical units +6. **Lifetimes**: Working with ratatui's rendering + +### Recommended Study Order + +1. **Week 1**: Basic file I/O and error handling +2. **Week 2**: ratatui basics and event handling +3. **Week 3**: Data structures and navigation +4. **Week 4**: Integration and polish + +## Development Environment Setup + +### VS Code Extensions (Recommended) + +```json +{ + "recommendations": [ + "rust-lang.rust-analyzer", + "vadimcn.vscode-lldb", + "serayuzgur.crates" + ] +} +``` + +### Useful Commands + +```bash +# Update dependencies +cargo update + +# Check for issues without building +cargo check + +# Run with better error messages +RUST_BACKTRACE=1 cargo run + +# Format code +cargo fmt + +# Lint code +cargo clippy +``` + +## Next Steps + +1. Update Cargo.toml with new dependencies +2. Rename project from "todos" to "engineering-journal" +3. Set up basic project structure (see 02-architecture.md) +4. Start with simple "Hello World" ratatui app (see 03-implementation-phases.md) diff --git a/docs/plan/02-architecture.md b/docs/plan/02-architecture.md new file mode 100644 index 0000000..502be53 --- /dev/null +++ b/docs/plan/02-architecture.md @@ -0,0 +1,257 @@ +# Project Architecture + +## Overview + +This document outlines the architecture for the Engineering Journal app, designed to be simple and beginner-friendly while following Rust best practices. + +## High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ main.rs │ +│ (Application Entry) │ +└─────────────────────┬───────────────────────────────────────┘ + │ +┌─────────────────────▼───────────────────────────────────────┐ +│ App │ +│ (Main Application State) │ +│ ┌─────────────┬─────────────┬─────────────┬─────────────┐ │ +│ │ UI │ Navigation │ Editor │ Storage │ │ +│ │ Module │ Module │ Module │ Module │ │ +│ └─────────────┴─────────────┴─────────────┴─────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Module Structure + +### File Organization + +``` +src/ +├── main.rs # Entry point +├── app.rs # Main application state and logic +├── ui/ +│ ├── mod.rs # UI module exports +│ ├── layout.rs # Layout and rendering +│ └── events.rs # Event handling +├── navigation/ +│ ├── mod.rs # Navigation module exports +│ ├── tree.rs # Tree structure and navigation +│ └── state.rs # Navigation state management +├── editor/ +│ ├── mod.rs # Editor module exports +│ ├── text.rs # Text editing logic +│ └── modes.rs # Navigation vs Edit modes +├── storage/ +│ ├── mod.rs # Storage module exports +│ ├── filesystem.rs # File operations +│ └── entry.rs # Entry data structure +└── utils/ + ├── mod.rs # Utility module exports + └── date.rs # Date formatting and parsing +``` + +## Core Data Structures + +### 1. Application State + +```rust +pub struct App { + // Application modes + pub mode: AppMode, + pub should_quit: bool, + + // Core components + pub navigation: NavigationState, + pub editor: EditorState, + pub storage: Storage, + + // UI state + pub current_view: ViewMode, +} + +pub enum AppMode { + Navigation, + Edit, + Prompt(PromptType), +} + +pub enum PromptType { + CreateEntry, + DeleteConfirmation, +} +``` + +### 2. Navigation State + +```rust +pub struct NavigationState { + pub tree: EntryTree, + pub selected_path: Option, + pub expanded_paths: HashSet, +} + +pub struct EntryTree { + pub years: BTreeMap, +} + +pub struct Year { + pub year: u32, + pub months: BTreeMap, +} + +pub struct Month { + pub month: u32, + pub entries: BTreeMap, +} +``` + +### 3. Entry Data + +```rust +pub struct Entry { + pub date: NaiveDate, + pub path: PathBuf, + pub content: Option, // Lazy loaded +} + +pub struct EntryPath { + pub year: u32, + pub month: u32, + pub day: u32, +} +``` + +### 4. Editor State + +```rust +pub struct EditorState { + pub content: Vec, // Lines of text + pub cursor_line: usize, + pub cursor_col: usize, + pub dirty: bool, // Has unsaved changes + pub current_entry: Option, +} +``` + +## Key Design Principles + +### 1. Separation of Concerns + +- **UI**: Only handles rendering and layout +- **Navigation**: Manages tree state and selection +- **Editor**: Handles text editing logic +- **Storage**: Manages file I/O operations + +### 2. Error Handling Strategy + +```rust +// Use color-eyre for all error handling +type Result = color_eyre::Result; + +// Example usage +pub fn load_entry(path: &EntryPath) -> Result { + // Implementation with automatic error propagation +} +``` + +### 3. State Management + +- **Single source of truth**: All state in `App` struct +- **Immutable updates**: State changes through methods +- **Event-driven**: UI events trigger state changes + +## Communication Patterns + +### Event Flow + +``` +User Input → UI Events → App State Update → UI Re-render + ↑ ↓ + └─────────── Storage Operations ←───────────┘ +``` + +### Example Event Handling + +```rust +impl App { + pub fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { + match self.mode { + AppMode::Navigation => self.handle_navigation_key(key), + AppMode::Edit => self.handle_editor_key(key), + AppMode::Prompt(prompt_type) => self.handle_prompt_key(key, prompt_type), + } + } +} +``` + +## Rust Learning Opportunities + +### Concepts You'll Practice + +1. **Ownership**: Passing data between modules +2. **Borrowing**: Accessing app state without moving +3. **Pattern Matching**: Handling different modes and events +4. **Error Handling**: Using Result throughout +5. **Traits**: Implementing custom behaviors +6. **Modules**: Organizing code logically + +### Starting Simple + +- Begin with basic structs and enums +- Add complexity gradually +- Use `#[derive(Debug)]` for easy debugging +- Start with synchronous file I/O + +## Dependencies Interaction + +### ratatui Integration + +```rust +// UI rendering with ratatui +impl Widget for NavigationTree { + fn render(self, area: Rect, buf: &mut Buffer) { + // Render tree using ratatui widgets + } +} +``` + +### chrono Integration + +```rust +// Date handling with chrono +pub fn parse_entry_date(date_str: &str) -> Result { + NaiveDate::parse_from_str(date_str, "%Y%m%d") + .map_err(|e| eyre::eyre!("Invalid date format: {}", e)) +} +``` + +## Testing Strategy + +### Unit Tests + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_entry_path_creation() { + let path = EntryPath::new(2025, 3, 15); + assert_eq!(path.to_string(), "20250315"); + } +} +``` + +### Integration Tests + +- Test file operations with temporary directories +- Test UI components with mock events +- Test complete workflows + +## Next Steps + +1. Set up basic project structure +2. Implement core data structures +3. Start with navigation module (see 03-implementation-phases.md) +4. Add UI layer progressively diff --git a/docs/plan/03-implementation-phases.md b/docs/plan/03-implementation-phases.md new file mode 100644 index 0000000..ffcf1a9 --- /dev/null +++ b/docs/plan/03-implementation-phases.md @@ -0,0 +1,413 @@ +# Implementation Phases + +## Overview + +This document breaks down the implementation into manageable phases, designed for someone learning Rust. Each phase builds on the previous one and introduces new Rust concepts gradually. + +## Phase 1: Foundation (Week 1-2) + +**Goal**: Basic project structure and file operations +**Rust Concepts**: Structs, enums, basic error handling + +### Milestones + +#### 1.1 Project Setup + +- [ ] Update Cargo.toml with dependencies +- [ ] Create basic module structure +- [ ] Set up main.rs with basic color-eyre setup +- [ ] Create simple "Hello World" with ratatui + +**Code Example**: + +```rust +// main.rs +use color_eyre::Result; + +fn main() -> Result<()> { + color_eyre::install()?; + println!("Engineering Journal v0.1.0"); + Ok(()) +} +``` + +#### 1.2 Basic Data Structures + +- [ ] Define `Entry`, `EntryPath` structs +- [ ] Implement basic date parsing with chrono +- [ ] Create directory structure utilities +- [ ] Test basic file operations + +**Learning Focus**: + +- Rust structs and implementation blocks +- Using external crates (chrono) +- Basic error handling with `?` operator + +#### 1.3 File System Operations + +- [ ] Create entry directory structure +- [ ] Implement basic CRUD operations for entries +- [ ] Handle file I/O errors gracefully +- [ ] Add unit tests for file operations + +**Code Example**: + +```rust +// storage/entry.rs +use chrono::NaiveDate; +use std::path::PathBuf; +use color_eyre::Result; + +#[derive(Debug, Clone)] +pub struct Entry { + pub date: NaiveDate, + pub path: PathBuf, + pub content: Option, +} + +impl Entry { + pub fn new(date: NaiveDate) -> Self { + let path = Self::path_for_date(&date); + Self { + date, + path, + content: None, + } + } + + pub fn load_content(&mut self) -> Result<&str> { + if self.content.is_none() { + self.content = Some(std::fs::read_to_string(&self.path)?); + } + Ok(self.content.as_ref().unwrap()) + } +} +``` + +**Deliverable**: CLI that can create, read, and list entries (no UI yet) + +## Phase 2: Basic UI (Week 2-3) + +**Goal**: Simple ratatui interface with basic navigation +**Rust Concepts**: Traits, pattern matching, lifetimes + +### Milestones + +#### 2.1 Basic ratatui Setup + +- [ ] Create terminal initialization/cleanup +- [ ] Set up event loop +- [ ] Handle basic keyboard input +- [ ] Implement graceful shutdown + +**Code Example**: + +```rust +// main.rs +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + Terminal, +}; + +fn main() -> Result<()> { + color_eyre::install()?; + + // Setup terminal + enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Run app + let result = run_app(&mut terminal); + + // Cleanup + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + result +} +``` + +#### 2.2 Basic App State + +- [ ] Create `App` struct with basic state +- [ ] Implement mode switching (Navigation vs Edit) +- [ ] Add basic event handling +- [ ] Create simple UI layout + +**Learning Focus**: + +- Ownership and borrowing with app state +- Pattern matching on events and modes +- Basic ratatui widget usage + +#### 2.3 Simple List View + +- [ ] Display list of existing entries +- [ ] Implement basic navigation (j/k or up/down) +- [ ] Show selected entry content +- [ ] Add status bar with key hints + +**Deliverable**: Basic TUI that shows entries and allows navigation + +## Phase 3: Navigation System (Week 3-4) + +**Goal**: Hierarchical tree navigation +**Rust Concepts**: Collections, iterators, more complex state management + +### Milestones + +#### 3.1 Tree Data Structure + +- [ ] Build hierarchical entry tree +- [ ] Implement tree traversal logic +- [ ] Add expand/collapse functionality +- [ ] Handle empty months/years gracefully + +**Code Example**: + +```rust +// navigation/tree.rs +use std::collections::BTreeMap; + +#[derive(Debug)] +pub struct EntryTree { + pub years: BTreeMap, +} + +impl EntryTree { + pub fn from_entries(entries: Vec) -> Self { + let mut tree = Self { + years: BTreeMap::new(), + }; + + for entry in entries { + tree.insert_entry(entry); + } + + tree + } + + pub fn navigate(&self, direction: NavDirection) -> Option { + // Tree navigation logic + } +} +``` + +#### 3.2 Tree Navigation UI + +- [ ] Render tree with expand/collapse indicators +- [ ] Implement hjkl navigation +- [ ] Add breadcrumb navigation +- [ ] Handle tree state updates + +**Learning Focus**: + +- Working with BTreeMap and other collections +- Iterator patterns in Rust +- Mutable vs immutable references + +#### 3.3 Enhanced Key Bindings + +- [ ] Add all vim-style navigation keys +- [ ] Implement arrow key alternatives +- [ ] Add space for expand/collapse +- [ ] Handle edge cases (empty tree, etc.) + +**Deliverable**: Full tree navigation working with real file system + +## Phase 4: Editor Integration (Week 4-5) + +**Goal**: Built-in text editor +**Rust Concepts**: String manipulation, more complex state + +### Milestones + +#### 4.1 Basic Text Editor + +- [ ] Create editor state management +- [ ] Implement cursor movement +- [ ] Add basic text insertion/deletion +- [ ] Handle line-based editing + +**Code Example**: + +```rust +// editor/text.rs +#[derive(Debug)] +pub struct EditorState { + pub lines: Vec, + pub cursor_line: usize, + pub cursor_col: usize, + pub dirty: bool, +} + +impl EditorState { + pub fn insert_char(&mut self, ch: char) { + if self.cursor_line < self.lines.len() { + let line = &mut self.lines[self.cursor_line]; + line.insert(self.cursor_col, ch); + self.cursor_col += 1; + self.dirty = true; + } + } + + pub fn delete_char(&mut self) { + // Implementation + } +} +``` + +#### 4.2 Mode Switching + +- [ ] Implement smooth navigation ↔ edit mode switching +- [ ] Handle ESC key properly +- [ ] Save on Ctrl+S +- [ ] Show mode in status bar + +#### 4.3 File Integration + +- [ ] Load entry content into editor +- [ ] Save editor content to file +- [ ] Handle unsaved changes warnings +- [ ] Create new entries from editor + +**Deliverable**: Working editor that can modify entry content + +## Phase 5: Entry Management (Week 5-6) + +**Goal**: Full CRUD operations +**Rust Concepts**: Advanced error handling, user input + +### Milestones + +#### 5.1 Entry Creation + +- [ ] Implement `n` key for today's entry +- [ ] Add `c` key with date prompt +- [ ] Handle date input validation +- [ ] Create file and directory structure + +#### 5.2 Entry Deletion + +- [ ] Add `d` key for deletion +- [ ] Implement confirmation prompt +- [ ] Handle file deletion +- [ ] Update tree state after deletion + +#### 5.3 Prompt System + +- [ ] Create reusable prompt widget +- [ ] Handle text input in prompts +- [ ] Add validation and error messages +- [ ] Smooth prompt → navigation transitions + +**Deliverable**: Complete CRUD functionality + +## Phase 6: Polish and Features (Week 6+) + +**Goal**: Production-ready application +**Rust Concepts**: Performance, error handling, testing + +### Milestones + +#### 6.1 Error Handling + +- [ ] Improve error messages +- [ ] Handle edge cases gracefully +- [ ] Add logging for debugging +- [ ] Test error scenarios + +#### 6.2 Performance + +- [ ] Lazy load entry content +- [ ] Optimize tree operations +- [ ] Handle large numbers of entries +- [ ] Add benchmarks + +#### 6.3 Testing + +- [ ] Unit tests for all modules +- [ ] Integration tests +- [ ] Test with real data +- [ ] Performance tests + +#### 6.4 Documentation + +- [ ] Add inline documentation +- [ ] Create user manual +- [ ] Document architecture decisions +- [ ] Add examples + +**Deliverable**: Production-ready application + +## Learning Checkpoints + +### After Phase 1 + +- [ ] Comfortable with basic Rust syntax +- [ ] Understanding ownership basics +- [ ] Can handle simple errors with `?` +- [ ] Know how to work with external crates + +### After Phase 3 + +- [ ] Understand borrowing and lifetimes +- [ ] Can work with complex data structures +- [ ] Comfortable with pattern matching +- [ ] Understand module organization + +### After Phase 6 + +- [ ] Proficient with Rust error handling +- [ ] Can design and implement complex applications +- [ ] Understand performance considerations +- [ ] Can write tests and documentation + +## Daily Development Workflow + +```bash +# Start each day +cargo check # Quick syntax check +cargo clippy # Linting +cargo test # Run tests + +# During development +cargo run # Test your changes +RUST_BACKTRACE=1 cargo run # Debug issues + +# End of day +cargo fmt # Format code +git add . && git commit -m "Phase X.Y: milestone description" +``` + +## Getting Help + +### When Stuck + +1. **Compiler errors**: Read them carefully, Rust's compiler is very helpful +2. **Ownership issues**: Start with cloning, optimize later +3. **ratatui questions**: Check the examples in their repository +4. **General Rust**: The Rust Book (https://doc.rust-lang.org/book/) + +### Useful Resources + +- [The Rust Book](https://doc.rust-lang.org/book/) +- [Rust by Example](https://doc.rust-lang.org/rust-by-example/) +- [ratatui examples](https://github.com/ratatui-org/ratatui/tree/main/examples) +- [color-eyre documentation](https://docs.rs/color-eyre/) + +Remember: Don't try to make everything perfect in the first implementation. Get it working, then refactor! diff --git a/docs/plan/04-rust-learning-guide.md b/docs/plan/04-rust-learning-guide.md new file mode 100644 index 0000000..efabb23 --- /dev/null +++ b/docs/plan/04-rust-learning-guide.md @@ -0,0 +1,515 @@ +# Rust Learning Guide + +## Overview + +This guide is specifically tailored for implementing the Engineering Journal app. It focuses on the Rust concepts you'll encounter and provides practical examples from our project. + +## Core Rust Concepts for This Project + +### 1. Ownership and Borrowing + +#### The Problem + +Rust ensures memory safety without garbage collection through ownership rules. + +#### In Our Project + +```rust +// ❌ This won't work - value moved +let app = App::new(); +handle_ui(app); +handle_events(app); // Error: app was moved + +// ✅ This works - borrowing +let mut app = App::new(); +handle_ui(&app); // Immutable borrow +handle_events(&mut app); // Mutable borrow +``` + +#### Practical Example: App State + +```rust +impl App { + // Takes ownership of self, returns new state + pub fn transition_to_edit_mode(mut self, entry_path: EntryPath) -> Self { + self.mode = AppMode::Edit; + self.editor.load_entry(entry_path); + self + } + + // Borrows self mutably to modify in place + pub fn handle_navigation(&mut self, direction: NavDirection) { + self.navigation.move_selection(direction); + } + + // Borrows self immutably to read data + pub fn current_entry(&self) -> Option<&Entry> { + self.navigation.selected_entry() + } +} +``` + +#### Learning Strategy + +1. **Start with cloning**: Use `.clone()` liberally at first +2. **Understand the borrow checker**: Read error messages carefully +3. **Refactor gradually**: Remove unnecessary clones as you learn + +### 2. Error Handling + +#### The Rust Way + +Rust uses `Result` for recoverable errors and `panic!` for unrecoverable ones. + +#### In Our Project + +```rust +use color_eyre::Result; // This is Result + +// File operations +pub fn load_entry(path: &Path) -> Result { + std::fs::read_to_string(path) + .map_err(|e| eyre::eyre!("Failed to load entry: {}", e)) +} + +// Using the ? operator for error propagation +pub fn save_entry(entry: &Entry) -> Result<()> { + let dir = entry.path.parent().unwrap(); + std::fs::create_dir_all(dir)?; // Propagates error if fails + std::fs::write(&entry.path, &entry.content)?; // Propagates error if fails + Ok(()) // Success +} + +// Handling errors in main application +pub fn handle_save_request(&mut self) -> Result<()> { + match self.editor.current_entry() { + Some(entry) => { + self.storage.save_entry(entry)?; + self.editor.mark_clean(); + Ok(()) + } + None => Err(eyre::eyre!("No entry to save")), + } +} +``` + +#### Common Patterns + +```rust +// Converting between error types +let date = NaiveDate::parse_from_str(date_str, "%Y%m%d") + .map_err(|e| eyre::eyre!("Invalid date format: {}", e))?; + +// Providing context +std::fs::create_dir_all(&dir) + .with_context(|| format!("Failed to create directory: {:?}", dir))?; + +// Handling Options +let entry = self.entries.get(&date) + .ok_or_else(|| eyre::eyre!("Entry not found for date: {}", date))?; +``` + +### 3. Pattern Matching + +#### Basic Matching + +```rust +// Handling different key events +match key.code { + KeyCode::Char('q') => self.should_quit = true, + KeyCode::Char('j') | KeyCode::Down => self.navigation.move_down(), + KeyCode::Char('k') | KeyCode::Up => self.navigation.move_up(), + KeyCode::Enter => self.open_selected_entry()?, + _ => {} // Do nothing for other keys +} + +// Matching on app modes +match self.mode { + AppMode::Navigation => self.handle_navigation_key(key), + AppMode::Edit => self.handle_editor_key(key), + AppMode::Prompt(PromptType::CreateEntry) => self.handle_create_prompt(key), + AppMode::Prompt(PromptType::DeleteConfirmation) => self.handle_delete_prompt(key), +} +``` + +#### Advanced Patterns + +```rust +// Matching with guards +match (self.mode, key.code) { + (AppMode::Edit, KeyCode::Esc) => { + if self.editor.is_dirty() { + self.show_unsaved_changes_prompt(); + } else { + self.mode = AppMode::Navigation; + } + } + (AppMode::Navigation, KeyCode::Char('d')) if self.navigation.has_selection() => { + self.show_delete_confirmation(); + } + _ => {} +} + +// Destructuring structs +match &self.navigation.selected_entry() { + Some(Entry { date, path, .. }) => { + println!("Selected entry: {} at {:?}", date, path); + } + None => println!("No entry selected"), +} +``` + +### 4. Traits + +#### Using Existing Traits + +```rust +// Debug trait for easy printing +#[derive(Debug)] +pub struct Entry { + pub date: NaiveDate, + pub content: String, +} + +// Clone trait for duplicating data +#[derive(Clone)] +pub struct NavigationState { + pub selected: Option, +} + +// Default trait for initial states +#[derive(Default)] +pub struct EditorState { + pub lines: Vec, + pub cursor_line: usize, + pub cursor_col: usize, +} +``` + +#### Implementing Custom Traits + +```rust +// ratatui Widget trait +use ratatui::{widgets::Widget, buffer::Buffer, layout::Rect}; + +impl Widget for EntryList { + fn render(self, area: Rect, buf: &mut Buffer) { + for (i, entry) in self.entries.iter().enumerate() { + let y = area.y + i as u16; + if y < area.bottom() { + buf.set_string(area.x, y, &entry.title, Style::default()); + } + } + } +} + +// Custom trait for our navigation +trait Navigable { + fn move_up(&mut self); + fn move_down(&mut self); + fn select_current(&self) -> Option<&Entry>; +} + +impl Navigable for EntryTree { + fn move_up(&mut self) { + // Implementation + } + // ... other methods +} +``` + +### 5. Collections + +#### Common Collections in Our Project + +```rust +use std::collections::{HashMap, BTreeMap, HashSet}; + +pub struct NavigationState { + // BTreeMap for sorted years/months + pub tree: BTreeMap>>, + + // HashSet for tracking expanded nodes + pub expanded: HashSet, + + // Vec for ordered lists + pub navigation_stack: Vec, +} + +// Working with collections +impl NavigationState { + pub fn add_entry(&mut self, entry: Entry) { + let year = entry.date.year() as u32; + let month = entry.date.month(); + + self.tree + .entry(year) + .or_insert_with(BTreeMap::new) + .entry(month) + .or_insert_with(Vec::new) + .push(entry); + } + + pub fn find_entries_in_month(&self, year: u32, month: u32) -> Option<&Vec> { + self.tree.get(&year)?.get(&month) + } +} +``` + +#### Iterator Patterns + +```rust +// Functional programming with iterators +impl EntryTree { + pub fn all_entries(&self) -> impl Iterator { + self.tree + .values() // Get all years + .flat_map(|year| year.values()) // Get all months + .flat_map(|entries| entries.iter()) // Get all entries + } + + pub fn entries_in_year(&self, year: u32) -> impl Iterator { + self.tree + .get(&year) + .into_iter() + .flat_map(|months| months.values()) + .flat_map(|entries| entries.iter()) + } + + pub fn find_entry(&self, date: &NaiveDate) -> Option<&Entry> { + self.all_entries() + .find(|entry| entry.date == *date) + } +} +``` + +### 6. Modules and Visibility + +#### Module Structure + +```rust +// src/lib.rs or src/main.rs +pub mod app; +pub mod ui; +pub mod navigation; +pub mod editor; +pub mod storage; + +// src/navigation/mod.rs +pub mod tree; +pub mod state; + +pub use tree::EntryTree; +pub use state::NavigationState; + +// src/navigation/tree.rs +use crate::storage::Entry; // Use from another module + +pub struct EntryTree { + // pub means public to users of this module + pub years: BTreeMap, + + // private field, only accessible within this module + cache: HashMap, +} + +impl EntryTree { + // Public method + pub fn new() -> Self { /* ... */ } + + // Private method + fn rebuild_cache(&mut self) { /* ... */ } +} +``` + +### 7. Lifetimes (You'll encounter these with ratatui) + +#### Basic Lifetime Annotations + +```rust +// ratatui widgets often need lifetimes +pub struct EntryWidget<'a> { + entry: &'a Entry, +} + +impl<'a> Widget for EntryWidget<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + // self.entry is valid for the lifetime 'a + buf.set_string(area.x, area.y, &self.entry.title, Style::default()); + } +} + +// Using the widget +let entry = Entry::new(/* ... */); +let widget = EntryWidget { entry: &entry }; +// widget can only be used while entry is alive +``` + +## Practical Learning Exercises + +### Exercise 1: Basic Entry Management + +```rust +// Create a simple entry manager +struct EntryManager { + entries: Vec, +} + +impl EntryManager { + pub fn new() -> Self { /* implement */ } + pub fn add_entry(&mut self, entry: Entry) { /* implement */ } + pub fn find_entry(&self, date: &NaiveDate) -> Option<&Entry> { /* implement */ } + pub fn remove_entry(&mut self, date: &NaiveDate) -> Option { /* implement */ } +} + +// Test your implementation +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_entry_management() { + let mut manager = EntryManager::new(); + let entry = Entry::new(NaiveDate::from_ymd(2025, 3, 15)); + + manager.add_entry(entry); + assert!(manager.find_entry(&NaiveDate::from_ymd(2025, 3, 15)).is_some()); + } +} +``` + +### Exercise 2: Event Handling + +```rust +#[derive(Debug)] +pub enum AppEvent { + KeyPress(char), + NavigateUp, + NavigateDown, + EnterEditMode, + SaveEntry, + Quit, +} + +pub struct EventHandler { + pub should_quit: bool, +} + +impl EventHandler { + pub fn handle_event(&mut self, event: AppEvent) -> Result<()> { + match event { + AppEvent::Quit => self.should_quit = true, + AppEvent::KeyPress('q') => self.should_quit = true, + // Implement other events + _ => {} + } + Ok(()) + } +} +``` + +## Common Pitfalls and Solutions + +### 1. Fighting the Borrow Checker + +```rust +// ❌ Borrow checker error +let entry = &mut self.entries[0]; +self.update_status(); // Error: can't borrow self while entry is borrowed + +// ✅ Solution: limit scope of borrow +{ + let entry = &mut self.entries[0]; + entry.modify(); +} // borrow ends here +self.update_status(); // Now this works +``` + +### 2. String vs &str + +```rust +// Use String for owned data +pub struct Entry { + pub content: String, // Owned +} + +// Use &str for borrowed data +pub fn process_content(content: &str) { // Borrowed + // Work with content +} + +// Converting between them +let owned: String = "hello".to_string(); +let borrowed: &str = &owned; +let owned_again: String = borrowed.to_owned(); +``` + +### 3. Option and Result Handling + +```rust +// Chaining operations +let result = self.navigation + .selected_entry() // Returns Option<&Entry> + .ok_or_else(|| eyre::eyre!("No entry selected"))? // Convert to Result + .load_content()?; // Returns Result + +// Using map and and_then +let content_length = self.navigation + .selected_entry() + .map(|entry| entry.content.len()) + .unwrap_or(0); +``` + +## Debugging Tips + +### 1. Use Debug Trait + +```rust +#[derive(Debug)] +pub struct Entry { + pub date: NaiveDate, + pub content: String, +} + +// Then you can: +println!("{:?}", entry); +dbg!(&entry); // Even better for debugging +``` + +### 2. Use the Compiler + +```rust +// Let the compiler infer types, then ask it what they are +let result = some_complex_operation(); +let _: () = result; // Compiler will tell you the actual type +``` + +### 3. Start Simple + +```rust +// Instead of this complex version immediately: +pub fn handle_key_event(&mut self, event: KeyEvent) -> Result<()> { + match (self.mode, event.code, event.modifiers) { + // Complex pattern matching... + } +} + +// Start with this: +pub fn handle_key_event(&mut self, event: KeyEvent) -> Result<()> { + match event.code { + KeyCode::Char('q') => self.should_quit = true, + _ => {} + } + Ok(()) +} +``` + +## Next Steps + +1. **Read through this guide** to get familiar with concepts +2. **Start with Phase 1** of the implementation plan +3. **Don't worry about perfection** - focus on getting things working +4. **Use the Rust compiler** as your learning partner - its error messages are excellent +5. **Test frequently** - small, working pieces are better than complex, broken ones + +Remember: Every Rust developer has fought the borrow checker. It gets easier with practice! diff --git a/docs/plan/05-ui-implementation.md b/docs/plan/05-ui-implementation.md new file mode 100644 index 0000000..98e6fca --- /dev/null +++ b/docs/plan/05-ui-implementation.md @@ -0,0 +1,699 @@ +# UI/UX Implementation Guide + +## Overview + +This document focuses on the user interface implementation using ratatui, with practical examples and learning guidance for building the Engineering Journal's TUI. + +## ratatui Fundamentals + +### Core Concepts + +#### 1. Terminal Setup + +```rust +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout}, + widgets::{Block, Borders, List, ListItem, Paragraph}, + Terminal, +}; + +// Terminal setup and cleanup +pub fn setup_terminal() -> Result>> { + enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + Ok(Terminal::new(backend)?) +} + +pub fn cleanup_terminal(terminal: &mut Terminal>) -> Result<()> { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + Ok(()) +} +``` + +#### 2. Event Loop Pattern + +```rust +pub fn run_app(terminal: &mut Terminal>, mut app: App) -> Result<()> { + loop { + // Render the UI + terminal.draw(|f| ui::render(&mut app, f))?; + + // Handle events + if event::poll(std::time::Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + app.handle_key_event(key)?; + + if app.should_quit { + break; + } + } + } + } + Ok(()) +} +``` + +## Layout System + +### Basic Layout + +```rust +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + Frame, +}; + +pub fn render(app: &mut App, frame: &mut Frame) { + // Main layout: horizontal split + let main_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), // Tree view (left) + Constraint::Percentage(70), // Content view (right) + ]) + .split(frame.size()); + + // Render tree in left pane + render_tree_view(app, frame, main_layout[0]); + + // Render content in right pane + render_content_view(app, frame, main_layout[1]); + + // Status bar at bottom + render_status_bar(app, frame); +} +``` + +### Advanced Layout with Status Bar + +```rust +pub fn render(app: &mut App, frame: &mut Frame) { + // Vertical split: main area + status bar + let root_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), // Main area (at least 3 lines) + Constraint::Length(1), // Status bar (exactly 1 line) + ]) + .split(frame.size()); + + // Horizontal split for main area + let main_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), // Tree view + Constraint::Percentage(70), // Content view + ]) + .split(root_layout[0]); + + render_tree_view(app, frame, main_layout[0]); + render_content_view(app, frame, main_layout[1]); + render_status_bar(app, frame, root_layout[1]); +} +``` + +## Tree View Implementation + +### Data Structure for UI + +```rust +use ratatui::widgets::{List, ListItem, ListState}; + +pub struct TreeViewState { + pub items: Vec, + pub list_state: ListState, + pub expanded: HashSet, +} + +#[derive(Debug, Clone)] +pub struct TreeItem { + pub label: String, + pub path: String, + pub level: usize, + pub item_type: TreeItemType, + pub has_children: bool, +} + +#[derive(Debug, Clone)] +pub enum TreeItemType { + Year(u32), + Month(u32), + Entry(NaiveDate), +} +``` + +### Building Tree Items + +```rust +impl TreeViewState { + pub fn from_entry_tree(tree: &EntryTree) -> Self { + let mut items = Vec::new(); + let expanded = HashSet::new(); + + for (year, months) in &tree.years { + // Add year item + items.push(TreeItem { + label: format!("▼ {}", year), + path: year.to_string(), + level: 0, + item_type: TreeItemType::Year(*year), + has_children: !months.is_empty(), + }); + + // Add month items if year is expanded + if expanded.contains(&year.to_string()) { + for (month, entries) in months { + let month_name = month_name(*month); + let month_path = format!("{}/{}", year, month); + + items.push(TreeItem { + label: format!(" ▼ {}", month_name), + path: month_path.clone(), + level: 1, + item_type: TreeItemType::Month(*month), + has_children: !entries.is_empty(), + }); + + // Add entry items if month is expanded + if expanded.contains(&month_path) { + for entry in entries { + items.push(TreeItem { + label: format!(" • {}", entry.date.day()), + path: format!("{}/{}", month_path, entry.date.day()), + level: 2, + item_type: TreeItemType::Entry(entry.date), + has_children: false, + }); + } + } + } + } + } + + let mut list_state = ListState::default(); + if !items.is_empty() { + list_state.select(Some(0)); + } + + Self { + items, + list_state, + expanded, + } + } +} + +fn month_name(month: u32) -> &'static str { + match month { + 1 => "January", 2 => "February", 3 => "March", + 4 => "April", 5 => "May", 6 => "June", + 7 => "July", 8 => "August", 9 => "September", + 10 => "October", 11 => "November", 12 => "December", + _ => "Unknown", + } +} +``` + +### Rendering Tree View + +```rust +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem}, +}; + +pub fn render_tree_view(app: &mut App, frame: &mut Frame, area: Rect) { + let items: Vec = app.tree_view + .items + .iter() + .enumerate() + .map(|(i, item)| { + let style = if Some(i) == app.tree_view.list_state.selected() { + Style::default() + .bg(Color::Blue) + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + ListItem::new(Line::from(Span::styled(&item.label, style))) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .title("Navigation") + .borders(Borders::ALL) + ) + .highlight_style( + Style::default() + .bg(Color::Blue) + .add_modifier(Modifier::BOLD) + ); + + frame.render_stateful_widget(list, area, &mut app.tree_view.list_state); +} +``` + +## Content View Implementation + +### Text Editor Widget + +```rust +use ratatui::{ + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +pub fn render_content_view(app: &mut App, frame: &mut Frame, area: Rect) { + match app.mode { + AppMode::Navigation => render_entry_preview(app, frame, area), + AppMode::Edit => render_editor(app, frame, area), + AppMode::Prompt(_) => render_prompt(app, frame, area), + } +} + +fn render_entry_preview(app: &App, frame: &mut Frame, area: Rect) { + let content = if let Some(entry) = app.navigation.selected_entry() { + entry.content.clone().unwrap_or_else(|| "Loading...".to_string()) + } else { + "No entry selected".to_string() + }; + + let paragraph = Paragraph::new(content) + .block( + Block::default() + .title("Entry Content") + .borders(Borders::ALL) + ) + .wrap(Wrap { trim: true }); + + frame.render_widget(paragraph, area); +} + +fn render_editor(app: &App, frame: &mut Frame, area: Rect) { + let title = format!( + "Editor - {} {}", + app.editor.current_entry + .map(|e| e.to_string()) + .unwrap_or_else(|| "New Entry".to_string()), + if app.editor.dirty { "*" } else { "" } + ); + + // Create lines with cursor indication + let lines: Vec = app.editor.lines + .iter() + .enumerate() + .map(|(line_idx, line_content)| { + if line_idx == app.editor.cursor_line { + // Show cursor position + let (before, after) = line_content.split_at( + app.editor.cursor_col.min(line_content.len()) + ); + Line::from(vec![ + Span::raw(before), + Span::styled("█", Style::default().bg(Color::White).fg(Color::Black)), + Span::raw(after), + ]) + } else { + Line::from(line_content.clone()) + } + }) + .collect(); + + let paragraph = Paragraph::new(lines) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + ) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); +} +``` + +## Status Bar Implementation + +### Dynamic Status Bar + +```rust +pub fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { + let status_text = match app.mode { + AppMode::Navigation => { + "[hjkl/↑↓←→] Nav [Enter] Edit [n] New [c] Create [d] Del [q] Quit" + } + AppMode::Edit => { + "[ESC] Nav Mode [Ctrl+S] Save [Ctrl+L] Refresh" + } + AppMode::Prompt(PromptType::CreateEntry) => { + "Enter date (YYYYMMDD) or press ESC to cancel" + } + AppMode::Prompt(PromptType::DeleteConfirmation) => { + "Delete entry? (y/N)" + } + }; + + let paragraph = Paragraph::new(status_text) + .style(Style::default().bg(Color::Blue).fg(Color::White)); + + frame.render_widget(paragraph, area); +} +``` + +## Navigation Implementation + +### Key Event Handling + +```rust +impl App { + pub fn handle_navigation_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + // Navigation + KeyCode::Char('j') | KeyCode::Down => self.tree_view.move_down(), + KeyCode::Char('k') | KeyCode::Up => self.tree_view.move_up(), + KeyCode::Char('h') | KeyCode::Left => self.tree_view.collapse_current(), + KeyCode::Char('l') | KeyCode::Right => self.tree_view.expand_current(), + + // Actions + KeyCode::Enter => self.open_selected_entry()?, + KeyCode::Char(' ') => self.tree_view.toggle_current(), + KeyCode::Char('n') => self.create_today_entry()?, + KeyCode::Char('c') => self.prompt_create_entry(), + KeyCode::Char('d') => self.prompt_delete_entry(), + KeyCode::Char('q') => self.should_quit = true, + + _ => {} + } + Ok(()) + } +} + +impl TreeViewState { + pub fn move_down(&mut self) { + let i = match self.list_state.selected() { + Some(i) => { + if i >= self.items.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.list_state.select(Some(i)); + } + + pub fn move_up(&mut self) { + let i = match self.list_state.selected() { + Some(i) => { + if i == 0 { + self.items.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.list_state.select(Some(i)); + } + + pub fn toggle_current(&mut self) { + if let Some(i) = self.list_state.selected() { + if let Some(item) = self.items.get(i) { + if item.has_children { + if self.expanded.contains(&item.path) { + self.expanded.remove(&item.path); + } else { + self.expanded.insert(item.path.clone()); + } + // Rebuild items with new expanded state + // This would require access to the original tree data + } + } + } + } +} +``` + +## Prompt System Implementation + +### Generic Prompt Widget + +```rust +pub struct PromptState { + pub message: String, + pub input: String, + pub cursor_pos: usize, +} + +impl PromptState { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + input: String::new(), + cursor_pos: 0, + } + } + + pub fn handle_key(&mut self, key: KeyEvent) -> PromptResult { + match key.code { + KeyCode::Enter => PromptResult::Submit(self.input.clone()), + KeyCode::Esc => PromptResult::Cancel, + KeyCode::Backspace => { + if self.cursor_pos > 0 { + self.input.remove(self.cursor_pos - 1); + self.cursor_pos -= 1; + } + PromptResult::Continue + } + KeyCode::Char(c) => { + self.input.insert(self.cursor_pos, c); + self.cursor_pos += 1; + PromptResult::Continue + } + KeyCode::Left => { + if self.cursor_pos > 0 { + self.cursor_pos -= 1; + } + PromptResult::Continue + } + KeyCode::Right => { + if self.cursor_pos < self.input.len() { + self.cursor_pos += 1; + } + PromptResult::Continue + } + _ => PromptResult::Continue, + } + } +} + +pub enum PromptResult { + Continue, + Submit(String), + Cancel, +} + +fn render_prompt(app: &App, frame: &mut Frame, area: Rect) { + if let Some(prompt) = &app.prompt_state { + // Center the prompt + let popup_area = centered_rect(60, 20, area); + + // Clear background + frame.render_widget( + Block::default().style(Style::default().bg(Color::Black)), + popup_area, + ); + + // Render prompt + let input_with_cursor = format!( + "{}{}{}", + &prompt.input[..prompt.cursor_pos], + "█", + &prompt.input[prompt.cursor_pos..] + ); + + let paragraph = Paragraph::new(vec![ + Line::from(prompt.message.clone()), + Line::from(""), + Line::from(input_with_cursor), + ]) + .block( + Block::default() + .title("Input") + .borders(Borders::ALL) + ); + + frame.render_widget(paragraph, popup_area); + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} +``` + +## Performance Considerations + +### Efficient Rendering + +```rust +// Only rebuild tree when data changes +impl App { + pub fn refresh_tree_view(&mut self) { + if self.tree_dirty { + self.tree_view = TreeViewState::from_entry_tree(&self.storage.tree); + self.tree_dirty = false; + } + } + + pub fn mark_tree_dirty(&mut self) { + self.tree_dirty = true; + } +} + +// Lazy loading for large entries +impl Entry { + pub fn load_preview(&mut self) -> Result { + if self.preview.is_none() { + let content = std::fs::read_to_string(&self.path)?; + self.preview = Some(content.lines().take(10).collect::>().join("\n")); + } + Ok(self.preview.as_ref().unwrap().clone()) + } +} +``` + +## Testing UI Components + +### Unit Testing Widgets + +```rust +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + #[test] + fn test_tree_view_navigation() { + let mut tree_view = TreeViewState::new(); + tree_view.items = vec![ + TreeItem { /* ... */ }, + TreeItem { /* ... */ }, + ]; + + tree_view.move_down(); + assert_eq!(tree_view.list_state.selected(), Some(1)); + + tree_view.move_down(); + assert_eq!(tree_view.list_state.selected(), Some(0)); // Wraps around + } + + #[test] + fn test_prompt_input() { + let mut prompt = PromptState::new("Enter date:"); + + let result = prompt.handle_key(KeyEvent::from(KeyCode::Char('2'))); + assert!(matches!(result, PromptResult::Continue)); + assert_eq!(prompt.input, "2"); + + let result = prompt.handle_key(KeyEvent::from(KeyCode::Enter)); + assert!(matches!(result, PromptResult::Submit(_))); + } +} +``` + +## Common UI Patterns + +### Modal Dialogs + +```rust +pub fn render_with_modal(app: &App, frame: &mut Frame) { + // Render main UI + render_main_ui(app, frame); + + // Render modal on top if active + if let Some(modal) = &app.active_modal { + render_modal(modal, frame, frame.size()); + } +} + +fn render_modal(modal: &Modal, frame: &mut Frame, area: Rect) { + // Semi-transparent background + let background = Block::default() + .style(Style::default().bg(Color::Black)); + frame.render_widget(background, area); + + // Modal content + let modal_area = centered_rect(50, 30, area); + // ... render modal content +} +``` + +### Scrollable Content + +```rust +pub struct ScrollableContent { + pub content: Vec, + pub scroll_offset: usize, + pub visible_lines: usize, +} + +impl ScrollableContent { + pub fn scroll_up(&mut self) { + if self.scroll_offset > 0 { + self.scroll_offset -= 1; + } + } + + pub fn scroll_down(&mut self) { + let max_scroll = self.content.len().saturating_sub(self.visible_lines); + if self.scroll_offset < max_scroll { + self.scroll_offset += 1; + } + } + + pub fn visible_content(&self) -> &[String] { + let start = self.scroll_offset; + let end = (start + self.visible_lines).min(self.content.len()); + &self.content[start..end] + } +} +``` + +This guide provides the foundation for implementing a polished TUI with ratatui. Focus on getting the basic layout and navigation working first, then add polish and advanced features gradually. diff --git a/docs/plan/06-testing-strategy.md b/docs/plan/06-testing-strategy.md new file mode 100644 index 0000000..91300f9 --- /dev/null +++ b/docs/plan/06-testing-strategy.md @@ -0,0 +1,664 @@ +# Testing Strategy + +## Overview + +This document outlines a comprehensive testing strategy for the Engineering Journal app, designed to be beginner-friendly while ensuring code quality and reliability. + +## Testing Philosophy + +### Start Simple, Build Up + +1. **Unit tests first**: Test individual functions and modules +2. **Integration tests**: Test how modules work together +3. **End-to-end tests**: Test complete user workflows +4. **Manual testing**: Regular testing of the actual TUI + +### Rust Testing Basics + +#### Built-in Test Framework + +```rust +// src/storage/entry.rs +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_entry_creation() { + let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); + let entry = Entry::new(date); + + assert_eq!(entry.date, date); + assert!(entry.content.is_none()); + } + + #[test] + fn test_entry_path_generation() { + let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); + let entry = Entry::new(date); + + let expected_filename = "20250315.md"; + assert!(entry.path.to_string_lossy().ends_with(expected_filename)); + } +} +``` + +#### Running Tests + +```bash +# Run all tests +cargo test + +# Run tests with output +cargo test -- --nocapture + +# Run specific test +cargo test test_entry_creation + +# Run tests in specific module +cargo test storage::tests + +# Run tests and show coverage +cargo test --verbose +``` + +## Unit Testing Strategy + +### 1. Data Structure Tests + +#### Entry Management + +```rust +// src/storage/tests.rs +use super::*; +use tempfile::TempDir; + +#[test] +fn test_entry_save_and_load() { + let temp_dir = TempDir::new().unwrap(); + let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); + let content = "# Daily Entry\n\nToday I learned Rust!".to_string(); + + let mut entry = Entry::new(date); + entry.content = Some(content.clone()); + entry.path = temp_dir.path().join("20250315.md"); + + // Test saving + entry.save().unwrap(); + assert!(entry.path.exists()); + + // Test loading + let mut loaded_entry = Entry::new(date); + loaded_entry.path = entry.path.clone(); + loaded_entry.load_content().unwrap(); + + assert_eq!(loaded_entry.content.as_ref().unwrap(), &content); +} + +#[test] +fn test_entry_date_parsing() { + let test_cases = vec![ + ("20250315", true), // Valid + ("2025315", false), // Invalid: missing zero + ("20250229", false), // Invalid: not leap year + ("20240229", true), // Valid: leap year + ("20251301", false), // Invalid: month > 12 + ("20250132", false), // Invalid: day > 31 + ]; + + for (date_str, should_be_valid) in test_cases { + let result = parse_entry_date(date_str); + assert_eq!(result.is_ok(), should_be_valid, "Failed for date: {}", date_str); + } +} +``` + +#### Navigation Tests + +```rust +// src/navigation/tests.rs +#[test] +fn test_tree_navigation() { + let mut tree = create_test_tree(); + + // Test moving down + tree.move_down(); + assert_eq!(tree.selected_index(), Some(1)); + + // Test wrapping at bottom + tree.select_last(); + tree.move_down(); + assert_eq!(tree.selected_index(), Some(0)); + + // Test moving up + tree.move_up(); + assert_eq!(tree.selected_index(), Some(tree.items.len() - 1)); +} + +#[test] +fn test_tree_expand_collapse() { + let mut tree = create_test_tree(); + + // Select a year node + tree.select_item_by_path("2025"); + + // Test expansion + assert!(!tree.is_expanded("2025")); + tree.toggle_current(); + assert!(tree.is_expanded("2025")); + + // Test collapse + tree.toggle_current(); + assert!(!tree.is_expanded("2025")); +} + +fn create_test_tree() -> TreeViewState { + let entries = vec![ + create_test_entry(2025, 3, 15), + create_test_entry(2025, 3, 16), + create_test_entry(2025, 4, 1), + create_test_entry(2024, 12, 31), + ]; + + TreeViewState::from_entries(entries) +} + +fn create_test_entry(year: i32, month: u32, day: u32) -> Entry { + let date = NaiveDate::from_ymd_opt(year, month, day).unwrap(); + Entry::new(date) +} +``` + +### 2. Editor Tests + +```rust +// src/editor/tests.rs +#[test] +fn test_text_insertion() { + let mut editor = EditorState::new(); + + // Insert characters + editor.insert_char('H'); + editor.insert_char('i'); + + assert_eq!(editor.current_line(), "Hi"); + assert_eq!(editor.cursor_col, 2); +} + +#[test] +fn test_line_operations() { + let mut editor = EditorState::with_content(vec![ + "Line 1".to_string(), + "Line 2".to_string(), + ]); + + // Insert new line + editor.cursor_line = 0; + editor.cursor_col = 6; + editor.insert_newline(); + + assert_eq!(editor.lines.len(), 3); + assert_eq!(editor.lines[0], "Line 1"); + assert_eq!(editor.lines[1], ""); + assert_eq!(editor.cursor_line, 1); + assert_eq!(editor.cursor_col, 0); +} + +#[test] +fn test_cursor_movement() { + let mut editor = EditorState::with_content(vec![ + "First line".to_string(), + "Second line".to_string(), + ]); + + // Move right + editor.move_cursor_right(); + assert_eq!(editor.cursor_col, 1); + + // Move to end of line + editor.cursor_col = editor.current_line().len(); + editor.move_cursor_right(); // Should not move past end + assert_eq!(editor.cursor_col, editor.current_line().len()); + + // Move down + editor.move_cursor_down(); + assert_eq!(editor.cursor_line, 1); +} +``` + +## Integration Testing + +### 1. File System Integration + +```rust +// tests/integration_tests.rs +use engineering_journal::{App, storage::Storage}; +use tempfile::TempDir; + +#[test] +fn test_full_entry_workflow() { + let temp_dir = TempDir::new().unwrap(); + let mut app = App::new_with_storage_path(temp_dir.path()).unwrap(); + + // Create entry + let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); + let content = "Test entry content".to_string(); + app.create_entry(date, content.clone()).unwrap(); + + // Verify entry exists + assert!(app.storage.entry_exists(&date)); + + // Load entry + let loaded_entry = app.storage.load_entry(&date).unwrap(); + assert_eq!(loaded_entry.content.as_ref().unwrap(), &content); + + // Delete entry + app.storage.delete_entry(&date).unwrap(); + assert!(!app.storage.entry_exists(&date)); +} + +#[test] +fn test_directory_structure() { + let temp_dir = TempDir::new().unwrap(); + let storage = Storage::new(temp_dir.path()).unwrap(); + + let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); + let entry = Entry::new(date); + + storage.save_entry(&entry).unwrap(); + + // Verify directory structure + let year_dir = temp_dir.path().join("entries").join("2025"); + let month_dir = year_dir.join("03"); + let entry_file = month_dir.join("20250315.md"); + + assert!(year_dir.exists()); + assert!(month_dir.exists()); + assert!(entry_file.exists()); +} +``` + +### 2. App State Integration + +```rust +#[test] +fn test_app_mode_transitions() { + let mut app = create_test_app(); + + // Start in navigation mode + assert!(matches!(app.mode, AppMode::Navigation)); + + // Transition to edit mode + let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); + app.enter_edit_mode(date).unwrap(); + assert!(matches!(app.mode, AppMode::Edit)); + + // Return to navigation mode + app.exit_edit_mode(); + assert!(matches!(app.mode, AppMode::Navigation)); +} + +#[test] +fn test_entry_creation_workflow() { + let mut app = create_test_app(); + + // Simulate 'n' key press (create today's entry) + app.handle_create_today_entry().unwrap(); + + // Should be in edit mode + assert!(matches!(app.mode, AppMode::Edit)); + + // Should have current date entry + let today = chrono::Local::now().date_naive(); + assert!(app.storage.entry_exists(&today)); +} +``` + +## UI Testing + +### 1. Mocking ratatui Components + +```rust +// src/ui/tests.rs +use ratatui::{backend::TestBackend, buffer::Buffer, Terminal}; + +#[test] +fn test_tree_view_rendering() { + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + let app = create_test_app_with_entries(); + + terminal.draw(|f| { + ui::render_tree_view(&app, f, f.size()); + }).unwrap(); + + let buffer = terminal.backend().buffer(); + + // Check that year is displayed + assert!(buffer_contains_text(buffer, "2025")); + + // Check that tree indicators are present + assert!(buffer_contains_text(buffer, "▼")); + assert!(buffer_contains_text(buffer, "▶")); +} + +#[test] +fn test_status_bar_content() { + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + let app = create_test_app(); + + terminal.draw(|f| { + ui::render_status_bar(&app, f, f.size()); + }).unwrap(); + + let buffer = terminal.backend().buffer(); + + // Check navigation mode status + assert!(buffer_contains_text(buffer, "[hjkl")); + assert!(buffer_contains_text(buffer, "Navigate")); + assert!(buffer_contains_text(buffer, "[q] Quit")); +} + +fn buffer_contains_text(buffer: &Buffer, text: &str) -> bool { + buffer.content().iter().any(|cell| cell.symbol().contains(text)) +} +``` + +### 2. Event Handling Tests + +```rust +#[test] +fn test_navigation_key_handling() { + let mut app = create_test_app_with_entries(); + + // Test 'j' key (move down) + let key_event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE); + app.handle_key_event(key_event).unwrap(); + + // Verify navigation state changed + let initial_selection = 0; + let new_selection = app.tree_view.list_state.selected().unwrap(); + assert_eq!(new_selection, initial_selection + 1); +} + +#[test] +fn test_mode_switching_keys() { + let mut app = create_test_app_with_entries(); + + // Test Enter key (should enter edit mode) + let key_event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + app.handle_key_event(key_event).unwrap(); + + assert!(matches!(app.mode, AppMode::Edit)); + + // Test ESC key (should return to navigation) + let key_event = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + app.handle_key_event(key_event).unwrap(); + + assert!(matches!(app.mode, AppMode::Navigation)); +} +``` + +## Property-Based Testing + +### Using quickcheck for Advanced Testing + +Add to Cargo.toml: + +```toml +[dev-dependencies] +quickcheck = "1.0" +quickcheck_macros = "1.0" +``` + +```rust +use quickcheck_macros::quickcheck; + +#[quickcheck] +fn test_date_parsing_roundtrip(year: u16, month: u8, day: u8) -> bool { + // Only test valid dates + if year < 1000 || year > 9999 || month == 0 || month > 12 || day == 0 || day > 31 { + return true; // Skip invalid inputs + } + + if let Ok(date) = NaiveDate::from_ymd_opt(year as i32, month as u32, day as u32) { + let formatted = format_entry_date(&date); + let parsed = parse_entry_date(&formatted); + + parsed.map(|p| p == date).unwrap_or(false) + } else { + true // Skip invalid dates + } +} + +#[quickcheck] +fn test_editor_operations_preserve_invariants(operations: Vec) -> bool { + let mut editor = EditorState::new(); + + for op in operations { + apply_operation(&mut editor, op); + + // Check invariants + if editor.cursor_line >= editor.lines.len() { + return false; + } + + if let Some(line) = editor.lines.get(editor.cursor_line) { + if editor.cursor_col > line.len() { + return false; + } + } + } + + true +} + +#[derive(Clone, Debug)] +enum EditorOperation { + InsertChar(char), + DeleteChar, + MoveCursor(CursorDirection), + InsertNewline, +} +``` + +## Performance Testing + +### Basic Benchmarking + +Add to Cargo.toml: + +```toml +[dev-dependencies] +criterion = "0.5" + +[[bench]] +name = "tree_operations" +harness = false +``` + +```rust +// benches/tree_operations.rs +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use engineering_journal::navigation::TreeViewState; + +fn bench_tree_navigation(c: &mut Criterion) { + let tree = create_large_tree(1000); // 1000 entries + + c.bench_function("tree navigation down", |b| { + b.iter(|| { + let mut tree_copy = tree.clone(); + for _ in 0..100 { + tree_copy.move_down(); + } + black_box(tree_copy); + }) + }); +} + +fn bench_tree_building(c: &mut Criterion) { + let entries = create_test_entries(1000); + + c.bench_function("build tree from entries", |b| { + b.iter(|| { + let tree = TreeViewState::from_entries(black_box(entries.clone())); + black_box(tree); + }) + }); +} + +criterion_group!(benches, bench_tree_navigation, bench_tree_building); +criterion_main!(benches); +``` + +## Test Data Management + +### Test Fixtures + +```rust +// src/test_utils.rs +pub mod test_utils { + use super::*; + use tempfile::TempDir; + + pub fn create_test_app() -> App { + let temp_dir = TempDir::new().unwrap(); + App::new_with_storage_path(temp_dir.path()).unwrap() + } + + pub fn create_test_entries(count: usize) -> Vec { + let start_date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + (0..count) + .map(|i| { + let date = start_date + chrono::Duration::days(i as i64); + let mut entry = Entry::new(date); + entry.content = Some(format!("Test entry {}", i)); + entry + }) + .collect() + } + + pub fn create_test_tree_with_entries(entries: Vec) -> TreeViewState { + TreeViewState::from_entries(entries) + } +} +``` + +## Continuous Integration + +### GitHub Actions Configuration + +```yaml +# .github/workflows/test.yml +name: Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt -- --check + + - name: Run Clippy + run: cargo clippy -- -D warnings + + - name: Run tests + run: cargo test --verbose + + - name: Run integration tests + run: cargo test --test integration_tests + + - name: Run benchmarks + run: cargo bench --no-run +``` + +## Manual Testing Checklist + +### Core Functionality + +- [ ] App starts without crashes +- [ ] Tree navigation works (hjkl and arrows) +- [ ] Entry creation works (n and c keys) +- [ ] Entry editing works (Enter to edit, ESC to exit) +- [ ] Entry deletion works (d key with confirmation) +- [ ] File persistence works (entries saved and loaded correctly) + +### Edge Cases + +- [ ] Empty directory (no entries) +- [ ] Large number of entries (100+) +- [ ] Very long entry content +- [ ] Invalid file permissions +- [ ] Disk full scenarios +- [ ] Terminal resize handling + +### User Experience + +- [ ] Status bar updates correctly +- [ ] Key bindings feel responsive +- [ ] Error messages are helpful +- [ ] No flickering or visual artifacts + +## Testing Best Practices + +### 1. Test Organization + +```rust +// Group related tests +mod entry_tests { + use super::*; + + mod creation { + use super::*; + // Entry creation tests + } + + mod persistence { + use super::*; + // File I/O tests + } +} +``` + +### 2. Descriptive Test Names + +```rust +#[test] +fn test_entry_creation_with_valid_date_creates_correct_file_path() { + // Test implementation +} + +#[test] +fn test_tree_navigation_wraps_around_when_reaching_end() { + // Test implementation +} +``` + +### 3. Clear Assertions + +```rust +#[test] +fn test_editor_cursor_movement() { + let mut editor = EditorState::new(); + editor.insert_char('H'); + + // Clear assertion with context + assert_eq!( + editor.cursor_col, 1, + "Cursor should be at position 1 after inserting one character" + ); +} +``` + +Remember: Good tests are your safety net while learning Rust. They help you catch issues early and give you confidence to refactor and improve your code! diff --git a/docs/plan/07-implementation-roadmap.md b/docs/plan/07-implementation-roadmap.md new file mode 100644 index 0000000..04b1457 --- /dev/null +++ b/docs/plan/07-implementation-roadmap.md @@ -0,0 +1,581 @@ +# Implementation Roadmap + +## Overview + +This roadmap provides a week-by-week implementation plan for building the Engineering Journal app. It's designed for someone learning Rust while building a real application. + +## Pre-Implementation (Day 0) + +### Setup and Preparation + +- [ ] Read through all planning documents +- [ ] Set up development environment +- [ ] Create initial Git repository +- [ ] Bookmark key resources + +**Resources to Bookmark:** + +- [The Rust Book](https://doc.rust-lang.org/book/) +- [ratatui Documentation](https://docs.rs/ratatui/) +- [color-eyre Documentation](https://docs.rs/color-eyre/) +- [chrono Documentation](https://docs.rs/chrono/) + +## Week 1: Foundation (Days 1-7) + +### Day 1: Project Setup + +**Goal**: Get basic project structure working + +**Tasks**: + +- [ ] Update Cargo.toml with dependencies +- [ ] Create basic module structure +- [ ] Set up color-eyre error handling +- [ ] Create "Hello World" with basic ratatui + +**Expected Output**: A program that opens a terminal UI and displays "Hello World" + +**Code Milestone**: + +```rust +// main.rs should successfully run and show a basic TUI +use color_eyre::Result; +use ratatui::prelude::*; + +fn main() -> Result<()> { + color_eyre::install()?; + println!("Engineering Journal - Setup Complete!"); + Ok(()) +} +``` + +### Day 2-3: Basic Data Structures + +**Goal**: Define core data types + +**Tasks**: + +- [ ] Create `Entry` struct with basic fields +- [ ] Implement date parsing with chrono +- [ ] Create `EntryPath` for YYYYMMDD format +- [ ] Add basic file path generation +- [ ] Write unit tests for data structures + +**Code Milestone**: + +```rust +// Should pass these tests +#[test] +fn test_entry_creation() { + let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); + let entry = Entry::new(date); + assert_eq!(entry.date, date); +} + +#[test] +fn test_date_parsing() { + let result = parse_entry_date("20250315"); + assert!(result.is_ok()); +} +``` + +### Day 4-5: File Operations + +**Goal**: Basic CRUD for entries + +**Tasks**: + +- [ ] Implement entry saving to filesystem +- [ ] Implement entry loading from filesystem +- [ ] Create directory structure automatically +- [ ] Handle file I/O errors with color-eyre +- [ ] Test with temporary directories + +**Code Milestone**: + +```bash +# Should be able to run these CLI commands +cargo run -- create 20250315 "My first entry" +cargo run -- read 20250315 +cargo run -- list +``` + +### Day 6-7: Basic CLI Interface + +**Goal**: Command-line interface working + +**Tasks**: + +- [ ] Parse command line arguments +- [ ] Implement basic CRUD commands +- [ ] Add help text and error messages +- [ ] Test all CLI operations +- [ ] Prepare for TUI implementation + +**Learning Focus This Week**: + +- Basic Rust syntax and ownership +- Working with external crates +- File I/O and error handling +- Unit testing in Rust + +## Week 2: Basic TUI (Days 8-14) + +### Day 8-9: Terminal Setup + +**Goal**: Basic ratatui application loop + +**Tasks**: + +- [ ] Set up terminal initialization/cleanup +- [ ] Create basic event loop +- [ ] Handle keyboard input +- [ ] Implement graceful shutdown (q key) +- [ ] Basic layout (split screen) + +**Code Milestone**: + +```rust +// Should have working TUI that: +// - Starts up cleanly +// - Responds to 'q' key to quit +// - Shows basic layout with two panes +``` + +### Day 10-11: App State Management + +**Goal**: Core application state + +**Tasks**: + +- [ ] Create `App` struct with modes +- [ ] Implement mode switching (Navigation/Edit) +- [ ] Basic event routing based on mode +- [ ] Simple status bar showing current mode +- [ ] Handle ESC key for mode switching + +**Code Milestone**: + +```rust +// Should be able to switch between modes +// - Start in Navigation mode +// - Press Enter → Edit mode +// - Press ESC → Navigation mode +// - Status bar shows current mode +``` + +### Day 12-14: Simple List View + +**Goal**: Display and navigate entries + +**Tasks**: + +- [ ] Create simple list widget +- [ ] Load and display existing entries +- [ ] Implement basic navigation (j/k keys) +- [ ] Show selected entry content in right pane +- [ ] Add visual selection indicator + +**Code Milestone**: + +```rust +// Should have working navigation: +// - List shows all entries +// - j/k keys move selection +// - Selected entry content displays +// - Visual feedback for selection +``` + +**Learning Focus This Week**: + +- ratatui basics (layouts, widgets, events) +- Pattern matching on events +- Borrowing and mutability with app state + +## Week 3: Navigation System (Days 15-21) + +### Day 15-16: Tree Data Structure + +**Goal**: Hierarchical entry organization + +**Tasks**: + +- [ ] Design tree structure (Year/Month/Entry) +- [ ] Build tree from entry list +- [ ] Implement tree traversal +- [ ] Add expand/collapse state tracking +- [ ] Test tree operations + +**Code Milestone**: + +```rust +// Should have working tree structure: +let tree = EntryTree::from_entries(entries); +assert_eq!(tree.years.len(), 2); // 2024, 2025 +assert_eq!(tree.years[&2025].months.len(), 3); // Jan, Mar, Apr +``` + +### Day 17-18: Tree Navigation Logic + +**Goal**: Navigate through tree hierarchy + +**Tasks**: + +- [ ] Implement hjkl navigation +- [ ] Add left/right for expand/collapse +- [ ] Handle navigation between levels +- [ ] Implement selection state management +- [ ] Add breadcrumb display + +**Code Milestone**: + +```rust +// Navigation should work: +// h/l: expand/collapse or move between levels +// j/k: move within current level +// Breadcrumb shows: 2025 > March > 15 +``` + +### Day 19-21: Tree UI Implementation + +**Goal**: Visual tree in left pane + +**Tasks**: + +- [ ] Render tree with proper indentation +- [ ] Add expand/collapse indicators (▼▶) +- [ ] Implement visual selection +- [ ] Show entry indicators (●•) +- [ ] Handle window resizing + +**Code Milestone**: + +``` +Tree should display like: +▼ 2025 + ▼ March + • 01 + ● 15 (selected) + • 28 + ▶ April +▶ 2024 +``` + +**Learning Focus This Week**: + +- Complex data structures (BTreeMap, HashSet) +- Working with collections and iterators +- State management patterns + +## Week 4: Editor Integration (Days 22-28) + +### Day 22-23: Basic Text Editor + +**Goal**: Simple text editing capability + +**Tasks**: + +- [ ] Create `EditorState` struct +- [ ] Implement cursor positioning +- [ ] Add character insertion/deletion +- [ ] Handle line operations +- [ ] Basic cursor movement (arrow keys) + +**Code Milestone**: + +```rust +// Should be able to: +// - Insert characters at cursor +// - Delete characters with backspace +// - Move cursor with arrow keys +// - Handle newlines +``` + +### Day 24-25: Editor UI + +**Goal**: Visual text editor in right pane + +**Tasks**: + +- [ ] Render text with cursor +- [ ] Show cursor position visually +- [ ] Handle text scrolling +- [ ] Display line numbers (optional) +- [ ] Show dirty state indicator + +**Code Milestone**: + +``` +Editor should show: +┌─ Editor - 20250315.md * ─┐ +│Line 1 of content█ │ +│Line 2 of content │ +│ │ +└──────────────────────────┘ +``` + +### Day 26-28: Mode Integration + +**Goal**: Smooth navigation ↔ edit transitions + +**Tasks**: + +- [ ] Load entry content into editor +- [ ] Save editor content to file (Ctrl+S) +- [ ] Handle unsaved changes warnings +- [ ] Return to navigation on ESC +- [ ] Update tree view after saves + +**Code Milestone**: + +``` +Complete workflow should work: +1. Navigate to entry (or create new) +2. Press Enter → Edit mode with content +3. Edit text, see cursor +4. Ctrl+S to save +5. ESC to return to navigation +``` + +**Learning Focus This Week**: + +- String manipulation in Rust +- Working with mutable state +- File I/O integration with UI + +## Week 5: Entry Management (Days 29-35) + +### Day 29-30: Entry Creation + +**Goal**: Create entries from UI + +**Tasks**: + +- [ ] Implement 'n' key for today's entry +- [ ] Add 'c' key with date prompt +- [ ] Create prompt input widget +- [ ] Validate date input +- [ ] Create files and update tree + +**Code Milestone**: + +``` +Entry creation should work: +- Press 'n' → creates today's entry, enters edit mode +- Press 'c' → shows prompt for date +- Type "20250315" → creates entry for that date +- Invalid dates show error message +``` + +### Day 31-32: Entry Deletion + +**Goal**: Delete entries safely + +**Tasks**: + +- [ ] Add 'd' key for deletion +- [ ] Implement confirmation prompt +- [ ] Delete file and update tree +- [ ] Handle deletion errors gracefully +- [ ] Update navigation after deletion + +**Code Milestone**: + +``` +Deletion should work: +- Press 'd' on selected entry +- Shows: "Delete entry for 20250315? (y/N)" +- Press 'y' → deletes file and updates tree +- Press 'n' or ESC → cancels operation +``` + +### Day 33-35: Prompt System + +**Goal**: Reusable prompt widget + +**Tasks**: + +- [ ] Create generic prompt widget +- [ ] Handle text input in prompts +- [ ] Add input validation +- [ ] Support different prompt types +- [ ] Polish prompt UI + +**Code Milestone**: + +``` +Prompt system should handle: +- Date input with validation +- Yes/No confirmations +- Error messages +- Clean transitions back to main UI +``` + +**Learning Focus This Week**: + +- User input handling +- State machine patterns +- Input validation techniques + +## Week 6: Polish and Testing (Days 36-42) + +### Day 36-37: Error Handling + +**Goal**: Robust error handling + +**Tasks**: + +- [ ] Improve error messages throughout +- [ ] Handle edge cases gracefully +- [ ] Add user-friendly error display +- [ ] Test error scenarios +- [ ] Add recovery mechanisms + +### Day 38-39: Performance + +**Goal**: Smooth performance + +**Tasks**: + +- [ ] Optimize tree operations +- [ ] Implement lazy loading for large content +- [ ] Profile performance with many entries +- [ ] Optimize UI rendering +- [ ] Add loading indicators if needed + +### Day 40-42: Final Testing + +**Goal**: Production-ready application + +**Tasks**: + +- [ ] Comprehensive manual testing +- [ ] Test with real data (100+ entries) +- [ ] Test edge cases and error conditions +- [ ] Performance testing +- [ ] Documentation and cleanup + +**Final Milestone**: + +``` +Complete application should: +✅ Navigate smoothly through hundreds of entries +✅ Create/edit/delete entries reliably +✅ Handle errors gracefully +✅ Start up quickly +✅ Feel responsive and polished +``` + +## Daily Development Routine + +### Morning Routine (15 minutes) + +```bash +# Pull latest changes +git pull + +# Check everything still works +cargo check +cargo test + +# Review today's goals +# Read relevant docs for today's tasks +``` + +### Development Session + +1. **Focus on one task at a time** +2. **Write tests before implementation** (when possible) +3. **Commit frequently** with descriptive messages +4. **Test changes immediately** + +### End of Day (10 minutes) + +```bash +# Clean up code +cargo fmt +cargo clippy + +# Run full test suite +cargo test + +# Commit progress +git add . +git commit -m "Day X: Implemented Y feature" + +# Plan tomorrow's work +``` + +## Weekly Reviews + +### End of Each Week + +1. **Demo the current progress** to yourself +2. **Review what you learned** about Rust +3. **Identify any struggles** and plan how to address them +4. **Adjust timeline** if needed (it's okay to take longer!) +5. **Celebrate progress** - building a real app is a big achievement! + +## Troubleshooting Guide + +### When Stuck on Rust Concepts + +1. **Read the compiler error carefully** - Rust's compiler is very helpful +2. **Start with .clone()** if ownership is confusing, optimize later +3. **Break the problem down** into smaller pieces +4. **Use println! debugging** liberally +5. **Ask for help** in Rust community forums + +### When Stuck on ratatui + +1. **Check the examples** in the ratatui repository +2. **Start with simple widgets** before complex layouts +3. **Use TestBackend** for testing UI components +4. **Debug rendering** by checking buffer contents + +### When Stuck on Architecture + +1. **Keep it simple** - don't over-engineer +2. **Make it work first**, then make it pretty +3. **Copy patterns** from the examples in this plan +4. **Refactor gradually** as you learn better patterns + +## Success Metrics + +### Technical Metrics + +- [ ] All tests pass +- [ ] No clippy warnings +- [ ] Code is formatted (cargo fmt) +- [ ] App starts in <1 second +- [ ] Can handle 100+ entries smoothly + +### Learning Metrics + +- [ ] Comfortable with basic Rust ownership +- [ ] Can read and fix compiler errors +- [ ] Understanding of ratatui patterns +- [ ] Can write simple tests +- [ ] Knows how to debug Rust programs + +### User Experience Metrics + +- [ ] All key bindings work as expected +- [ ] No crashes during normal use +- [ ] Error messages are helpful +- [ ] UI feels responsive +- [ ] Navigation is intuitive + +## Beyond Week 6: Future Enhancements + +Once you have the MVP working, consider these additions: + +- [ ] Search functionality across entries +- [ ] Export entries to different formats +- [ ] Configuration file for customization +- [ ] Vim-like command mode +- [ ] Entry templates +- [ ] Tags and categorization +- [ ] Statistics and analytics + +Remember: The goal is to learn Rust while building something useful. Don't worry about building the perfect application - focus on learning and making steady progress! diff --git a/docs/requirements.md b/docs/requirements.md index 7fc630c..4fc148f 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -1,101 +1,222 @@ -# Functional Requirements +# Engineering Journal App - MVP Functional Requirements -## 1. Core Entry Management +## Project Overview -- **Create a new entry** +A terminal-based journal keeping application designed specifically for engineers, built with Rust and `ratatui`. The app provides a vim-like navigation experience with efficient keyboard shortcuts for managing daily journal entries. - - `devlog new -m "message"` → inline note - - `devlog new` → open \$EDITOR for detailed entry - - Support YAML frontmatter (date, tags, people, projects) + Markdown body +## Core Functionality (MVP) -- **Edit an entry** +### 1. Entry Management (CRUD Operations) - - `devlog edit ` → open existing entry in \$EDITOR +#### Create Entry -- **List entries** +- **Entry ID Format**: `YYYYMMDD` (e.g., `20250915`) +- **Auto-creation**: Automatically create today's entry if it doesn't exist +- **Manual creation**: Allow creating entries for specific dates - - `devlog list [--since ] [--until ] [--tag ] [--project ] [--person <@name>]` +#### Read/View Entry -- **Show entry** +- Display entry content in a dedicated view pane +- Show entry in plain text format - - `devlog show ` → display entry details +#### Update Entry ---- +- Modify existing entry content using built-in editor +- Save changes when user explicitly saves (Ctrl+S or :w) -## 2. Annotation & Metadata +#### Delete Entry -- **Inline annotations (Markdown-compatible):** +- Simple delete with confirmation prompt - - `@alice` → coworker/person - - `::search-service` → project - - `+motivation` → tag/technology +### 2. Navigation System + +#### Hierarchical Navigation Structure -- **Auto-extraction of metadata** into YAML frontmatter for easy querying. -- **Configurable dictionary** to autocomplete/validate coworkers, projects, tags. +``` +Year (2025) +├── Month (Jan, Feb, Mar...) +│ ├── Day (01, 02, 03...) +│ │ └── Entry Content +``` ---- +#### Keyboard Navigation (Vim-inspired + Arrow Keys) -## 3. Stats & Insights +``` +Movement (Vim-style): +- h / ←: Move left (collapse/go to parent level) +- j / ↓: Move down (next item in current level) +- k / ↑: Move up (previous item in current level) +- l / →: Move right (expand/go to child level) -- **Generate summary reports**: +Entry Management: +- Enter: Open/Edit selected entry (creates if doesn't exist) +- n: Create new entry for today +- c: Create entry for specific date (prompts for YYYYMMDD) +- d: Delete selected entry (with confirmation) +- Space: Toggle expand/collapse + +Mode & Application: +- ESC: Return to navigation mode (from edit mode) +- Ctrl+S: Save entry (in edit mode) +- q: Quit application +``` - - Collaboration list (who you work with most) - - Top projects mentioned - - Sentiment/motivation trend over time - - Frequency of blockers/issues +#### Smart Navigation Features -- CLI command: `devlog stats [--since ]` +- **Year View**: Navigate between years (2023, 2024, 2025...) +- **Month View**: Navigate months within a year (Jan, Feb, Mar...) +- **Day View**: Navigate days within a month (01, 02, 03...) +- **Breadcrumb Navigation**: Always show current location (2025 > Mar > 15) ---- +### 3. User Interface Design -## 4. Exporting +#### Main Layout -- **Brag doc export** +``` +┌─────────────────────────────────────────────────────────────┐ +│ Engineering Journal v1.0 [h] Help │ +├─────────────────────────────────────────────────────────────┤ +│ Navigation: 2025 > March > 15 │ +├─────────────────┬───────────────────────────────────────────┤ +│ Tree View │ Entry Content │ +│ │ │ +│ ▼ 2025 │ # March 15, 2025 │ +│ ▼ March │ │ +│ • 01 │ ## Daily Standup │ +│ • 03 │ - Fixed authentication bug │ +│ • 07 │ - Reviewed PR #123 │ +│ ● 15 │ │ +│ • 28 │ ## Technical Notes │ +│ ▼ April │ Discovered interesting pattern in... │ +│ • 02 │ │ +│ • 05 │ │ +│ ▶ May │ │ +│ │ │ +│ │ │ +├─────────────────┴───────────────────────────────────────────┤ +│ [hjkl/↑↓←→] Nav [Enter] Edit [n] New [c] Create [d] Del [q] Quit │ +└─────────────────────────────────────────────────────────────┘ +``` - - `devlog export --since last-quarter --format markdown` - - Generates bullet points grouped by project, theme, or outcome +#### Visual Indicators -- Support export formats: Markdown, CSV (others later: PDF, HTML). +- `▼` Expanded folder (year/month) +- `▶` Collapsed folder +- `●` Current/selected entry +- `•` Available entry ---- +### 3. Built-in Editor -## 5. Sync & Storage +#### Editor Modes -- **Local-first storage** +- **Navigation Mode**: Browse entries using hjkl/arrow key navigation +- **Edit Mode**: Edit entry content with basic text editing capabilities - - Entries stored in `~/.devlog/entries/YYYY-MM-DD.md` - - Markdown + YAML frontmatter +#### Editor Key Bindings -- **Sync command** (premium/extension): +``` +Navigation Mode: +- hjkl / ↑↓←→: Navigate tree +- Enter: Open entry for editing (creates if doesn't exist) +- n: Create new entry for today +- c: Create entry for specific date (prompts for YYYYMMDD) +- d: Delete selected entry (with confirmation) +- Space: Toggle expand/collapse +- q: Quit application + +Edit Mode: +- ESC: Return to navigation mode +- Ctrl+S: Save entry +- Basic text editing (insert, delete, arrow keys for cursor movement) +- Ctrl+L: Clear screen/refresh +``` + +#### Editor Features + +- Simple text editing capabilities +- Line-based editing +- Basic cursor movement with arrow keys +- Insert and delete text + +### 4. Data Storage + +#### File Structure + +``` +~/.config/engineering-journal/ +├── entries/ +│ ├── 2025/ +│ │ ├── 01/ +│ │ │ ├── 20250101.md +│ │ │ ├── 20250102.md +│ │ │ └── ... +│ │ ├── 02/ +│ │ └── ... +│ └── 2024/ +``` + +#### Entry Format + +- **File Format**: Plain text (`.txt`) or Markdown (`.md`) +- **Naming Convention**: `YYYYMMDD.md` +- **Content**: Simple text content without metadata + +### 5. Common Workflows + +#### Creating Entries + +**Scenario 1: Create today's entry** + +1. Press `n` from anywhere in navigation mode +2. App creates entry for current date and opens editor +3. User writes content and saves with `Ctrl+S` + +**Scenario 2: Create entry for specific date** + +1. Press `c` from anywhere in navigation mode +2. App prompts for date input (YYYYMMDD format) +3. App creates entry for specified date and opens editor +4. User writes content and saves with `Ctrl+S` + +#### Deleting Entries + +**Delete workflow** - - `devlog sync` → push to remote (e.g., Git repo, web dashboard, cloud backup). +1. Navigate to existing entry +2. Press `d` to delete +3. App shows confirmation prompt: "Delete entry for YYYYMMDD? (y/N)" +4. Press `y` to confirm or `n`/`ESC` to cancel +5. If confirmed, entry is deleted and removed from tree view ---- +#### Editing Existing Entries -## 6. Config & Customization +**Edit workflow** -- **Config file** at `~/.devlog/config.yml`: +1. Navigate to existing entry using hjkl/arrows +2. Press `Enter` to open editor +3. Modify content using basic text editing +4. Save with `Ctrl+S` and return to navigation with `ESC` - - Default editor - - Known coworkers, projects, tags - - Sync settings +## Implementation Plan -- **Templates** for entries (daily, weekly retro). +### MVP Features (Phase 1) ---- +1. Basic file structure creation and management +2. Hierarchical navigation with hjkl/arrow key support +3. Entry creation with YYYYMMDD format +4. Simple built-in text editor with two modes +5. Save/load entries from filesystem +6. Basic UI layout with tree view and content pane -## 7. Extensibility Hooks (future) +### Key Implementation Focus -- **IDE integration**: VS Code/JetBrains extension reusing CLI backend. -- **Slack/Discord integration**: `/devlog` command → append entry. -- **Git integration**: auto-suggest commits/issues for context. +- **Navigation**: Smooth hjkl + arrow key navigation between years/months/days +- **Editor**: Simple two-mode system (navigation vs edit) +- **File I/O**: Basic read/write operations for entry files +- **UI**: Clean split-pane layout with ratatui widgets ---- +### Success Criteria -✅ **MVP focus** (phase 1): - -- Entry creation (`new`, `list`, `show`, `edit`) -- Annotation parsing (`@`, `::`, `+`) -- Local Markdown storage with YAML frontmatter -- Basic `stats` (counts, top collaborators/projects) -- Basic `export` (Markdown brag doc) +- **Usability**: Engineers can efficiently create and navigate entries using familiar key bindings +- **Performance**: Fast navigation between entries without lag +- **Simplicity**: Intuitive interface that doesn't require documentation to use diff --git a/src/annotations.rs b/src/annotations.rs deleted file mode 100644 index 7805894..0000000 --- a/src/annotations.rs +++ /dev/null @@ -1,182 +0,0 @@ -use regex::Regex; - -/// Result of parsing annotations from content -pub struct ParsedAnnotations { - pub people: Vec, - pub projects: Vec, - pub tags: Vec, -} - -/// Trait for extracting annotations from text content -pub trait AnnotationParser { - /// Extract all annotations from content - /// @alice -> people - /// ::search_engine -> projects - /// +motivation -> tags - fn parse(&self, content: &str) -> ParsedAnnotations; -} - -/// Regex-based implementation of annotation parsing -pub struct RegexAnnotationParser { - people_regex: Regex, - projects_regex: Regex, - tags_regex: Regex, -} - -impl RegexAnnotationParser { - /// Create a new regex annotation parser with default patterns - pub fn new() -> Self { - Self { - // Regex pattern: ([\w-]+): one or more word characters (letters/digits/underscore/hyphen) - people_regex: Regex::new(r"@([\w-]+)").unwrap(), - projects_regex: Regex::new(r"::([\w-]+)").unwrap(), - tags_regex: Regex::new(r"\+([\w-]+)").unwrap(), - } - } - - /// Generic extraction function - fn extract_with_regex(&self, content: &str, regex: &Regex) -> Vec { - regex - .captures_iter(content) - // capture[0] is the full match, i.e. @alice - // capture[1] is the first capture group, i.e. (alice) - // filter_map() returns the item that is `Some(value)` - .filter_map(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()) - .collect() - } -} - -impl AnnotationParser for RegexAnnotationParser { - /// Extract all annotations from content - /// @alice -> people - /// ::search_engine -> projects - /// +motivation -> tags - fn parse(&self, content: &str) -> ParsedAnnotations { - ParsedAnnotations { - people: self.extract_with_regex(content, &self.people_regex), - projects: self.extract_with_regex(content, &self.projects_regex), - tags: (self.extract_with_regex(content, &self.tags_regex)), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_all_annotations() { - let parser = RegexAnnotationParser::new(); - let content = - "@alice helped with ::project, then @alice worked on ::project using +rust and +rust"; - let annotations = parser.parse(content); - - // Should allow duplicates since we're using Vec instead of HashSet - assert_eq!(annotations.people.len(), 2); - assert_eq!(annotations.people, ["alice", "alice"].map(String::from)); - - assert_eq!(annotations.projects.len(), 2); - assert_eq!( - annotations.projects, - ["project", "project"].map(String::from) - ); - - assert_eq!(annotations.tags.len(), 2); - assert_eq!(annotations.tags, ["rust", "rust"].map(String::from)); - } - - #[test] - fn test_no_annotations() { - let parser = RegexAnnotationParser::new(); - let content = "Just worked alone today on some regular code"; - let annotations = parser.parse(content); - - assert!(annotations.people.is_empty()); - assert!(annotations.projects.is_empty()); - assert!(annotations.tags.is_empty()); - } - - #[test] - fn test_ignore_incomplete_annotations() { - let parser = RegexAnnotationParser::new(); - let content = "The @ symbol alone or @ with space, :: without name, + without tag"; - let annotations = parser.parse(content); - - assert!(annotations.people.is_empty()); - assert!(annotations.projects.is_empty()); - assert!(annotations.tags.is_empty()); - } - - #[test] - fn test_annotations_with_punctuation() { - let parser = RegexAnnotationParser::new(); - let content = "Talked to @alice! Then worked on ::project? Finally learned +rust."; - let annotations = parser.parse(content); - - assert_eq!(annotations.people.len(), 1); - assert_eq!(annotations.people, vec!["alice".to_string()]); - - assert_eq!(annotations.projects.len(), 1); - assert_eq!(annotations.projects, vec!["project".to_string()]); - - assert_eq!(annotations.tags.len(), 1); - assert_eq!(annotations.tags, vec!["rust".to_string()]); - } - - #[test] - fn test_multiline_content() { - let parser = RegexAnnotationParser::new(); - let content = r#" - Day 1: Met with @sarah about ::search_engine - Day 2: @mike joined, we used +rust - Day 3: Deployed to ::production with +confidence - "#; - let annotations = parser.parse(content); - - assert_eq!(annotations.people.len(), 2); - assert_eq!( - annotations.people, - vec!["sarah".to_string(), "mike".to_string()] - ); - - assert_eq!(annotations.projects.len(), 2); - assert_eq!( - annotations.projects, - vec!["search_engine".to_string(), "production".to_string()] - ); - - assert_eq!(annotations.tags.len(), 2); - assert_eq!( - annotations.tags, - vec!["rust".to_string(), "confidence".to_string()] - ); - } - - #[test] - fn test_duplicates_preserved() { - let parser = RegexAnnotationParser::new(); - let content = - "@alice @bob @alice worked on ::proj1 ::proj2 ::proj1 using +rust +debug +rust"; - let annotations = parser.parse(content); - - // Duplicates should be preserved in order - assert_eq!(annotations.people.len(), 3); - assert_eq!( - annotations.people, - ["alice", "bob", "alice"].map(String::from) - ); - - assert_eq!(annotations.projects.len(), 3); - assert_eq!( - annotations.projects, - ["proj1", "proj2", "proj1"].map(String::from) - ); - - assert_eq!(annotations.tags.len(), 3); - assert_eq!( - annotations.tags, - ["rust", "debug", "rust"].map(String::from) - ); - } -} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..83ac147 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,102 @@ +use color_eyre::{Result, eyre::Ok}; +use crossterm::event::{KeyCode, KeyEvent}; + +/// Main application state +#[derive(Debug)] +pub struct App { + /// Current application mode + pub mode: AppMode, + /// Whether the application should quit + pub should_quit: bool, +} + +/// Application mode +#[derive(Debug, PartialEq, Clone)] +pub enum AppMode { + /// Navigating through entries + Navigation, + /// Edit an entry + Edit, + /// Showing a prompt + Prompt(PromptType), +} + +/// Prompt types +#[derive(Debug, PartialEq, Clone)] +pub enum PromptType { + CreateEntry, + DeleteConfirmation, +} + +impl App { + /// Create a new application instance + pub fn new() -> Self { + Self { + mode: AppMode::Navigation, + should_quit: false, + } + } + + /// Handle a key event based on current mode + pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> { + match self.mode { + AppMode::Navigation => self.handle_navigation_key(key), + // Future modes will go here + _ => Ok(()), + } + } + + /// Handel keys when in vaigation mode + fn handle_navigation_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Char('q') => { + self.should_quit = true; + } + // Future navigation keys here + _ => {} + } + Ok(()) + } + + /// Check if the app should quit + pub fn should_quit(&self) -> bool { + self.should_quit + } + + /// Get current mode as a string for display + pub fn mode_string(&self) -> &'static str { + match self.mode { + AppMode::Navigation => "Navigation", + AppMode::Edit => "Edit", + _ => "", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + #[test] + fn test_new_app() { + let app = App::new(); + assert_eq!(app.mode, AppMode::Navigation); + assert!(!app.should_quit); + } + + #[test] + fn test_quit_key() { + let mut app = App::new(); + let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE); + + app.handle_key(key).unwrap(); + assert!(app.should_quit()); + } + + #[test] + fn test_mode_string() { + let app = App::new(); + assert_eq!(app.mode_string(), "Navigation"); + } +} diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index 0e2e85b..0000000 --- a/src/cli.rs +++ /dev/null @@ -1,421 +0,0 @@ -use crate::entry::Entry; -use crate::storage::{EntryStorage, LocalEntryStorage}; -use chrono::{Local, NaiveDate}; -use clap::{Parser, Subcommand}; -use std::process; - -/// DevLog - a journal CLI tool for developers -#[derive(Parser)] -#[command(name = "devlog")] -#[command(about = "A journal CLI tool for developers")] -#[command(version)] -pub struct Cli { - #[command(subcommand)] - pub command: Commands, -} - -#[derive(Subcommand)] -pub enum Commands { - /// Create a new entry - New { - /// Inline message for the entry - #[arg(short, long)] - message: Option, - /// Optional ID for the entry (format: YYYMMDD) - #[arg(long, value_name = "YYYYMMDD")] - id: Option, - }, - /// Edit an existing entry - Edit { - /// Entry ID to edit (format: YYYYMMDD) - #[arg(long, value_name = "YYYYMMDD")] - id: String, - }, - /// Show a specific entry - Show { - /// Entry ID to display (format: YYYYMMDD) - #[arg(value_name = "YYYYMMDD")] - id: String, - /// Display human-readable format instead of raw markdown content - #[arg(long)] - formatted: bool, - }, - /// List all entries - List, -} - -impl Cli { - /// Run the CLI application - pub fn run() -> Result<(), Box> { - let cli = Cli::parse(); - // TODO: read user defined storage path - // For now, we use the default `base_dir`, which is `~/.devlog` - let storage = LocalEntryStorage::new(None)?; - - match cli.command { - Commands::New { message, id } => { - Self::handle_new_command(message, id, &storage)?; - } - Commands::Edit { id } => { - Self::handle_edit_command(id, &storage)?; - } - Commands::Show { id, formatted } => { - Self::handle_show_command(id, formatted, &storage)?; - } - Commands::List => { - Self::handle_list_command(&storage)?; - } - } - - Ok(()) - } - - /// Handle the new subcommand - fn handle_new_command( - message: Option, - custom_id: Option, - storage: &dyn EntryStorage, - ) -> Result<(), Box> { - // Validate custom ID format if provided - if let Some(ref id) = custom_id { - Self::validate_id_format(id)?; - } - // Generate ID: use custom ID if provided, otherwise use current date - let id = custom_id.unwrap_or_else(|| format!("{}", Local::now().format("%Y%m%d"))); - - // Check if entry already exists to prevent data loss - if Entry::load(&id, storage)?.is_some() { - eprintln!("Entry with ID '{}' already exists.", id); - eprintln!("To edit the existing entry, use: devlog edit --id {}", id); - process::exit(1); - } - - let content = match message { - Some(msg) => msg, - None => Self::open_editor_for_content(None)?, - }; - - if content.trim().is_empty() { - eprintln!("Entry content cannot be empty."); - process::exit(1); - } - - // Create new entry with mandatory ID - let entry = Entry::new(content, id); - - // Save the entry - entry.save(storage)?; - - let state = entry.current_state(); - println!("Created new entry: {}", state.id); - - Ok(()) - } - - /// Handle the edit subcommand - fn handle_edit_command( - id: String, - storage: &dyn EntryStorage, - ) -> Result<(), Box> { - // Load existing entry - let mut entry = match Entry::load(&id, storage)? { - Some(entry) => entry, - None => { - eprintln!("Entry with ID '{}' not found.", id); - process::exit(1); - } - }; - - // Get current content and open editor with it - let current_content = entry.current_state().content.clone(); - let new_content = Self::open_editor_for_content(Some(¤t_content))?; - - if new_content.trim().is_empty() { - eprintln!("Entry content cannot be empty."); - process::exit(1); - } - - // Update the entry - entry.update_content(new_content); - - // Save the updated entry - entry.save(storage)?; - - println!("Updated entry: {}", id); - - Ok(()) - } - - /// Handle the show subcommand - fn handle_show_command( - id: String, - formatted: bool, - storage: &dyn EntryStorage, - ) -> Result<(), Box> { - // Validate ID format - Self::validate_id_format(&id)?; - - // Load the entry - let entry = match Entry::load(&id, storage)? { - Some(entry) => entry, - None => { - eprintln!("Entry with ID '{}' not found.", id); - process::exit(1); - } - }; - - if formatted { - Self::display_default_format(&entry); - } else { - println!("{}", entry.to_markdown()); - } - - Ok(()) - } - - /// Handle the list subcommand - fn handle_list_command(storage: &dyn EntryStorage) -> Result<(), Box> { - let entry_ids = storage.list_entry_ids()?; - - if entry_ids.is_empty() { - println!("No entries found. Create one with 'devlog new'"); - return Ok(()); - } - - println!(); - println!("DevLog Entries"); - println!("══════════════"); - - for entry_id in &entry_ids { - // Load the entry to get its content - if let Some(entry) = Entry::load(entry_id, storage)? { - let state = entry.current_state(); - - // Get the first line of content, truncated to ~60 characters - let first_line = state.content.lines().next().unwrap_or("(empty)").trim(); - - let display_content = if first_line.len() > 60 { - format!("{}...", &first_line[..57]) - } else if state.content.lines().count() > 1 { - format!("{}...", first_line) - } else { - first_line.to_string() - }; - - println!(" {} {}", entry_id, display_content); - } - } - - println!("══════════════"); - println!("Total: {} entries", entry_ids.len()); - println!(); - println!("Commands:"); - println!(" devlog edit --id YYYYMMDD Edit an entry"); - println!(" devlog new Create a new entry"); - - Ok(()) - } - - /// Display entry in default human-readable format - fn display_default_format(entry: &Entry) { - let state = entry.current_state(); - - println!("Entry: {}", state.id); - println!("Created: {}", state.created_at.format("%Y-%m-%d %H:%M:%S")); - println!("Updated: {}", state.updated_at.format("%Y-%m-%d %H:%M:%S")); - println!(); - - // Display metadata if present - if !state.people.is_empty() || !state.projects.is_empty() || !state.tags.is_empty() { - println!("Metadata:"); - if !state.people.is_empty() { - println!(" People: {}", state.people.join(", ")); - } - if !state.projects.is_empty() { - println!(" Projects: {}", state.projects.join(", ")); - } - if !state.tags.is_empty() { - println!(" Tags: {}", state.tags.join(", ")); - } - println!(); - } - - println!("Content:"); - println!("{}", Self::highlight_annotations(&state.content)); - } - - /// Highlight annotations in content for better readability - fn highlight_annotations(content: &str) -> String { - // For now, just return the content as-is - // In the future, we could add color highlighting for @person, ::project, +tag - content.to_string() - } - - /// Validate that the ID is in YYYYMMDD format - fn validate_id_format(id: &str) -> Result<(), Box> { - NaiveDate::parse_from_str(id, "%Y%m%d") - .map(|_| format!("Invalid date format '{}'. Expected YYYYMMDD formate", id))?; - Ok(()) - } - - /// Open a text editor for the user to write content - fn open_editor_for_content( - existing_content: Option<&str>, - ) -> Result> { - // Create a temporary file for editing - let temp_file = tempfile::NamedTempFile::new()?; - let temp_path = temp_file.path(); - - // Write initial content with instructions - let init_content = match existing_content { - Some(content) => format!("{}\n{}", content, Self::get_template()), - None => Self::get_template(), - }; - std::fs::write(temp_path, init_content)?; - - // Open the editor - let editor = Self::find_available_editor(); - let status = process::Command::new(&editor).arg(temp_path).status()?; - - if !status.success() { - return Err(format!("Editor '{}' exited with non-zero status", editor).into()); - } - - // Read content back from temp file - let content = std::fs::read_to_string(temp_path)?; - - // Clean the content by removing comment lines - let processed_content = Self::clean_content(content); - Ok(processed_content) - } - - /// Find the first available editor - fn find_available_editor() -> String { - let editors = ["vi", "nano"]; - - for editor in &editors { - if process::Command::new(editor) - .arg("--version") - .output() - .is_ok() - { - return editor.to_string(); - } - } - - // Fallback to vi (should be available on most Unix systems) - "vi".to_string() - } - - /// Get the initial template for new entries - fn get_template() -> String { - r#" - -# Enter your journal entry above this line -# Lines starting with # are comments and will be ignored -# You can use annotations: -# @person - to mention people -# ::project - to reference projects -# +tag - to add tags -# -# Save and exit to create the entry (:wq in vim) -# Exit without saving to cancel (ZQ in vim or Ctrl+C) -"# - .to_string() - } - - /// Clean content by removing comment lines and tempy lines at the beginning - fn clean_content(content: String) -> String { - let lines: Vec<&str> = content - .lines() - .filter(|line| !line.trim().starts_with('#')) - .collect(); - lines.join("\n").trim().to_string() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_get_template() { - let template = Cli::get_template(); - assert!(template.contains("# Enter your journal entry")); - assert!(template.contains("@person")); - assert!(template.contains("::project")); - assert!(template.contains("+tag")); - } - - #[test] - fn test_find_available_editor() { - let editor = Cli::find_available_editor(); - // Should return one of our supported editors or fallback to vi - assert!(editor == "vi" || editor == "nano"); - } - - #[test] - fn test_validate_id_format_valid() { - assert!(Cli::validate_id_format("20250905").is_ok()); - assert!(Cli::validate_id_format("20231231").is_ok()); - assert!(Cli::validate_id_format("20240229").is_ok()); // Leap year - } - - #[test] - fn test_validate_id_format_invalid() { - assert!(Cli::validate_id_format("2025905").is_err()); // Too short - assert!(Cli::validate_id_format("202509055").is_err()); // Too long - assert!(Cli::validate_id_format("20250932").is_err()); // Invalid day - assert!(Cli::validate_id_format("20251301").is_err()); // Invalid month - assert!(Cli::validate_id_format("abcd1234").is_err()); // Non-numeric - assert!(Cli::validate_id_format("").is_err()); // Empty - } - - #[test] - fn test_highlight_annotations() { - let content = "Worked with @alice on ::project using +rust"; - let result = Cli::highlight_annotations(content); - // For now, it should just return the content as-is - assert_eq!(result, content); - } - - #[test] - fn test_show_command_with_valid_entry() { - let temp_dir = TempDir::new().unwrap(); - let storage = LocalEntryStorage::new(Some(temp_dir.path().to_path_buf())).unwrap(); - - // Create a test entry - let entry = Entry::new( - "Test content with @alice and +rust".to_string(), - "20250905".to_string(), - ); - entry.save(&storage).unwrap(); - - // Test show command - should not panic - let result = Cli::handle_show_command("20250905".to_string(), false, &storage); - assert!(result.is_ok()); - } - - #[test] - fn test_show_command_with_invalid_id_format() { - let temp_dir = TempDir::new().unwrap(); - let storage = LocalEntryStorage::new(Some(temp_dir.path().to_path_buf())).unwrap(); - - // Test with invalid ID format - let result = Cli::handle_show_command("invalid".to_string(), false, &storage); - assert!(result.is_err()); - } - - #[test] - fn test_display_function_with_empty_annotations() { - // Create a test entry with no annotations - let entry = Entry::new( - "Simple content with no annotations".to_string(), - "20250905".to_string(), - ); - - // These functions should handle empty annotations gracefully - Cli::display_default_format(&entry); - } -} diff --git a/src/data/entry.rs b/src/data/entry.rs new file mode 100644 index 0000000..8d9e176 --- /dev/null +++ b/src/data/entry.rs @@ -0,0 +1,43 @@ +use chrono::NaiveDate; + +#[derive(Debug, Clone)] +pub struct Entry { + pub date: NaiveDate, + pub content: String, +} + +impl Entry { + pub fn new(date: NaiveDate) -> Self { + Self { + date, + content: String::new(), + } + } + + pub fn with_content(date: NaiveDate, content: String) -> Self { + Self { date, content } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_entry_creation() { + let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); + let entry = Entry::new(date); + + assert_eq!(entry.date, date); + assert!(entry.content.is_empty()); + } + + #[test] + fn test_entry_with_content() { + let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); + let content = "Today I learned Rust!".to_string(); + let entry = Entry::with_content(date, content.clone()); + + assert_eq!(entry.content, content); + } +} diff --git a/src/data/mod.rs b/src/data/mod.rs new file mode 100644 index 0000000..5e0bb04 --- /dev/null +++ b/src/data/mod.rs @@ -0,0 +1,5 @@ +pub mod entry; +pub mod storage; + +pub use entry::*; +pub use storage::*; diff --git a/src/data/storage.rs b/src/data/storage.rs new file mode 100644 index 0000000..d98dfd2 --- /dev/null +++ b/src/data/storage.rs @@ -0,0 +1,106 @@ +use crate::data::entry::Entry; +use crate::utils::date::format_entry_date; +use chrono::{Datelike, NaiveDate}; +use color_eyre::{Result, eyre::Ok}; +use std::path::{Path, PathBuf}; + +/// Handels file system operations for journal entries +pub struct Storage { + base_dir: PathBuf, +} + +impl Storage { + /// Creates a storage instance with custom base directory (absolute path) + pub fn new(base_dir: PathBuf) -> Self { + Self { base_dir } + } + + /// Creates a storage instance with default base directory in user's home + pub fn default() -> Result { + let home_dir = dirs::home_dir() + .ok_or_else(|| color_eyre::eyre::eyre!("Could not find home directory"))?; + + let base_dir = home_dir.join(".devlog").join("entries"); + Ok(Self::new(base_dir)) + } + + /// Load an entry for a specific date + pub fn load_entry(&self, date: NaiveDate) -> Result { + let path = self.generate_file_path(&date); + let content = if path.exists() { + std::fs::read_to_string(&path)? + } else { + String::new() + }; + Ok(Entry::with_content(date, content)) + } + + /// Save the entry to the storage layer + pub fn save_entry(&self, entry: &Entry) -> Result<()> { + let path = self.generate_file_path(&entry.date); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, &entry.content)?; + Ok(()) + } + + /// Get the base directory path (for directory scanning) + pub fn get_base_dir(&self) -> &Path { + &self.base_dir + } + + fn generate_file_path(&self, date: &NaiveDate) -> PathBuf { + let year = date.year(); + let month = date.month(); + let date_str = format_entry_date(date); + + self.base_dir + .join(format!("{}", year)) + .join(format!("{:02}", month)) + .join(format!("{}.md", date_str)) + } +} + +#[cfg(test)] +mod tests { + use std::env::temp_dir; + + use super::*; + use tempfile::TempDir; + + #[test] + fn test_storage_creation() { + let temp_dir = TempDir::new().unwrap(); + let storage = Storage::new(temp_dir.path().to_path_buf()); + assert_eq!(storage.base_dir, temp_dir.path()); + } + + #[test] + fn test_path_generation() { + let temp_dir = TempDir::new().unwrap(); + let storage = Storage::new(temp_dir.path().to_path_buf()); + let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); + let path = storage.generate_file_path(&date); + + let path_str = path.to_string_lossy(); + assert!(path_str.contains("2025")); + assert!(path_str.contains("03")); + assert!(path_str.ends_with("20250315.md")); + } + + #[test] + fn test_save_and_load() { + let temp_dir = TempDir::new().unwrap(); + let storage = Storage::new(temp_dir.path().to_path_buf()); + let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); + let content = "Test content".to_string(); + + let entry = Entry::with_content(date, content.clone()); + storage.save_entry(&entry).unwrap(); + + let loaded_entry = storage.load_entry(date).unwrap(); + assert_eq!(loaded_entry.content, content); + assert_eq!(loaded_entry.date, date); + } +} diff --git a/src/entry.rs b/src/entry.rs deleted file mode 100644 index 516ebcb..0000000 --- a/src/entry.rs +++ /dev/null @@ -1,492 +0,0 @@ -use crate::annotations::{AnnotationParser, RegexAnnotationParser}; -use crate::events::EntryEvent; -use crate::storage::EntryStorage; -use chrono::{DateTime, Local}; - -#[cfg(test)] -use crate::storage::LocalEntryStorage; - -/// Current state of an entry (derived from events) -#[derive(Debug, Clone)] -pub struct EntryState { - pub id: String, - pub created_at: DateTime, - pub updated_at: DateTime, - pub content: String, - pub tags: Vec, - pub people: Vec, - pub projects: Vec, -} - -impl Default for EntryState { - fn default() -> Self { - let now = Local::now(); - Self { - id: String::new(), - created_at: now, - updated_at: now, - content: String::new(), - tags: Vec::new(), - people: Vec::new(), - projects: Vec::new(), - } - } -} - -/// The main Entry aggregate that manages events and state -pub struct Entry { - events: Vec, - state: EntryState, - annotation_parser: Box, -} - -impl Entry { - /// Create a new entry with initial content and ID - pub fn new(content: String, id: String) -> Self { - // Start with empty entry and apply events - let mut entry = Entry { - events: Vec::new(), - state: EntryState::default(), - annotation_parser: Box::new(RegexAnnotationParser::new()), - }; - - let event = EntryEvent::Created { - id, - content, - timestamp: Local::now(), - }; - entry.apply_event(event); - entry.parse_annotations(); // Automatically parse annotations on creation - entry - } - - /// Update the content and automatically reparse annotations - pub fn update_content(&mut self, new_content: String) { - let event = EntryEvent::ContentUpdated { - content: new_content.clone(), - timestamp: Local::now(), - }; - - self.apply_event(event); - self.parse_annotations(); // reparse annotations when content changes - } - - /// Parse annotations and record the parsing event - fn parse_annotations(&mut self) { - let annotations = self.annotation_parser.parse(&self.state.content); - - let event = EntryEvent::AnnotationParsed { - tags: annotations.tags, - people: annotations.people, - projects: annotations.projects, - timestamp: Local::now(), - }; - - self.apply_event(event); - } - - /// Apply an event to update the current state - fn apply_event(&mut self, event: EntryEvent) { - match &event { - EntryEvent::Created { - id, - content, - timestamp, - } => { - self.state.id = id.clone(); - self.state.content = content.clone(); - self.state.created_at = *timestamp; - self.state.updated_at = *timestamp; - } - EntryEvent::ContentUpdated { content, timestamp } => { - self.state.content = content.clone(); - self.state.updated_at = *timestamp; - } - EntryEvent::AnnotationParsed { - tags, - people, - projects, - timestamp, - } => { - self.state.tags = tags.clone(); - self.state.people = people.clone(); - self.state.projects = projects.clone(); - self.state.updated_at = *timestamp; - } - } - self.events.push(event); - } - - /// Get the current state (what user sees) - pub fn current_state(&self) -> &EntryState { - &self.state - } - - /// Get all events (for storage or debugging) - #[allow(dead_code)] - pub fn events(&self) -> &[EntryEvent] { - &self.events - } - - /// Rebuild entry from events - pub fn from_events(events: Vec) -> Option { - if events.is_empty() { - return None; - } - - // Start with default state - let mut entry = Entry { - events: Vec::new(), - state: EntryState::default(), - annotation_parser: Box::new(RegexAnnotationParser::new()), - }; - - // Apply all events to the state - for event in events { - entry.apply_event(event); - } - - Some(entry) - } - - /// Convert current state to markdown content - pub fn to_markdown(&self) -> String { - format!( - r#"--- -id: {} -created_at: {} -updated_at: {} -tags: [{}] -people: [{}] -projects: [{}] ---- - -{} -"#, - self.state.id, - self.state.created_at.format("%Y-%m-%dT%H:%M:%S%:z"), - self.state.updated_at.format("%Y-%m-%dT%H:%M:%S%:z"), - self.state.tags.join(", "), - self.state.people.join(", "), - self.state.projects.join(", "), - self.state.content, - ) - } - - /// Save entry to storage - pub fn save(&self, storage: &dyn EntryStorage) -> Result<(), Box> { - let date = &self.state.id; - - // Save all events - storage.save_events(date, &self.events)?; - - // Save current markdown - let markdown = self.to_markdown(); - storage.save_markdown(date, &markdown)?; - - Ok(()) - } - - /// Load entry from storage - pub fn load( - date: &str, - storage: &dyn EntryStorage, - ) -> Result, Box> { - let events = storage.load_events(date)?; - Ok(Entry::from_events(events)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::TimeZone; - - fn create_test_timestamp() -> DateTime { - Local.with_ymd_and_hms(2025, 9, 5, 14, 30, 0).unwrap() - } - - #[test] - fn test_new_entry_basic() { - let entry = Entry::new("Test content".to_string(), "20250905".to_string()); - let state = entry.current_state(); - - assert_eq!(state.content, "Test content"); - assert_eq!(state.id, "20250905"); - - // Should have 2 events: Created and AnnotationParsed - assert_eq!(entry.events.len(), 2); - } - - #[test] - fn test_new_entry_with_annotations() { - let entry = Entry::new( - "Worked with @alice on ::search_engine using +rust".to_string(), - "20250905".to_string(), - ); - let state = entry.current_state(); - - assert_eq!( - state.content, - "Worked with @alice on ::search_engine using +rust" - ); - assert_eq!(state.people.len(), 1); - assert_eq!(state.people[0], "alice"); - assert_eq!(state.projects.len(), 1); - assert_eq!(state.projects[0], "search_engine"); - assert_eq!(state.tags.len(), 1); - assert_eq!(state.tags[0], "rust"); - - // Should have 2 events: Created and AnnotationParsed - assert_eq!(entry.events().len(), 2); - } - - #[test] - fn test_new_entry_preserves_annotation_order() { - let entry = Entry::new( - "Met @alice then @bob then @alice again".to_string(), - "20250905".to_string(), - ); - let state = entry.current_state(); - - // Vec preserves order and allows duplicates - assert_eq!(state.people.len(), 3); - assert_eq!(state.people[0], "alice"); - assert_eq!(state.people[1], "bob"); - assert_eq!(state.people[2], "alice"); - } - - #[test] - fn test_update_content() { - let mut entry = Entry::new("Initial content".to_string(), "20250905".to_string()); - let initial_events = entry.events().len(); - - entry.update_content("Updated with @bob and +learning".to_string()); - - let state = entry.current_state(); - assert_eq!(state.content, "Updated with @bob and +learning"); - assert_eq!(state.people[0], "bob"); - assert_eq!(state.tags[0], "learning"); - - // Should have added ContentUpdated and AnnotationParsed events - assert_eq!(entry.events().len(), initial_events + 2); - } - - #[test] - fn test_multiple_content_updates() { - let mut entry = Entry::new("Initial".to_string(), "20250905".to_string()); - - entry.update_content("First update @alice".to_string()); - entry.update_content("Second update @bob +rust".to_string()); - - let state = entry.current_state(); - assert_eq!(state.content, "Second update @bob +rust"); - assert_eq!(state.people[0], "bob"); - assert_eq!(state.tags[0], "rust"); - - // Should have 6 events: Created, AnnotationParsed, ContentUpdated, AnnotationParsed, ContentUpdated, AnnotationParsed - assert_eq!(entry.events().len(), 6); - } - - #[test] - fn test_from_events_empty() { - let result = Entry::from_events(Vec::new()); - assert!(result.is_none()); - } - - #[test] - fn test_from_events_single_created() { - let timestamp = create_test_timestamp(); - let events = vec![EntryEvent::Created { - id: "20250905".to_string(), - content: "Test content".to_string(), - timestamp, - }]; - - let entry = Entry::from_events(events).unwrap(); - let state = entry.current_state(); - - assert_eq!(state.id, "20250905"); - assert_eq!(state.content, "Test content"); - assert_eq!(state.created_at, timestamp); - assert_eq!(state.updated_at, timestamp); - assert_eq!(entry.events().len(), 1); - } - - #[test] - fn test_from_events_with_annotations() { - let timestamp = create_test_timestamp(); - let events = vec![ - EntryEvent::Created { - id: "20250905".to_string(), - content: "Test content @alice".to_string(), - timestamp, - }, - EntryEvent::AnnotationParsed { - tags: Vec::new(), - people: vec!["alice".to_string()], - projects: Vec::new(), - timestamp, - }, - ]; - - let entry = Entry::from_events(events).unwrap(); - let state = entry.current_state(); - - assert_eq!(state.content, "Test content @alice"); - assert_eq!(state.people[0], "alice"); - assert_eq!(entry.events().len(), 2); - } - - #[test] - fn test_from_events_complex_sequence() { - let timestamp = create_test_timestamp(); - let events = vec![ - EntryEvent::Created { - id: "20250905".to_string(), - content: "Initial content".to_string(), - timestamp, - }, - EntryEvent::AnnotationParsed { - tags: Vec::new(), - people: Vec::new(), - projects: Vec::new(), - timestamp, - }, - EntryEvent::ContentUpdated { - content: "Updated with @alice +rust".to_string(), - timestamp, - }, - EntryEvent::AnnotationParsed { - tags: vec!["rust".to_string()], - people: vec!["alice".to_string()], - projects: Vec::new(), - timestamp, - }, - ]; - - let entry = Entry::from_events(events).unwrap(); - let state = entry.current_state(); - - assert_eq!(state.content, "Updated with @alice +rust"); - assert_eq!(state.people[0], "alice"); - assert_eq!(state.tags[0], "rust"); - assert_eq!(entry.events().len(), 4); - } - - #[test] - fn test_new_and_from_events_consistency() { - // Create entry using new() - let entry1 = Entry::new( - "Test content @alice +rust".to_string(), - "20250905".to_string(), - ); - - // Create entry using from_events() with same events - let events = entry1.events().to_vec(); - let entry2 = Entry::from_events(events).unwrap(); - - // Both should have identical state - assert_eq!(entry1.state.id, entry2.state.id); - assert_eq!(entry1.state.content, entry2.state.content); - assert_eq!(entry1.state.people, entry2.state.people); - assert_eq!(entry1.state.tags, entry2.state.tags); - assert_eq!(entry1.state.projects, entry2.state.projects); - assert_eq!(entry1.events().len(), entry2.events().len()); - } - - #[test] - fn test_to_markdown_basic() { - let entry = Entry::new("Simple content".to_string(), "20250905".to_string()); - let markdown = entry.to_markdown(); - - assert!(markdown.contains("---")); - assert!(markdown.contains("id: 20250905")); - assert!(markdown.contains("created_at:")); - assert!(markdown.contains("updated_at:")); - assert!(markdown.contains("tags: []")); - assert!(markdown.contains("people: []")); - assert!(markdown.contains("projects: []")); - assert!(markdown.contains("Simple content")); - } - - #[test] - fn test_to_markdown_with_annotations() { - let entry = Entry::new( - "Worked with @alice and @bob on ::project using +rust".to_string(), - "20250905".to_string(), - ); - let markdown = entry.to_markdown(); - - assert!(markdown.contains("---")); - assert!(markdown.contains("people: [alice, bob]")); - assert!(markdown.contains("projects: [project]")); - assert!(markdown.contains("tags: [rust]")); - assert!(markdown.contains("Worked with @alice and @bob")); - - // Test ISO 8601 timestamp format - assert!(markdown.contains("T") && (markdown.contains("+") || markdown.contains("-"))); - } - - #[test] - fn test_to_markdown_empty_annotations() { - let entry = Entry::new("No annotations here".to_string(), "20250905".to_string()); - let markdown = entry.to_markdown(); - - assert!(markdown.contains("tags: []")); - assert!(markdown.contains("people: []")); - assert!(markdown.contains("projects: []")); - } - - #[test] - fn test_to_markdown_multiple_annotations() { - let entry = Entry::new( - "Complex: @alice @bob @charlie +rust +tokio +async ::project1 ::project2".to_string(), - "20250905".to_string(), - ); - let markdown = entry.to_markdown(); - - assert!(markdown.contains("people: [alice, bob, charlie]")); - assert!(markdown.contains("tags: [rust, tokio, async]")); - assert!(markdown.contains("projects: [project1, project2]")); - } - - #[test] - fn test_save_and_edit_no_duplicate_events() { - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let storage = LocalEntryStorage::new(Some(temp_dir.path().to_path_buf())).unwrap(); - - // Simulate 'devlog new' command - let entry = Entry::new( - "Initial content @alice +rust".to_string(), - "20250905".to_string(), - ); - entry.save(&storage).unwrap(); - - // Verify initial events are saved - let initial_events = storage.load_events(&entry.current_state().id).unwrap(); - assert_eq!(initial_events.len(), 2); // Created + AnnotationParsed - - // Simulate 'devlog edit' command - let mut loaded_entry = Entry::load(&entry.current_state().id, &storage) - .unwrap() - .unwrap(); - loaded_entry.update_content("Updated content @bob +golang".to_string()); - loaded_entry.save(&storage).unwrap(); - - // Verify no duplicate events - let final_events = storage.load_events(&entry.current_state().id).unwrap(); - assert_eq!(final_events.len(), 4); // Created + AnnotationParsed + ContentUpdated + AnnotationParsed - - // Verify the content is correct - let final_entry = Entry::load(&entry.current_state().id, &storage) - .unwrap() - .unwrap(); - let state = final_entry.current_state(); - assert_eq!(state.content, "Updated content @bob +golang"); - assert_eq!(state.people, vec!["bob"]); - assert_eq!(state.tags, vec!["golang"]); - } -} diff --git a/src/events.rs b/src/events.rs deleted file mode 100644 index 6e36e37..0000000 --- a/src/events.rs +++ /dev/null @@ -1,124 +0,0 @@ -use chrono::{DateTime, Local}; -use serde::{Deserialize, Serialize}; - -/// Events that can happen to an entry -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "data")] -pub enum EntryEvent { - Created { - id: String, - content: String, - timestamp: DateTime, - }, - ContentUpdated { - content: String, - timestamp: DateTime, - }, - AnnotationParsed { - tags: Vec, - people: Vec, - projects: Vec, - timestamp: DateTime, - }, -} - -impl EntryEvent { - /// Get the timestamp of when this event occured - #[allow(dead_code)] - pub fn timestamp(&self) -> DateTime { - match self { - // * is the dereference operator in Rust - // Since `DateTime` implements the `Copy` trait - // dereferencing with `*` creates a copy of the value - EntryEvent::Created { timestamp, .. } => *timestamp, - EntryEvent::ContentUpdated { timestamp, .. } => *timestamp, - EntryEvent::AnnotationParsed { timestamp, .. } => *timestamp, - } - } - - /// Get the entry ID this event belongs to - #[allow(dead_code)] - pub fn entry_id(&self) -> Option<&str> { - match self { - EntryEvent::Created { id, .. } => Some(id), - _ => None, // Other events don't carry the ID directly - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::TimeZone; - - fn create_test_timestamp() -> DateTime { - Local.with_ymd_and_hms(2025, 9, 6, 10, 30, 0).unwrap() - } - - #[test] - fn test_created_event_creation() { - let timestamp = create_test_timestamp(); - let event = EntryEvent::Created { - id: "20250906".to_string(), - content: "Test content".to_string(), - timestamp, - }; - - assert_eq!(event.timestamp(), timestamp); - assert_eq!(event.entry_id(), Some("20250906")); - } - - #[test] - fn test_content_updated_event() { - let timestamp = create_test_timestamp(); - let event = EntryEvent::ContentUpdated { - content: "Updated content".to_string(), - timestamp, - }; - - assert_eq!(event.timestamp(), timestamp); - assert_eq!(event.entry_id(), None); // Update event itself doesn't have event id - } - - #[test] - fn test_serialized_format_has_type_tag() { - let timestamp = create_test_timestamp(); - let event = EntryEvent::Created { - id: "test".to_string(), - content: "content".to_string(), - timestamp, - }; - - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"type\":\"Created\"")); - } - - #[test] - fn test_all_event_types_timestamp_method() { - let timestamp1 = create_test_timestamp(); - let timestamp2 = Local.with_ymd_and_hms(2025, 9, 7, 15, 45, 30).unwrap(); - let timestamp3 = Local.with_ymd_and_hms(2025, 9, 8, 9, 15, 45).unwrap(); - - let created = EntryEvent::Created { - id: "20250906".to_string(), - content: "content".to_string(), - timestamp: timestamp1, - }; - - let updated = EntryEvent::ContentUpdated { - content: "new content".to_string(), - timestamp: timestamp2, - }; - - let parsed = EntryEvent::AnnotationParsed { - tags: vec!["awesome".to_string()], - people: vec!["alice".to_string()], - projects: vec!["devlog".to_string()], - timestamp: timestamp3, - }; - - assert_eq!(created.timestamp(), timestamp1); - assert_eq!(updated.timestamp(), timestamp2); - assert_eq!(parsed.timestamp(), timestamp3); - } -} diff --git a/src/main.rs b/src/main.rs index bfa197e..12394f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,16 @@ -mod annotations; -mod cli; -mod entry; -mod events; -mod storage; - -use cli::Cli; -use std::process; - -fn main() { - if let Err(e) = Cli::run() { - eprintln!("Error: {}", e); - process::exit(1); - } +mod app; +mod data; +mod navigation; +mod ui; +mod utils; + +use color_eyre::Result; + +fn main() -> Result<()> { + // Set up error handling + color_eyre::install()?; + + ui::run()?; + + Ok(()) } diff --git a/src/navigation/mod.rs b/src/navigation/mod.rs new file mode 100644 index 0000000..2dc980d --- /dev/null +++ b/src/navigation/mod.rs @@ -0,0 +1,5 @@ +pub mod state; +pub mod tree; + +pub use state::*; +pub use tree::*; diff --git a/src/navigation/state.rs b/src/navigation/state.rs new file mode 100644 index 0000000..9cf872b --- /dev/null +++ b/src/navigation/state.rs @@ -0,0 +1,439 @@ +use crate::{ + data::{Entry, Storage}, + navigation::EntryTree, + utils::date::parse_entry_date, +}; +use chrono::{Datelike, NaiveDate}; +use color_eyre::Result; +use std::fs; +use walkdir::WalkDir; + +/// Navigation state for the application +#[derive(Debug)] +pub struct NavigationState { + pub tree: EntryTree, + pub selected_date: Option, + pub expanded_year: Option, + pub expanded_month: Option, +} + +impl NavigationState { + /// Create a new navigation state + pub fn new() -> Self { + Self { + tree: EntryTree::new(), + selected_date: None, + expanded_year: None, + expanded_month: None, + } + } + + /// Load the navigation state from storage + pub fn load_from_storage(storage: &Storage) -> Result { + let mut state = Self::new(); + state.refresh_from_storage(storage); + Ok(state) + } + + /// Refresh the tree from storage when files actually change: create/update/delete + pub fn refresh_from_storage(&mut self, storage: &Storage) -> Result<()> { + // Clear existing tree + self.tree = EntryTree::new(); + + // Get the base directory path + let base_path = storage.get_base_dir(); + if !base_path.exists() { + return Ok(()); + } + + // Walk through all files recursively + for entry in WalkDir::new(base_path) + .into_iter() + .filter_map(|e| e.ok()) // Skip errors, continue with valid entries + .filter(|e| e.file_type().is_file()) // Only process files + .filter(|e| { + // Only process .md files + e.path().extension().map_or(false, |ext| ext == "md") + }) + { + let file_path = entry.path(); + + // Extract filename (YYYYMMDD) + if let Some(file_stem) = file_path.file_stem() { + if let Some(date_str) = file_stem.to_str() { + match parse_entry_date(date_str) { + Ok(date) => { + // Load the entry using storage + match storage.load_entry(date) { + Ok(entry) => { + self.tree.add_entry(entry); + } + Err(e) => { + // Log warning but continue processing other files + eprintln!("Warning: Failed to load entry for {}: {}", date, e); + } + } + } + Err(_) => { + // Skip files with invalid date formats + eprintln!("Warning: Failed to process date str: {}", date_str); + } + } + } + } + } + + Ok(()) + } + + /// Select a specific entry date + pub fn select_date(&mut self, date: NaiveDate) { + self.selected_date = Some(date); + } + + /// Clear the selection + pub fn clear_selection(&mut self) { + self.selected_date = None; + } + + /// Get the currently selected date + pub fn get_selected_date(&self) -> Option { + self.selected_date + } + + /// Move selection to the next entry + pub fn select_next(&mut self) { + let dates = self.tree.get_all_dates(); + if dates.is_empty() { + return; + } + + match self.selected_date { + None => self.selected_date = dates.first().copied(), + Some(current) => { + if let Some(pos) = dates.iter().position(|&d| d == current) { + if pos + 1 < dates.len() { + self.selected_date = Some(dates[pos + 1]); + } + } + } + } + } + + /// Move selection to the previous entry + pub fn select_prev(&mut self) { + let dates = self.tree.get_all_dates(); + if dates.is_empty() { + return; + } + + match self.selected_date { + None => self.selected_date = dates.last().copied(), + Some(current) => { + if let Some(pos) = dates.iter().position(|&d| d == current) { + if pos > 0 { + self.selected_date = Some(dates[pos - 1]); + } + } + } + } + } + + /// Expand a specific year (collapses any other expanded year) + pub fn expand_year(&mut self, year: u32) { + self.expanded_year = Some(year); + self.expanded_month = None; // Reset month expansion + } + + /// Expand a specific month with the expanded year + pub fn expand_month(&mut self, month: u32) { + if self.expanded_year.is_some() { + self.expanded_month = Some(month); + } + } + + /// Collapse the expanded year (and month) + pub fn collapse_year(&mut self) { + self.expanded_year = None; + self.expanded_month = None; + } + + /// Collapse the expanded month (keep year expanded) + pub fn collapse_month(&mut self) { + self.expanded_month = None; + } + + /// Toggle year expansion + pub fn toggle_year(&mut self, year: u32) { + if self.expanded_year == Some(year) { + self.collapse_year(); + } else { + self.expand_year(year); + } + } + + /// Toggle month expansion + pub fn toggle_month(&mut self, month: u32) { + if self.expanded_year.is_some() { + if self.expanded_month == Some(month) { + self.collapse_month(); + } else { + self.expand_month(month); + } + } + } + + /// Check if a year is expanded + pub fn is_year_expanded(&self, year: u32) -> bool { + self.expanded_year == Some(year) + } + + /// Check if a month is expanded + pub fn is_month_expanded(&self, month: u32) -> bool { + self.expanded_month == Some(month) + } + + /// Get the currently expanded year + pub fn get_expanded_year(&self) -> Option { + self.expanded_year + } + + /// Get the currently expanded month + pub fn get_expanded_month(&self) -> Option { + self.expanded_month + } + + /// Add a new entry to the tree and optionally select it + pub fn add_entry(&mut self, entry: Entry, select: bool) { + let date = entry.date; + self.tree.add_entry(entry); + + if select { + self.selected_date = Some(date); + // Auto-expand to show the new entry + self.expand_year(date.year() as u32); + self.expand_month(date.month()); + } + } + + /// Get all available years in descending order + pub fn get_years(&self) -> Vec { + let mut years = self.tree.get_years(); + years.sort_by(|a, b| b.cmp(a)); + years + } + + /// Get all months for the expanded year in descending order + pub fn get_months_for_expanded_year(&self) -> Vec { + match self.expanded_year { + Some(year) => { + let mut months = self.tree.get_months_for_year(year); + months.sort_by(|a, b| b.cmp(a)); + months + } + None => Vec::new(), + } + } + + /// Get all days for the expanded month in descending order + pub fn get_days_for_expanded_month(&self) -> Vec { + match (self.expanded_year, self.expanded_month) { + (Some(year), Some(month)) => { + let mut days = self.tree.get_days_for_month(year, month); + days.sort_by(|a, b| b.cmp(a)); + days + } + _ => Vec::new(), + } + } + + /// Get the currently selected entry + pub fn get_selected_entry(&self) -> Option<&Entry> { + self.selected_date + .and_then(|date| self.tree.get_entry(&date)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn create_test_entry(year: i32, month: u32, day: u32) -> Entry { + let date = NaiveDate::from_ymd_opt(year, month, day).unwrap(); + Entry::with_content(date, "Test content".to_string()) + } + + #[test] + fn test_new_navigation_state() { + let state = NavigationState::new(); + assert!(state.tree.is_empty()); + assert!(state.selected_date.is_none()); + assert!(state.expanded_year.is_none()); + assert!(state.expanded_month.is_none()); + } + + #[test] + fn test_year_expansion() { + let mut state = NavigationState::new(); + + // Initially nothing expanded + assert!(!state.is_year_expanded(2025)); + + // Expand 2025 + state.expand_year(2025); + assert!(state.is_year_expanded(2025)); + assert!(!state.is_year_expanded(2024)); + + // Expanding different year should collapse previous + state.expand_year(2024); + assert!(state.is_year_expanded(2024)); + assert!(!state.is_year_expanded(2025)); + + // Toggle should collapse + state.toggle_year(2024); + assert!(!state.is_year_expanded(2024)); + } + + #[test] + fn test_month_expansion() { + let mut state = NavigationState::new(); + + // Can't expand month without year + state.expand_month(3); + assert!(!state.is_month_expanded(3)); + + // Expand year first + state.expand_year(2025); + state.expand_month(3); + assert!(state.is_month_expanded(3)); + + // Collapsing year should collapse month + state.collapse_year(); + assert!(!state.is_year_expanded(2025)); + assert!(!state.is_month_expanded(3)); + } + + #[test] + fn test_auto_expansion_on_add() { + let mut state = NavigationState::new(); + let entry = create_test_entry(2025, 3, 15); + + state.add_entry(entry, true); + + // Should auto-expand to show the new entry + assert!(state.is_year_expanded(2025)); + assert!(state.is_month_expanded(3)); + assert_eq!( + state.get_selected_date(), + Some(NaiveDate::from_ymd_opt(2025, 3, 15).unwrap()) + ); + } + + #[test] + fn test_expanded_year_months() { + let mut state = NavigationState::new(); + + state.add_entry(create_test_entry(2025, 3, 15), false); + state.add_entry(create_test_entry(2025, 4, 10), false); + state.add_entry(create_test_entry(2024, 12, 31), false); + + // No months when no year expanded + assert!(state.get_months_for_expanded_year().is_empty()); + + // Expand 2025, months are sorted in descending order + state.expand_year(2025); + let months = state.get_months_for_expanded_year(); + assert_eq!(months, vec![4, 3]); + + // Expand 2024 + state.expand_year(2024); + let months = state.get_months_for_expanded_year(); + assert_eq!(months, vec![12]); + } + + #[test] + fn test_refresh_from_storage_empty_directory() { + let temp_dir = TempDir::new().unwrap(); + let storage = Storage::new(temp_dir.path().to_path_buf()); + let mut state = NavigationState::new(); + + let result = state.refresh_from_storage(&storage); + assert!(result.is_ok()); + assert!(state.tree.is_empty()); + } + + #[test] + fn test_refresh_from_storage_with_entries() { + let temp_dir = TempDir::new().unwrap(); + let storage = Storage::new(temp_dir.path().to_path_buf()); + + // Create some test entries using storage + let entries = vec![ + create_test_entry(2025, 3, 15), + create_test_entry(2025, 3, 16), + create_test_entry(2025, 4, 1), + create_test_entry(2024, 12, 31), + ]; + + // Save entries to create the directory structure + for entry in &entries { + storage.save_entry(entry).unwrap(); + } + + // Now refresh and check that all entries are loaded + let mut state = NavigationState::new(); + let result = state.refresh_from_storage(&storage); + + assert!(result.is_ok()); + assert!(!state.tree.is_empty()); + + // Check that all dates are loaded + let loaded_dates = state.tree.get_all_dates(); + assert_eq!(loaded_dates.len(), 4); + + for entry in &entries { + assert!(loaded_dates.contains(&entry.date)); + assert!(state.tree.get_entry(&entry.date).is_some()); + } + } + + #[test] + fn test_refresh_from_storage_ignores_invalid_filenames() { + let temp_dir = TempDir::new().unwrap(); + let storage = Storage::new(temp_dir.path().to_path_buf()); + + // Create directory structure + let year_dir = temp_dir.path().join("2025").join("03"); + fs::create_dir_all(&year_dir).unwrap(); + + // Create files with invalid date formats (should be ignored) + fs::write(year_dir.join("invalid.md"), "Invalid filename").unwrap(); + fs::write(year_dir.join("2025031.md"), "Too short").unwrap(); + fs::write(year_dir.join("202503155.md"), "Too long").unwrap(); + fs::write(year_dir.join("20250229.md"), "Invalid date").unwrap(); + fs::write(year_dir.join("readme.md"), "Not a date").unwrap(); + + // Create valid files + fs::write(year_dir.join("20250315.md"), "Valid entry 1").unwrap(); + fs::write(year_dir.join("20250316.md"), "Valid entry 2").unwrap(); + + let mut state = NavigationState::new(); + let result = state.refresh_from_storage(&storage); + + assert!(result.is_ok()); + + // Should only load the valid files, ignore invalid ones + let loaded_dates = state.tree.get_all_dates(); + assert_eq!(loaded_dates.len(), 2); + + let expected_dates = vec![ + NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(), + NaiveDate::from_ymd_opt(2025, 3, 16).unwrap(), + ]; + + for expected_date in expected_dates { + assert!(loaded_dates.contains(&expected_date)); + } + } +} diff --git a/src/navigation/tree.rs b/src/navigation/tree.rs new file mode 100644 index 0000000..ed7c551 --- /dev/null +++ b/src/navigation/tree.rs @@ -0,0 +1,242 @@ +use std::collections::BTreeMap; + +use chrono::{Datelike, NaiveDate}; + +use crate::data::Entry; + +/// Represents a month in the entry tree +#[derive(Debug, Clone)] +pub struct Month { + pub month: u32, + pub entries: BTreeMap, +} + +/// Represents a year in the entry tree +#[derive(Debug, Clone)] +pub struct Year { + pub year: u32, + pub months: BTreeMap, +} + +/// Main tree structure for organizing entries +#[derive(Debug, Clone)] +pub struct EntryTree { + pub years: BTreeMap, +} + +impl Month { + pub fn new(month: u32) -> Self { + Self { + month, + entries: BTreeMap::new(), + } + } + + /// Add an entry to this month + pub fn add_entry(&mut self, entry: Entry) { + let day = entry.date.day(); + self.entries.insert(day, entry); + } + + /// Get entry for a specific day + pub fn get_entry(&self, day: u32) -> Option<&Entry> { + self.entries.get(&day) + } + + /// check if this month has any entries + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +impl Year { + pub fn new(year: u32) -> Self { + Self { + year, + months: BTreeMap::new(), + } + } + + /// Add an entry to this year + pub fn add_entry(&mut self, entry: Entry) { + let month_num = entry.date.month(); + let month = self + .months + .entry(month_num) + .or_insert_with(|| Month::new(month_num)); + month.add_entry(entry); + } + + /// Get all entries in this year + pub fn get_all_entries(&self) -> Vec<&Entry> { + self.months + .values() + .flat_map(|month| month.entries.values()) + .collect() + } + + /// Check if this year has any entries + pub fn is_empty(&self) -> bool { + self.months.is_empty() + } +} + +impl EntryTree { + pub fn new() -> Self { + Self { + years: BTreeMap::new(), + } + } + + /// Add an entry to the tree + pub fn add_entry(&mut self, entry: Entry) { + let year_num = entry.date.year() as u32; + let year = self + .years + .entry(year_num) + .or_insert_with(|| Year::new(year_num)); + year.add_entry(entry); + } + + /// Get an entry by date + pub fn get_entry(&self, date: &NaiveDate) -> Option<&Entry> { + let year_num = date.year() as u32; + let month_num = date.month(); + let day_num = date.day(); + + self.years + .get(&year_num)? + .months + .get(&month_num)? + .entries + .get(&day_num) + } + + /// Get all entries in chronological order + pub fn get_all_entries(&self) -> Vec<&Entry> { + self.years + .values() + .flat_map(|year| year.get_all_entries()) + .collect() + } + + /// Get all entry dates in chronological order + pub fn get_all_dates(&self) -> Vec { + let mut dates = Vec::new(); + + for year in self.years.values() { + for month in year.months.values() { + for entry in month.entries.values() { + dates.push(entry.date); + } + } + } + + dates.sort(); + dates + } + + /// Check if the tree is empty + pub fn is_empty(&self) -> bool { + self.years.is_empty() + } + + /// Get the latest entry date + pub fn get_latest_date(&self) -> Option { + self.get_all_dates().into_iter().last() + } + + /// Get the earliest entry date + pub fn get_earliest_date(&self) -> Option { + self.get_all_dates().into_iter().next() + } + + /// Get all available years + pub fn get_years(&self) -> Vec { + self.years.keys().cloned().collect() + } + + /// Get all months for a specific year + pub fn get_months_for_year(&self, year: u32) -> Vec { + self.years + .get(&year) + .map(|y| y.months.keys().cloned().collect()) + .unwrap_or_default() + } + + /// Get all days for a specific year/month + pub fn get_days_for_month(&self, year: u32, month: u32) -> Vec { + self.years + .get(&year) + .and_then(|y| y.months.get(&month)) + .map(|m| m.entries.keys().cloned().collect()) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_entry(year: i32, month: u32, day: u32, content: &str) -> Entry { + let date = NaiveDate::from_ymd_opt(year, month, day).unwrap(); + Entry::with_content(date, content.to_string()) + } + + #[test] + fn test_empty_tree() { + let tree = EntryTree::new(); + assert!(tree.is_empty()); + assert!(tree.get_latest_date().is_none()); + assert!(tree.get_earliest_date().is_none()); + } + + #[test] + fn test_add_single_entry() { + let mut tree = EntryTree::new(); + let entry = create_test_entry(2025, 3, 15, "Test entry"); + let date = entry.date; + + tree.add_entry(entry); + + assert!(!tree.is_empty()); + assert!(tree.get_entry(&date).is_some()); + } + + #[test] + fn test_multiple_entries() { + let mut tree = EntryTree::new(); + + tree.add_entry(create_test_entry(2025, 3, 15, "Entry 1")); + tree.add_entry(create_test_entry(2025, 3, 16, "Entry 2")); + tree.add_entry(create_test_entry(2025, 4, 1, "Entry 3")); + tree.add_entry(create_test_entry(2024, 12, 31, "Entry 4")); + + let all_dates = tree.get_all_dates(); + assert_eq!(all_dates.len(), 4); + + // Should be in chronological order + assert_eq!(all_dates[0], NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()); + assert_eq!(all_dates[1], NaiveDate::from_ymd_opt(2025, 3, 15).unwrap()); + assert_eq!(all_dates[2], NaiveDate::from_ymd_opt(2025, 3, 16).unwrap()); + assert_eq!(all_dates[3], NaiveDate::from_ymd_opt(2025, 4, 1).unwrap()); + } + + #[test] + fn test_latest_and_earliest() { + let mut tree = EntryTree::new(); + + tree.add_entry(create_test_entry(2025, 3, 15, "Middle")); + tree.add_entry(create_test_entry(2025, 4, 1, "Latest")); + tree.add_entry(create_test_entry(2024, 12, 31, "Earliest")); + + assert_eq!( + tree.get_earliest_date(), + Some(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()) + ); + assert_eq!( + tree.get_latest_date(), + Some(NaiveDate::from_ymd_opt(2025, 4, 1).unwrap()) + ); + } +} diff --git a/src/storage.rs b/src/storage.rs deleted file mode 100644 index d7637f3..0000000 --- a/src/storage.rs +++ /dev/null @@ -1,271 +0,0 @@ -use crate::events::EntryEvent; -use serde_json; -use std::fs; -use std::path::PathBuf; - -/// Trait for handling file storage for `entries` and `events` -pub trait EntryStorage { - /// Save all events for a given date (overwrites existing events) - fn save_events( - &self, - date: &str, - events: &[EntryEvent], - ) -> Result<(), Box>; - - /// Save markdown content (overwrites existing markdown content) - fn save_markdown(&self, date: &str, content: &str) -> Result<(), Box>; - - /// Load all events for a given date - fn load_events(&self, date: &str) -> Result, Box>; - - /// Load markdown content - #[allow(dead_code)] - fn load_markdown(&self, date: &str) -> Result, Box>; - - /// List all entry IDs sorted in descending order (newest first) - fn list_entry_ids(&self) -> Result, Box>; -} - -/// Local file system implementation of entry storage -pub struct LocalEntryStorage { - // `PathBuf` handles cross-platform path separators (`/` on Linux, `\` on Windows) - // It also has built-in methods like `.join()` and `.exists()` - base_dir: PathBuf, -} - -impl LocalEntryStorage { - /// Create a new local storage instance - pub fn new(base_dir: Option) -> Result> { - // The `Box` error type is convinient to capture any error type that implements `std::error::Error` - // Examples: - // fs::create_dir_all(path)?; // std::io::Error - // serde_json::to_String(event)?; // serde_json::Error - // dirs::home_dir().expect(...); // Option -> panic (but could be Result) - - // default storage path: `~/.devlog` - // user custom path: `/custom/path` - let base_dir = base_dir.unwrap_or_else(|| { - dirs::home_dir() - .expect("Could not find home directory") - .join(".devlog") - }); - - // Ensure base directories exist - fs::create_dir_all(base_dir.join("events"))?; - fs::create_dir_all(base_dir.join("entries"))?; - - Ok(Self { base_dir }) - } - - /// Get the event file path for a given date - fn events_path(&self, date: &str) -> PathBuf { - self.base_dir.join("events").join(format!("{}.jsonl", date)) - } - - /// Get the markdown file path for a given date - fn markdown_path(&self, date: &str) -> PathBuf { - self.base_dir.join("entries").join(format!("{}.md", date)) - } -} - -impl EntryStorage for LocalEntryStorage { - /// Save all events for a given date (overwrites existing events) - fn save_events( - &self, - date: &str, - events: &[EntryEvent], - ) -> Result<(), Box> { - let events_path = self.events_path(date); - - let mut content = String::new(); - for event in events { - let event_json = serde_json::to_string(event)?; - content.push_str(&event_json); - content.push('\n'); - } - - fs::write(&events_path, content)?; - Ok(()) - } - - /// Save markdown content (overwrites existing markdown content) - fn save_markdown(&self, date: &str, content: &str) -> Result<(), Box> { - let markdown_path = self.markdown_path(date); - fs::write(&markdown_path, content)?; - Ok(()) - } - - /// Load all events for a given date - fn load_events(&self, date: &str) -> Result, Box> { - let events_path = self.events_path(date); - - if !events_path.exists() { - // Return empty vector for events for a new date - return Ok(Vec::new()); - } - - let content = fs::read_to_string(&events_path)?; - let mut events = Vec::new(); - - for line in content.lines() { - let event: EntryEvent = serde_json::from_str(line)?; - events.push(event); - } - - Ok(events) - } - - /// Load markdown content - fn load_markdown(&self, date: &str) -> Result, Box> { - let markdown_path = self.markdown_path(date); - - if !markdown_path.exists() { - return Ok(None); - } - - let content = fs::read_to_string(&markdown_path)?; - Ok(Some(content)) - } - - /// List all entry IDs sorted in descending order (newest first) - fn list_entry_ids(&self) -> Result, Box> { - let entries_dir = self.base_dir.join("entries"); - - if !entries_dir.exists() { - return Ok(Vec::new()); - } - - let mut entry_ids = Vec::new(); - - for entry in fs::read_dir(entries_dir)? { - // Entry is Result, not DirEntry - // Each individual file/directory read operation could fail due to permission, corrupted filesystem, etc. - let entry = entry?; - - // Get the file name - let file_name = entry.file_name(); - // Convert OsString to String - if let Some(file_name_str) = file_name.to_str() { - // Remove the .md extension - if file_name_str.ends_with(".md") { - let entry_id = file_name_str.strip_suffix(".md").unwrap().to_string(); - entry_ids.push(entry_id); - } - } - } - - // Sort entry IDs in descending order (newest first) - entry_ids.sort_by(|a, b| b.cmp(a)); - - Ok(entry_ids) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::Local; - use tempfile::TempDir; - - #[test] - fn test_storage_operations() -> Result<(), Box> { - let temp_dir = TempDir::new()?; - let storage = LocalEntryStorage::new(Some(temp_dir.path().to_path_buf()))?; - - let now = Local::now(); - let date = format!("{}", now.format("%Y%m%d")); - - // Test event storage with save_events - let events = vec![EntryEvent::Created { - id: date.to_string(), - content: "Test content".to_string(), - timestamp: now, - }]; - - storage.save_events(&date, &events)?; - - // Test event loading - let loaded_events = storage.load_events(&date)?; - assert_eq!(loaded_events.len(), 1); - - // Test markdown storage - let markdown = "# Test Entry\n\nTest content"; - storage.save_markdown(&date, markdown)?; - - // Test markdown loading - let loaded_markdown = storage.load_markdown(&date)?; - assert_eq!(loaded_markdown, Some(markdown.to_string())); - - Ok(()) - } - - #[test] - fn test_save_events_overwrites() -> Result<(), Box> { - let temp_dir = TempDir::new()?; - let storage = LocalEntryStorage::new(Some(temp_dir.path().to_path_buf()))?; - - let now = Local::now(); - let date = format!("{}", now.format("%Y%m%d")); - - // First save some events - let events1 = vec![ - EntryEvent::Created { - id: date.to_string(), - content: "First content".to_string(), - timestamp: now, - }, - EntryEvent::AnnotationParsed { - tags: vec!["first".to_string()], - people: Vec::new(), - projects: Vec::new(), - timestamp: now, - }, - ]; - - storage.save_events(&date, &events1)?; - let loaded = storage.load_events(&date)?; - assert_eq!(loaded.len(), 2); - - // Now save different events (should overwrite) - let events2 = vec![ - EntryEvent::Created { - id: date.to_string(), - content: "Second content".to_string(), - timestamp: now, - }, - EntryEvent::AnnotationParsed { - tags: vec!["second".to_string()], - people: Vec::new(), - projects: Vec::new(), - timestamp: now, - }, - EntryEvent::ContentUpdated { - content: "Updated content".to_string(), - timestamp: now, - }, - ]; - - storage.save_events(&date, &events2)?; - let loaded = storage.load_events(&date)?; - assert_eq!(loaded.len(), 3); // Should have 3 events, not 5 - - Ok(()) - } - - #[test] - fn test_save_empty_events() -> Result<(), Box> { - let temp_dir = TempDir::new()?; - let storage = LocalEntryStorage::new(Some(temp_dir.path().to_path_buf()))?; - - let date = "20250906"; - - // Save empty events list - storage.save_events(date, &[])?; - - // Should load empty list - let loaded = storage.load_events(date)?; - assert_eq!(loaded.len(), 0); - - Ok(()) - } -} diff --git a/src/ui/content.rs b/src/ui/content.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/layout.rs b/src/ui/layout.rs new file mode 100644 index 0000000..9549ad2 --- /dev/null +++ b/src/ui/layout.rs @@ -0,0 +1,59 @@ +use std::default; + +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + widgets::{Block, Borders, Paragraph}, +}; + +use crate::app::App; +/// Render the main UI layout +pub fn render(app: &App, frame: &mut Frame) { + // Split screen: main area + status bar + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), // Main area (at least 3 lines) + Constraint::Length(1), // Status bar (exactly 1 line) + ]) + .split(frame.area()); + + // Render main content area + render_main_content(app, frame, chunks[0]); + + // Render status bar + render_status_bar(app, frame, chunks[1]); +} + +/// Render the main content area +fn render_main_content(app: &App, frame: &mut Frame, area: Rect) { + let content = format!( + "DevLog\n\nCurrent Mode: {}\n\nPress 'q' to quit", + app.mode_string() + ); + + let paragraph = Paragraph::new(content) + .block( + Block::default() + .title("DevLog v1.0.0") + .borders(Borders::ALL), + ) + .style(Style::default().fg(Color::White)); + + frame.render_widget(paragraph, area); +} + +/// Render the status bar at the bottom +fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) { + let status_text = match app.mode { + crate::app::AppMode::Navigation => "[q] Quit | Mode: Navigation", + _ => "", + }; + + let paragraph = Paragraph::new(status_text) + .style(Style::default().bg(Color::Blue).fg(Color::White)) + .block(Block::default()); + + frame.render_widget(paragraph, area); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..5694428 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,74 @@ +pub mod layout; + +use std::io::{self, stdout}; + +use color_eyre::Result; +use crossterm::{ + ExecutableCommand, + event::{self, Event, KeyEventKind}, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, prelude::CrosstermBackend}; + +use crate::app::{self, App}; + +pub fn run() -> Result<()> { + // Normal Mode vs Raw Mode + // ** Normal Mode ** + // - Terminal processes special keys: `ctrl+c` kills program, `enter` submits line + // - Text is buffered till you press Enter. + // - Terminal handles cursor movement, backspace + // ** Raw Mode ** + // - Program gets every single keypress immediately + // - No special key processing (`ctrl+c` won't kill our program) + // - No line buffering + // - Our program contls the entire screen + // - Terminal doesn't echo what we type + + // 1. Switch terminal to raw mode + enable_raw_mode()?; + + // 2. Switch to alternate screen (like vim/less do) + // Save the current terminal content and start the app with a clean screen + stdout().execute(EnterAlternateScreen)?; + // ↑ ↑ ↑ ↑ + // │ │ │ └─ Propagate any errors + // │ │ └─ Command to switch to alternate screen + // │ └─ Execute a terminal command + // └─ Get the standard output stream + + // 3. Create the terminal interface + // stdout() -> CrosstermBackend -> Terminal + let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; + + // 4. Run the app + let mut app = App::new(); + let result = run_app(&mut terminal, &mut app); + + // 5. Cleanup + disable_raw_mode()?; + stdout().execute(LeaveAlternateScreen)?; + + result +} + +fn run_app(terminal: &mut Terminal>, app: &mut App) -> Result<()> { + loop { + // Draw frame + terminal.draw(|frame| { + layout::render(app, frame); + })?; + + // Wait for keypress + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + app.handle_key(key)?; + + if app.should_quit() { + break; + } + } + } + } + Ok(()) +} diff --git a/src/ui/status.rs b/src/ui/status.rs new file mode 100644 index 0000000..03b8cf5 --- /dev/null +++ b/src/ui/status.rs @@ -0,0 +1 @@ +pub fn render() \ No newline at end of file diff --git a/src/ui/tree.rs b/src/ui/tree.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/date.rs b/src/utils/date.rs new file mode 100644 index 0000000..613e1fc --- /dev/null +++ b/src/utils/date.rs @@ -0,0 +1,54 @@ +use chrono::{Local, NaiveDate}; +use color_eyre::{Result, eyre}; + +/// Parse YYYYMMDD format into NaiveDate +pub fn parse_entry_date(date_str: &str) -> Result { + // Check if the string is exactly 8 characters (YYYYMMDD) + if date_str.len() != 8 { + return Err(eyre::eyre!( + "Invalid date format '{}': must be exactly 8 characters (YYYYMMDD)", + date_str + )); + } + NaiveDate::parse_from_str(date_str, "%Y%m%d") + .map_err(|e| eyre::eyre!("Invalid date format '{}': {}", date_str, e)) +} + +/// Get today's date +pub fn today() -> NaiveDate { + Local::now().date_naive() +} + +/// Format date as YYYYMMDD +pub fn format_entry_date(date: &NaiveDate) -> String { + date.format("%Y%m%d").to_string() +} + +#[cfg(test)] +mod tests { + use chrono::Datelike; + + use super::*; + + #[test] + fn test_parse_entry_date() { + let date = parse_entry_date("20250315").unwrap(); + assert_eq!(date.year(), 2025); + assert_eq!(date.month(), 3); + assert_eq!(date.day(), 15); + } + + #[test] + fn test_parse_invalid_date() { + assert!(parse_entry_date("invalid").is_err()); + assert!(parse_entry_date("20251301").is_err()); // invalid month + assert!(parse_entry_date("20250230").is_err()); // invalid day + assert!(parse_entry_date("2025031").is_err()); // too short + } + + #[test] + fn test_format_entry_date() { + let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); + assert_eq!(format_entry_date(&date), "20250315"); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..35b6698 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod date; + +pub use date::*;