From 70c197509040cb33bf76d7dcda6ff33a168c48bb Mon Sep 17 00:00:00 2001 From: Amolith Date: Tue, 24 Feb 2026 21:27:41 -0700 Subject: [PATCH] heblo --- .gitignore | 1 + Cargo.lock | 972 +++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 22 + Makefile | 27 ++ README.md | 33 ++ src/cli.rs | 192 ++++++++ src/cmd/compact.rs | 13 + src/cmd/create.rs | 82 ++++ src/cmd/dep.rs | 60 +++ src/cmd/done.rs | 35 ++ src/cmd/export.rs | 40 ++ src/cmd/import.rs | 97 ++++ src/cmd/init.rs | 28 ++ src/cmd/label.rs | 67 +++ src/cmd/list.rs | 81 ++++ src/cmd/mod.rs | 132 ++++++ src/cmd/ready.rs | 48 ++ src/cmd/reopen.rs | 35 ++ src/cmd/search.rs | 40 ++ src/cmd/show.rs | 53 +++ src/cmd/stats.rs | 37 ++ src/cmd/update.rs | 58 +++ src/color.rs | 50 +++ src/db.rs | 175 ++++++++ src/lib.rs | 11 + src/main.rs | 9 + tests/cli_create.rs | 125 ++++++ tests/cli_dep.rs | 92 ++++ tests/cli_init.rs | 80 ++++ tests/cli_io.rs | 111 +++++ tests/cli_label.rs | 89 ++++ tests/cli_list_show.rs | 179 ++++++++ tests/cli_query.rs | 153 +++++++ tests/cli_update.rs | 159 +++++++ 34 files changed, 3386 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Makefile create mode 100644 README.md create mode 100644 src/cli.rs create mode 100644 src/cmd/compact.rs create mode 100644 src/cmd/create.rs create mode 100644 src/cmd/dep.rs create mode 100644 src/cmd/done.rs create mode 100644 src/cmd/export.rs create mode 100644 src/cmd/import.rs create mode 100644 src/cmd/init.rs create mode 100644 src/cmd/label.rs create mode 100644 src/cmd/list.rs create mode 100644 src/cmd/mod.rs create mode 100644 src/cmd/ready.rs create mode 100644 src/cmd/reopen.rs create mode 100644 src/cmd/search.rs create mode 100644 src/cmd/show.rs create mode 100644 src/cmd/stats.rs create mode 100644 src/cmd/update.rs create mode 100644 src/color.rs create mode 100644 src/db.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 tests/cli_create.rs create mode 100644 tests/cli_dep.rs create mode 100644 tests/cli_init.rs create mode 100644 tests/cli_io.rs create mode 100644 tests/cli_label.rs create mode 100644 tests/cli_list_show.rs create mode 100644 tests/cli_query.rs create mode 100644 tests/cli_update.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..2f7896d1d1365eafb0da03d9fe456fac81408487 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..dbec37daf114bf3dad0f1821c93aab2af2b1a63d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,972 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert_cmd" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libsqlite3-sys" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbb8270bb4060bd76c6e96f20c52d80620f1d82a3470885694e41e0f81ef6fe7" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rusqlite" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "yatd" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_cmd", + "chrono", + "clap", + "predicates", + "rusqlite", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..0d28a4b99ba99416bc96efcd31c4e7c3f5196c06 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "yatd" +version = "0.1.0" +edition = "2021" +description = "Todo tracker for AI agents" + +[[bin]] +name = "td" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } +clap = { version = "4", features = ["derive"] } +rusqlite = { version = "0.34", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +tempfile = "3" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..6bab180aec66ded3927a47a9f0ebc279d367ce54 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +BINDIR := $(or $(XDG_BIN_HOME),$(XDG_BIN_DIR),$(HOME)/.local/bin) + +.PHONY: all check test fmt clippy ci install + +all: fmt check test + +ci: fmt + @cargo check --quiet 2>&1 | grep -v '^warning: use of deprecated' || true + @cargo clippy --quiet -- -D warnings 2>&1 | grep -v '^warning: use of deprecated' || true + @cargo test --quiet 2>&1 | grep -v '^warning: use of deprecated' | grep -E '(^test |^running|test result|FAILED|error)' + +check: + @cargo check --quiet + @cargo clippy --quiet -- -D warnings + +test: + @cargo test --quiet + +fmt: + @cargo fmt + +clippy: + @cargo clippy --quiet -- -D warnings + +install: + cargo build --release --quiet + install -Dm755 target/release/td "$(BINDIR)/td" diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5b3349fa8db6fabf2e284e729f530a2aa94eaf95 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# yatd, _yet another td_ + +There are many tds. This one is mine. + +``` +$ td --help +Todo tracker for AI agents + +Usage: td [OPTIONS] + +Commands: + init Initialize .td directory + create Create a new task [aliases: add] + list List tasks [aliases: ls] + show Show task details + update Update a task + done Mark task(s) as closed [aliases: close] + reopen Reopen task(s) + dep Manage dependencies / blockers + label Manage labels + search Search tasks by title or description + ready Show tasks with no open blockers + stats Show task statistics (always JSON) + compact Vacuum the database + export Export tasks to JSONL (one JSON object per line) + import Import tasks from a JSONL file + help Print this message or the help of the given subcommand(s) + +Options: + -j, --json Output JSON + -h, --help Print help + -V, --version Print version +``` diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000000000000000000000000000000000000..1cd49aa7952d63c712460d25d5714fcb248b91a9 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,192 @@ +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "td", version, about = "Todo tracker for AI agents")] +pub struct Cli { + /// Output JSON + #[arg(short = 'j', long = "json", global = true)] + pub json: bool, + + #[command(subcommand)] + pub command: Command, +} + +#[derive(Subcommand)] +pub enum Command { + /// Initialize .td directory + Init { + /// Add .td/ to .gitignore + #[arg(long)] + stealth: bool, + }, + + /// Create a new task + #[command(visible_alias = "add")] + Create { + /// Task title + title: Option, + + /// Priority level (1=high, 2=medium, 3=low) + #[arg(short, long, default_value_t = 2)] + priority: i32, + + /// Task type + #[arg(short = 't', long = "type", default_value = "task")] + task_type: String, + + /// Description + #[arg(short = 'd', long = "desc")] + desc: Option, + + /// Parent task ID (creates a subtask) + #[arg(long)] + parent: Option, + + /// Labels (comma-separated) + #[arg(short, long)] + labels: Option, + }, + + /// List tasks + #[command(visible_alias = "ls")] + List { + /// Filter by status + #[arg(short, long)] + status: Option, + + /// Filter by priority + #[arg(short, long)] + priority: Option, + + /// Filter by label + #[arg(short, long)] + label: Option, + }, + + /// Show task details + Show { + /// Task ID + id: String, + }, + + /// Update a task + Update { + /// Task ID + id: String, + + /// Set status + #[arg(short, long)] + status: Option, + + /// Set priority + #[arg(short, long)] + priority: Option, + + /// Set title + #[arg(short = 't', long)] + title: Option, + + /// Set description + #[arg(short = 'd', long = "desc")] + desc: Option, + }, + + /// Mark task(s) as closed + #[command(visible_alias = "close")] + Done { + /// Task IDs + #[arg(required = true)] + ids: Vec, + }, + + /// Reopen task(s) + Reopen { + /// Task IDs + #[arg(required = true)] + ids: Vec, + }, + + /// Manage dependencies / blockers + Dep { + #[command(subcommand)] + action: DepAction, + }, + + /// Manage labels + Label { + #[command(subcommand)] + action: LabelAction, + }, + + /// Search tasks by title or description + Search { + /// Search query + query: String, + }, + + /// Show tasks with no open blockers + Ready, + + /// Show task statistics (always JSON) + Stats, + + /// Vacuum the database + Compact, + + /// Export tasks to JSONL (one JSON object per line) + Export, + + /// Import tasks from a JSONL file + Import { + /// Path to JSONL file (- for stdin) + file: String, + }, +} + +#[derive(Subcommand)] +pub enum DepAction { + /// Add a dependency (child is blocked by parent) + Add { + /// Task that is blocked + child: String, + /// Task that blocks it + parent: String, + }, + /// Remove a dependency + Rm { + /// Task that was blocked + child: String, + /// Task that was blocking + parent: String, + }, + /// Show child tasks + Tree { + /// Parent task ID + id: String, + }, +} + +#[derive(Subcommand)] +pub enum LabelAction { + /// Add a label to a task + Add { + /// Task ID + id: String, + /// Label to add + label: String, + }, + /// Remove a label from a task + Rm { + /// Task ID + id: String, + /// Label to remove + label: String, + }, + /// List labels on a task + List { + /// Task ID + id: String, + }, + /// List all distinct labels + ListAll, +} diff --git a/src/cmd/compact.rs b/src/cmd/compact.rs new file mode 100644 index 0000000000000000000000000000000000000000..58d97fff951085820d235fe681074da1769f0f50 --- /dev/null +++ b/src/cmd/compact.rs @@ -0,0 +1,13 @@ +use anyhow::Result; +use std::path::Path; + +use crate::db; + +pub fn run(root: &Path) -> Result<()> { + let conn = db::open(root)?; + let c = crate::color::stderr_theme(); + eprintln!("{}info:{} vacuuming database...", c.blue, c.reset); + conn.execute_batch("VACUUM;")?; + eprintln!("{}info:{} done", c.blue, c.reset); + Ok(()) +} diff --git a/src/cmd/create.rs b/src/cmd/create.rs new file mode 100644 index 0000000000000000000000000000000000000000..cd9b782e188f523971e3533a30e76d0a91d659b8 --- /dev/null +++ b/src/cmd/create.rs @@ -0,0 +1,82 @@ +use anyhow::Result; +use std::path::Path; + +use crate::db; + +pub struct Opts<'a> { + pub title: Option<&'a str>, + pub priority: i32, + pub task_type: &'a str, + pub desc: Option<&'a str>, + pub parent: Option<&'a str>, + pub labels: Option<&'a str>, + pub json: bool, +} + +pub fn run(root: &Path, opts: Opts) -> Result<()> { + let title = opts + .title + .ok_or_else(|| anyhow::anyhow!("title required"))?; + let desc = opts.desc.unwrap_or(""); + let ts = db::now_utc(); + + let conn = db::open(root)?; + + let id = match opts.parent { + Some(pid) => { + let count: i64 = + conn.query_row("SELECT COUNT(*) FROM tasks WHERE parent = ?1", [pid], |r| { + r.get(0) + })?; + format!("{pid}.{}", count + 1) + } + None => db::gen_id(), + }; + + conn.execute( + "INSERT INTO tasks (id, title, description, type, priority, status, parent, created, updated) + VALUES (?1, ?2, ?3, ?4, ?5, 'open', ?6, ?7, ?8)", + rusqlite::params![ + id, + title, + desc, + opts.task_type, + opts.priority, + opts.parent.unwrap_or(""), + ts, + ts + ], + )?; + + if let Some(label_str) = opts.labels { + for lbl in label_str.split(',') { + let lbl = lbl.trim(); + if !lbl.is_empty() { + conn.execute( + "INSERT OR IGNORE INTO labels (task_id, label) VALUES (?1, ?2)", + [&id, lbl], + )?; + } + } + } + + if opts.json { + let task = db::Task { + id: id.clone(), + title: title.to_string(), + description: desc.to_string(), + task_type: opts.task_type.to_string(), + priority: opts.priority, + status: "open".to_string(), + parent: opts.parent.unwrap_or("").to_string(), + created: ts.clone(), + updated: ts, + }; + println!("{}", serde_json::to_string(&task)?); + } else { + let c = crate::color::stdout_theme(); + println!("{}created{} {id}: {title}", c.green, c.reset); + } + + Ok(()) +} diff --git a/src/cmd/dep.rs b/src/cmd/dep.rs new file mode 100644 index 0000000000000000000000000000000000000000..6b5d86341bb31fb466d8256b64f264bab5a06abb --- /dev/null +++ b/src/cmd/dep.rs @@ -0,0 +1,60 @@ +use anyhow::Result; +use std::path::Path; + +use crate::cli::DepAction; +use crate::db; + +pub fn run(root: &Path, action: &DepAction, json: bool) -> Result<()> { + let conn = db::open(root)?; + + match action { + DepAction::Add { child, parent } => { + conn.execute( + "INSERT OR IGNORE INTO blockers (task_id, blocker_id) VALUES (?1, ?2)", + [child, parent], + )?; + conn.execute( + "UPDATE tasks SET updated = ?1 WHERE id = ?2", + rusqlite::params![db::now_utc(), child], + )?; + if json { + println!("{}", serde_json::json!({"child": child, "blocker": parent})); + } else { + let c = crate::color::stdout_theme(); + println!( + "{}{child}{} blocked by {}{parent}{}", + c.green, c.reset, c.yellow, c.reset + ); + } + } + DepAction::Rm { child, parent } => { + conn.execute( + "DELETE FROM blockers WHERE task_id = ?1 AND blocker_id = ?2", + [child, parent], + )?; + conn.execute( + "UPDATE tasks SET updated = ?1 WHERE id = ?2", + rusqlite::params![db::now_utc(), child], + )?; + if !json { + let c = crate::color::stdout_theme(); + println!( + "{}{child}{} no longer blocked by {}{parent}{}", + c.green, c.reset, c.yellow, c.reset + ); + } + } + DepAction::Tree { id } => { + println!("{id}"); + let mut stmt = conn.prepare("SELECT id FROM tasks WHERE parent = ?1 ORDER BY id")?; + let children: Vec = stmt + .query_map([id], |r| r.get(0))? + .collect::>()?; + for child in &children { + println!(" {child}"); + } + } + } + + Ok(()) +} diff --git a/src/cmd/done.rs b/src/cmd/done.rs new file mode 100644 index 0000000000000000000000000000000000000000..486f943589c5c8eeeaf824d45b38b0275821b827 --- /dev/null +++ b/src/cmd/done.rs @@ -0,0 +1,35 @@ +use anyhow::Result; +use std::path::Path; + +use crate::db; + +pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> { + let conn = db::open(root)?; + let ts = db::now_utc(); + + let c = crate::color::stdout_theme(); + for id in ids { + conn.execute( + "UPDATE tasks SET status = 'closed', updated = ?1 WHERE id = ?2", + rusqlite::params![ts, id], + )?; + if !json { + println!("{}closed{} {id}", c.green, c.reset); + } + } + + if json { + let details: Vec = ids + .iter() + .map(|id| { + Ok(serde_json::json!({ + "id": id, + "status": "closed", + })) + }) + .collect::>()?; + println!("{}", serde_json::to_string(&details)?); + } + + Ok(()) +} diff --git a/src/cmd/export.rs b/src/cmd/export.rs new file mode 100644 index 0000000000000000000000000000000000000000..40014863a11010ba31a3819bce1ea33b4677dc19 --- /dev/null +++ b/src/cmd/export.rs @@ -0,0 +1,40 @@ +use anyhow::Result; +use std::path::Path; + +use crate::db; + +pub fn run(root: &Path) -> Result<()> { + let conn = db::open(root)?; + + let mut stmt = conn.prepare( + "SELECT id, title, description, type, priority, status, parent, created, updated + FROM tasks ORDER BY id", + )?; + + let tasks: Vec = stmt + .query_map([], db::row_to_task)? + .collect::>()?; + + for t in &tasks { + let labels = db::load_labels(&conn, &t.id)?; + let blockers = db::load_blockers(&conn, &t.id)?; + let detail = db::TaskDetail { + task: db::Task { + id: t.id.clone(), + title: t.title.clone(), + description: t.description.clone(), + task_type: t.task_type.clone(), + priority: t.priority, + status: t.status.clone(), + parent: t.parent.clone(), + created: t.created.clone(), + updated: t.updated.clone(), + }, + labels, + blockers, + }; + println!("{}", serde_json::to_string(&detail)?); + } + + Ok(()) +} diff --git a/src/cmd/import.rs b/src/cmd/import.rs new file mode 100644 index 0000000000000000000000000000000000000000..23c47b52b47a1b0865b6085ffc5b6f01893f898f --- /dev/null +++ b/src/cmd/import.rs @@ -0,0 +1,97 @@ +use anyhow::Result; +use serde::Deserialize; +use std::io::BufRead; +use std::path::Path; + +use crate::db; + +#[derive(Deserialize)] +struct ImportTask { + id: String, + title: String, + #[serde(default)] + description: String, + #[serde(rename = "type", default = "default_type")] + task_type: String, + #[serde(default = "default_priority")] + priority: i32, + #[serde(default = "default_status")] + status: String, + #[serde(default)] + parent: String, + created: String, + updated: String, + #[serde(default)] + labels: Vec, + #[serde(default)] + blockers: Vec, +} + +fn default_type() -> String { + "task".into() +} +fn default_priority() -> i32 { + 2 +} +fn default_status() -> String { + "open".into() +} + +pub fn run(root: &Path, file: &str) -> Result<()> { + let conn = db::open(root)?; + + eprintln!("info: importing from {file}..."); + + let reader: Box = if file == "-" { + Box::new(std::io::stdin().lock()) + } else { + Box::new(std::io::BufReader::new(std::fs::File::open(file)?)) + }; + + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + + let t: ImportTask = serde_json::from_str(&line)?; + + conn.execute( + "INSERT OR REPLACE INTO tasks + (id, title, description, type, priority, status, parent, created, updated) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params![ + t.id, + t.title, + t.description, + t.task_type, + t.priority, + t.status, + t.parent, + t.created, + t.updated, + ], + )?; + + // Replace labels. + conn.execute("DELETE FROM labels WHERE task_id = ?1", [&t.id])?; + for lbl in &t.labels { + conn.execute( + "INSERT INTO labels (task_id, label) VALUES (?1, ?2)", + [&t.id, lbl], + )?; + } + + // Replace blockers. + conn.execute("DELETE FROM blockers WHERE task_id = ?1", [&t.id])?; + for blk in &t.blockers { + conn.execute( + "INSERT INTO blockers (task_id, blocker_id) VALUES (?1, ?2)", + [&t.id, blk], + )?; + } + } + + eprintln!("info: import complete"); + Ok(()) +} diff --git a/src/cmd/init.rs b/src/cmd/init.rs new file mode 100644 index 0000000000000000000000000000000000000000..62d9821afe470b626b62752a82516bf114917d6e --- /dev/null +++ b/src/cmd/init.rs @@ -0,0 +1,28 @@ +use anyhow::{bail, Result}; +use std::path::Path; + +pub fn run(root: &Path, stealth: bool, json: bool) -> Result<()> { + let td_dir = crate::db::td_dir(root); + if td_dir.exists() { + bail!("already initialized"); + } + + crate::db::init(root)?; + + if stealth { + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(root.join(".gitignore"))?; + writeln!(f, ".td/")?; + } + + let c = crate::color::stderr_theme(); + eprintln!("{}info:{} initialized .td/", c.blue, c.reset); + if json { + println!(r#"{{"success":true}}"#); + } + + Ok(()) +} diff --git a/src/cmd/label.rs b/src/cmd/label.rs new file mode 100644 index 0000000000000000000000000000000000000000..16860a264a5b27b0d7a1972c827b7dd8a6d45560 --- /dev/null +++ b/src/cmd/label.rs @@ -0,0 +1,67 @@ +use anyhow::Result; +use std::path::Path; + +use crate::cli::LabelAction; +use crate::db; + +pub fn run(root: &Path, action: &LabelAction, json: bool) -> Result<()> { + let conn = db::open(root)?; + + match action { + LabelAction::Add { id, label } => { + conn.execute( + "INSERT OR IGNORE INTO labels (task_id, label) VALUES (?1, ?2)", + [id, label], + )?; + conn.execute( + "UPDATE tasks SET updated = ?1 WHERE id = ?2", + rusqlite::params![db::now_utc(), id], + )?; + if json { + println!("{}", serde_json::json!({"id": id, "label": label})); + } else { + let c = crate::color::stdout_theme(); + println!("{}added{} label {label}", c.green, c.reset); + } + } + LabelAction::Rm { id, label } => { + conn.execute( + "DELETE FROM labels WHERE task_id = ?1 AND label = ?2", + [id, label], + )?; + conn.execute( + "UPDATE tasks SET updated = ?1 WHERE id = ?2", + rusqlite::params![db::now_utc(), id], + )?; + if !json { + let c = crate::color::stdout_theme(); + println!("{}removed{} label {label}", c.green, c.reset); + } + } + LabelAction::List { id } => { + let labels = db::load_labels(&conn, id)?; + if json { + println!("{}", serde_json::to_string(&labels)?); + } else { + for l in &labels { + println!("{l}"); + } + } + } + LabelAction::ListAll => { + let mut stmt = conn.prepare("SELECT DISTINCT label FROM labels ORDER BY label")?; + let labels: Vec = stmt + .query_map([], |r| r.get(0))? + .collect::>()?; + if json { + println!("{}", serde_json::to_string(&labels)?); + } else { + for l in &labels { + println!("{l}"); + } + } + } + } + + Ok(()) +} diff --git a/src/cmd/list.rs b/src/cmd/list.rs new file mode 100644 index 0000000000000000000000000000000000000000..4def99d9a349557d25e93c6f89888694ae208dca --- /dev/null +++ b/src/cmd/list.rs @@ -0,0 +1,81 @@ +use anyhow::Result; +use std::path::Path; + +use crate::db; + +pub fn run( + root: &Path, + status: Option<&str>, + priority: Option, + label: Option<&str>, + json: bool, +) -> Result<()> { + let conn = db::open(root)?; + + let mut sql = String::from( + "SELECT id, title, description, type, priority, status, parent, created, updated + FROM tasks WHERE 1=1", + ); + let mut params: Vec> = Vec::new(); + let mut idx = 1; + + if let Some(s) = status { + sql.push_str(&format!(" AND status = ?{idx}")); + params.push(Box::new(s.to_string())); + idx += 1; + } + if let Some(p) = priority { + sql.push_str(&format!(" AND priority = ?{idx}")); + params.push(Box::new(p)); + idx += 1; + } + if let Some(l) = label { + sql.push_str(&format!( + " AND id IN (SELECT task_id FROM labels WHERE label = ?{idx})" + )); + params.push(Box::new(l.to_string())); + } + + sql.push_str(" ORDER BY priority, created"); + + let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql)?; + let tasks: Vec = stmt + .query_map(param_refs.as_slice(), db::row_to_task)? + .collect::>()?; + + if json { + let details: Vec = tasks + .into_iter() + .map(|t| { + let labels = db::load_labels(&conn, &t.id)?; + let blockers = db::load_blockers(&conn, &t.id)?; + Ok(db::TaskDetail { + task: t, + labels, + blockers, + }) + }) + .collect::>()?; + println!("{}", serde_json::to_string(&details)?); + } else { + let c = crate::color::stdout_theme(); + for t in &tasks { + println!( + "{}{:<12}{} {}{:<12}{} {}{:<4}{} {}", + c.bold, + t.id, + c.reset, + c.yellow, + format!("[{}]", t.status), + c.reset, + c.red, + format!("P{}", t.priority), + c.reset, + t.title, + ); + } + } + + Ok(()) +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..d10bf6f5820c2531e423ab797b8fd3e4328e97fc --- /dev/null +++ b/src/cmd/mod.rs @@ -0,0 +1,132 @@ +mod compact; +mod create; +mod dep; +mod done; +mod export; +mod import; +mod init; +mod label; +mod list; +mod ready; +mod reopen; +mod search; +mod show; +mod stats; +mod update; + +use crate::cli::{Cli, Command}; +use crate::db; +use anyhow::Result; + +fn require_root() -> Result { + db::find_root(&std::env::current_dir()?) +} + +pub fn dispatch(cli: &Cli) -> Result<()> { + match &cli.command { + Command::Init { stealth } => { + let root = std::env::current_dir()?; + init::run(&root, *stealth, cli.json) + } + Command::Create { + title, + priority, + task_type, + desc, + parent, + labels, + } => { + let root = require_root()?; + create::run( + &root, + create::Opts { + title: title.as_deref(), + priority: *priority, + task_type, + desc: desc.as_deref(), + parent: parent.as_deref(), + labels: labels.as_deref(), + json: cli.json, + }, + ) + } + Command::List { + status, + priority, + label, + } => { + let root = require_root()?; + list::run( + &root, + status.as_deref(), + *priority, + label.as_deref(), + cli.json, + ) + } + Command::Show { id } => { + let root = require_root()?; + show::run(&root, id, cli.json) + } + Command::Update { + id, + status, + priority, + title, + desc, + } => { + let root = require_root()?; + update::run( + &root, + id, + update::Opts { + status: status.as_deref(), + priority: *priority, + title: title.as_deref(), + desc: desc.as_deref(), + json: cli.json, + }, + ) + } + Command::Done { ids } => { + let root = require_root()?; + done::run(&root, ids, cli.json) + } + Command::Reopen { ids } => { + let root = require_root()?; + reopen::run(&root, ids, cli.json) + } + Command::Dep { action } => { + let root = require_root()?; + dep::run(&root, action, cli.json) + } + Command::Label { action } => { + let root = require_root()?; + label::run(&root, action, cli.json) + } + Command::Search { query } => { + let root = require_root()?; + search::run(&root, query, cli.json) + } + Command::Ready => { + let root = require_root()?; + ready::run(&root, cli.json) + } + Command::Stats => { + let root = require_root()?; + stats::run(&root) + } + Command::Compact => { + let root = require_root()?; + compact::run(&root) + } + Command::Export => { + let root = require_root()?; + export::run(&root) + } + Command::Import { file } => { + let root = require_root()?; + import::run(&root, file) + } + } +} diff --git a/src/cmd/ready.rs b/src/cmd/ready.rs new file mode 100644 index 0000000000000000000000000000000000000000..b0cfd129260912847eb6b95003b2910e34362a8e --- /dev/null +++ b/src/cmd/ready.rs @@ -0,0 +1,48 @@ +use anyhow::Result; +use std::path::Path; + +use crate::db; + +pub fn run(root: &Path, json: bool) -> Result<()> { + let conn = db::open(root)?; + + let mut stmt = conn.prepare( + "SELECT id, title, description, type, priority, status, parent, created, updated + FROM tasks + WHERE status = 'open' + AND id NOT IN ( + SELECT b.task_id FROM blockers b + JOIN tasks t ON b.blocker_id = t.id + WHERE t.status != 'closed' + ) + ORDER BY priority, created", + )?; + + let tasks: Vec = stmt + .query_map([], db::row_to_task)? + .collect::>()?; + + if json { + let summary: Vec = tasks + .iter() + .map(|t| { + serde_json::json!({ + "id": t.id, + "title": t.title, + "priority": t.priority, + }) + }) + .collect(); + println!("{}", serde_json::to_string(&summary)?); + } else { + let c = crate::color::stdout_theme(); + for t in &tasks { + println!( + "{}{:<12}{} {}P{:<3}{} {}", + c.green, t.id, c.reset, c.red, t.priority, c.reset, t.title + ); + } + } + + Ok(()) +} diff --git a/src/cmd/reopen.rs b/src/cmd/reopen.rs new file mode 100644 index 0000000000000000000000000000000000000000..af49fbf136f53c01986b6fc7783ae915b536d7b2 --- /dev/null +++ b/src/cmd/reopen.rs @@ -0,0 +1,35 @@ +use anyhow::Result; +use std::path::Path; + +use crate::db; + +pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> { + let conn = db::open(root)?; + let ts = db::now_utc(); + + let c = crate::color::stdout_theme(); + for id in ids { + conn.execute( + "UPDATE tasks SET status = 'open', updated = ?1 WHERE id = ?2", + rusqlite::params![ts, id], + )?; + if !json { + println!("{}reopened{} {id}", c.green, c.reset); + } + } + + if json { + let details: Vec = ids + .iter() + .map(|id| { + Ok(serde_json::json!({ + "id": id, + "status": "open", + })) + }) + .collect::>()?; + println!("{}", serde_json::to_string(&details)?); + } + + Ok(()) +} diff --git a/src/cmd/search.rs b/src/cmd/search.rs new file mode 100644 index 0000000000000000000000000000000000000000..91400b66fd6b7138575f336be106db93bf84dc7d --- /dev/null +++ b/src/cmd/search.rs @@ -0,0 +1,40 @@ +use anyhow::Result; +use std::path::Path; + +use crate::db; + +pub fn run(root: &Path, query: &str, json: bool) -> Result<()> { + let conn = db::open(root)?; + let pattern = format!("%{query}%"); + + let mut stmt = conn.prepare( + "SELECT id, title, description, type, priority, status, parent, created, updated + FROM tasks + WHERE title LIKE ?1 OR description LIKE ?1", + )?; + + let tasks: Vec = stmt + .query_map([&pattern], db::row_to_task)? + .collect::>()?; + + if json { + let summary: Vec = tasks + .iter() + .map(|t| { + serde_json::json!({ + "id": t.id, + "title": t.title, + "status": t.status, + }) + }) + .collect(); + println!("{}", serde_json::to_string(&summary)?); + } else { + let c = crate::color::stdout_theme(); + for t in &tasks { + println!("{}{}{} {}", c.bold, t.id, c.reset, t.title); + } + } + + Ok(()) +} diff --git a/src/cmd/show.rs b/src/cmd/show.rs new file mode 100644 index 0000000000000000000000000000000000000000..385016d9f04800238dd0dd1219878ae5519d9fb8 --- /dev/null +++ b/src/cmd/show.rs @@ -0,0 +1,53 @@ +use anyhow::{bail, Result}; +use std::path::Path; + +use crate::db; + +pub fn run(root: &Path, id: &str, json: bool) -> Result<()> { + let conn = db::open(root)?; + + let exists: bool = conn.query_row("SELECT COUNT(*) FROM tasks WHERE id = ?1", [id], |r| { + r.get::<_, i64>(0).map(|n| n > 0) + })?; + + if !exists { + bail!("task {id} not found"); + } + + let detail = db::load_task_detail(&conn, id)?; + + if json { + println!("{}", serde_json::to_string(&detail)?); + } else { + let c = crate::color::stdout_theme(); + let t = &detail.task; + println!("{} id{} = {}", c.bold, c.reset, t.id); + println!("{} title{} = {}", c.bold, c.reset, t.title); + println!("{} status{} = {}", c.bold, c.reset, t.status); + println!("{} priority{} = {}", c.bold, c.reset, t.priority); + println!("{} type{} = {}", c.bold, c.reset, t.task_type); + if !t.description.is_empty() { + println!("{} description{} = {}", c.bold, c.reset, t.description); + } + println!("{} created{} = {}", c.bold, c.reset, t.created); + println!("{} updated{} = {}", c.bold, c.reset, t.updated); + if !detail.labels.is_empty() { + println!( + "{} labels{} = {}", + c.bold, + c.reset, + detail.labels.join(",") + ); + } + if !detail.blockers.is_empty() { + println!( + "{} blockers{} = {}", + c.bold, + c.reset, + detail.blockers.join(",") + ); + } + } + + Ok(()) +} diff --git a/src/cmd/stats.rs b/src/cmd/stats.rs new file mode 100644 index 0000000000000000000000000000000000000000..7bfedde0c03bb632dad5addf0ad7c2a3364dc98e --- /dev/null +++ b/src/cmd/stats.rs @@ -0,0 +1,37 @@ +use anyhow::Result; +use std::path::Path; + +use crate::db; + +pub fn run(root: &Path) -> Result<()> { + let conn = db::open(root)?; + + let total: i64 = conn.query_row("SELECT COUNT(*) FROM tasks", [], |r| r.get(0))?; + let open: i64 = conn.query_row( + "SELECT COUNT(*) FROM tasks WHERE status = 'open'", + [], + |r| r.get(0), + )?; + let in_progress: i64 = conn.query_row( + "SELECT COUNT(*) FROM tasks WHERE status = 'in_progress'", + [], + |r| r.get(0), + )?; + let closed: i64 = conn.query_row( + "SELECT COUNT(*) FROM tasks WHERE status = 'closed'", + [], + |r| r.get(0), + )?; + + println!( + "{}", + serde_json::json!({ + "total": total, + "open": open, + "in_progress": in_progress, + "closed": closed, + }) + ); + + Ok(()) +} diff --git a/src/cmd/update.rs b/src/cmd/update.rs new file mode 100644 index 0000000000000000000000000000000000000000..d2301748d8c7f1b606d8d65040b54db8467779d2 --- /dev/null +++ b/src/cmd/update.rs @@ -0,0 +1,58 @@ +use anyhow::Result; +use std::path::Path; + +use crate::db; + +pub struct Opts<'a> { + pub status: Option<&'a str>, + pub priority: Option, + pub title: Option<&'a str>, + pub desc: Option<&'a str>, + pub json: bool, +} + +pub fn run(root: &Path, id: &str, opts: Opts) -> Result<()> { + let conn = db::open(root)?; + let ts = db::now_utc(); + + let mut sets = vec![format!("updated = '{ts}'")]; + let mut params: Vec> = Vec::new(); + let mut idx = 1; + + if let Some(s) = opts.status { + sets.push(format!("status = ?{idx}")); + params.push(Box::new(s.to_string())); + idx += 1; + } + if let Some(p) = opts.priority { + sets.push(format!("priority = ?{idx}")); + params.push(Box::new(p)); + idx += 1; + } + if let Some(t) = opts.title { + sets.push(format!("title = ?{idx}")); + params.push(Box::new(t.to_string())); + idx += 1; + } + if let Some(d) = opts.desc { + sets.push(format!("description = ?{idx}")); + params.push(Box::new(d.to_string())); + idx += 1; + } + + let sql = format!("UPDATE tasks SET {} WHERE id = ?{idx}", sets.join(", ")); + params.push(Box::new(id.to_string())); + + let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + conn.execute(&sql, param_refs.as_slice())?; + + if opts.json { + let detail = db::load_task_detail(&conn, id)?; + println!("{}", serde_json::to_string(&detail)?); + } else { + let c = crate::color::stdout_theme(); + println!("{}updated{} {id}", c.green, c.reset); + } + + Ok(()) +} diff --git a/src/color.rs b/src/color.rs new file mode 100644 index 0000000000000000000000000000000000000000..e348dc65041dd2f30431ad1cd453769531133185 --- /dev/null +++ b/src/color.rs @@ -0,0 +1,50 @@ +use std::io::IsTerminal; + +pub struct Theme { + pub red: &'static str, + pub green: &'static str, + pub yellow: &'static str, + pub blue: &'static str, + pub bold: &'static str, + pub reset: &'static str, +} + +const ON: Theme = Theme { + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + bold: "\x1b[1m", + reset: "\x1b[0m", +}; + +const OFF: Theme = Theme { + red: "", + green: "", + yellow: "", + blue: "", + bold: "", + reset: "", +}; + +fn use_color(is_tty: bool) -> bool { + std::env::var_os("NO_COLOR").is_none() && is_tty +} + +/// Colour theme for stdout. +pub fn stdout_theme() -> &'static Theme { + if use_color(std::io::stdout().is_terminal()) { + &ON + } else { + &OFF + } +} + +/// Colour theme for stderr. +pub fn stderr_theme() -> &'static Theme { + if use_color(std::io::stderr().is_terminal()) { + &ON + } else { + &OFF + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000000000000000000000000000000000000..1cbac731a33ad0de2e369aad736020925fc55f8c --- /dev/null +++ b/src/db.rs @@ -0,0 +1,175 @@ +use anyhow::{bail, Result}; +use rusqlite::Connection; +use serde::Serialize; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::SystemTime; + +const TD_DIR: &str = ".td"; +const DB_FILE: &str = "tasks.db"; + +/// A task record. +#[derive(Debug, Serialize)] +pub struct Task { + pub id: String, + pub title: String, + pub description: String, + #[serde(rename = "type")] + pub task_type: String, + pub priority: i32, + pub status: String, + pub parent: String, + pub created: String, + pub updated: String, +} + +static ID_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Generate a short unique ID like `td-a1b2c3`. +pub fn gen_id() -> String { + let mut hasher = DefaultHasher::new(); + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + .hash(&mut hasher); + std::process::id().hash(&mut hasher); + ID_COUNTER.fetch_add(1, Ordering::Relaxed).hash(&mut hasher); + format!("td-{:06x}", hasher.finish() & 0xffffff) +} + +/// A task with its labels and blockers. +#[derive(Debug, Serialize)] +pub struct TaskDetail { + #[serde(flatten)] + pub task: Task, + pub labels: Vec, + pub blockers: Vec, +} + +/// Current UTC time in ISO 8601 format. +pub fn now_utc() -> String { + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() +} + +/// Read a single `Task` row from a query result. +pub fn row_to_task(row: &rusqlite::Row) -> rusqlite::Result { + Ok(Task { + id: row.get("id")?, + title: row.get("title")?, + description: row.get("description")?, + task_type: row.get("type")?, + priority: row.get("priority")?, + status: row.get("status")?, + parent: row.get("parent")?, + created: row.get("created")?, + updated: row.get("updated")?, + }) +} + +/// Load labels for a task. +pub fn load_labels(conn: &Connection, task_id: &str) -> Result> { + let mut stmt = conn.prepare("SELECT label FROM labels WHERE task_id = ?1")?; + let labels = stmt + .query_map([task_id], |r| r.get(0))? + .collect::>>()?; + Ok(labels) +} + +/// Load blockers for a task. +pub fn load_blockers(conn: &Connection, task_id: &str) -> Result> { + let mut stmt = conn.prepare("SELECT blocker_id FROM blockers WHERE task_id = ?1")?; + let blockers = stmt + .query_map([task_id], |r| r.get(0))? + .collect::>>()?; + Ok(blockers) +} + +/// Load a full task with labels and blockers. +pub fn load_task_detail(conn: &Connection, id: &str) -> Result { + let task = conn.query_row( + "SELECT id, title, description, type, priority, status, parent, created, updated + FROM tasks WHERE id = ?1", + [id], + row_to_task, + )?; + let labels = load_labels(conn, id)?; + let blockers = load_blockers(conn, id)?; + Ok(TaskDetail { + task, + labels, + blockers, + }) +} + +const SCHEMA: &str = " +CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT DEFAULT '', + type TEXT DEFAULT 'task', + priority INTEGER DEFAULT 2, + status TEXT DEFAULT 'open', + parent TEXT DEFAULT '', + created TEXT NOT NULL, + updated TEXT NOT NULL +); + +CREATE TABLE labels ( + task_id TEXT, + label TEXT, + PRIMARY KEY (task_id, label), + FOREIGN KEY (task_id) REFERENCES tasks(id) +); + +CREATE TABLE blockers ( + task_id TEXT, + blocker_id TEXT, + PRIMARY KEY (task_id, blocker_id), + FOREIGN KEY (task_id) REFERENCES tasks(id) +); + +CREATE INDEX idx_status ON tasks(status); +CREATE INDEX idx_priority ON tasks(priority); +CREATE INDEX idx_parent ON tasks(parent); +"; + +/// Walk up from `start` looking for a `.td/` directory. +pub fn find_root(start: &Path) -> Result { + let mut dir = start.to_path_buf(); + loop { + if dir.join(TD_DIR).is_dir() { + return Ok(dir); + } + if !dir.pop() { + bail!("not initialized. Run 'td init'"); + } + } +} + +/// Create the `.td/` directory and initialise the database schema. +pub fn init(root: &Path) -> Result { + let td = root.join(TD_DIR); + std::fs::create_dir_all(&td)?; + let conn = Connection::open(td.join(DB_FILE))?; + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + conn.execute_batch(SCHEMA)?; + Ok(conn) +} + +/// Open an existing database. +pub fn open(root: &Path) -> Result { + let path = root.join(TD_DIR).join(DB_FILE); + if !path.exists() { + bail!("not initialized. Run 'td init'"); + } + let conn = Connection::open(path)?; + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + Ok(conn) +} + +/// Return the path to the `.td/` directory under `root`. +pub fn td_dir(root: &Path) -> PathBuf { + root.join(TD_DIR) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..8eae9205e0dca22bf12c0d4903da7de79a43269e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,11 @@ +pub mod cli; +pub mod cmd; +pub mod color; +pub mod db; + +use clap::Parser; + +pub fn run() -> anyhow::Result<()> { + let cli = cli::Cli::parse(); + cmd::dispatch(&cli) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..abd9fa2ff5475f670f3e65ca1c18bdf77e11a628 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,9 @@ +use std::process; + +fn main() { + if let Err(e) = yatd::run() { + let c = yatd::color::stderr_theme(); + eprintln!("{}error:{} {e}", c.red, c.reset); + process::exit(1); + } +} diff --git a/tests/cli_create.rs b/tests/cli_create.rs new file mode 100644 index 0000000000000000000000000000000000000000..8f3fbe2763f0fb4a3b7b78be89cde2cf40fb5437 --- /dev/null +++ b/tests/cli_create.rs @@ -0,0 +1,125 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn td() -> Command { + Command::cargo_bin("td").unwrap() +} + +/// Initialise a temp directory and return it. +fn init_tmp() -> TempDir { + let tmp = TempDir::new().unwrap(); + td().arg("init").current_dir(&tmp).assert().success(); + tmp +} + +#[test] +fn create_prints_id_and_title() { + let tmp = init_tmp(); + + td().args(["create", "My first task"]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("My first task")); +} + +#[test] +fn create_json_returns_task_object() { + let tmp = init_tmp(); + + td().args(["--json", "create", "Buy milk"]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains(r#""title":"Buy milk"#)) + .stdout(predicate::str::contains(r#""status":"open"#)) + .stdout(predicate::str::contains(r#""priority":2"#)); +} + +#[test] +fn create_with_priority_and_type() { + let tmp = init_tmp(); + + td().args(["--json", "create", "Urgent bug", "-p", "1", "-t", "bug"]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains(r#""priority":1"#)) + .stdout(predicate::str::contains(r#""type":"bug"#)); +} + +#[test] +fn create_with_description() { + let tmp = init_tmp(); + + td().args([ + "--json", + "create", + "Fix login", + "-d", + "The login page is broken", + ]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("The login page is broken")); +} + +#[test] +fn create_with_labels() { + let tmp = init_tmp(); + + td().args(["--json", "create", "Labelled task", "-l", "frontend,urgent"]) + .current_dir(&tmp) + .assert() + .success(); + + // Verify labels are stored by checking the database directly. + let conn = rusqlite::Connection::open(tmp.path().join(".td/tasks.db")).unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM labels", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 2); +} + +#[test] +fn create_requires_title() { + let tmp = init_tmp(); + + td().arg("create") + .current_dir(&tmp) + .assert() + .failure() + .stderr(predicate::str::contains("title required")); +} + +#[test] +fn create_subtask_under_parent() { + let tmp = init_tmp(); + + // Create parent, extract its id. + let parent_out = td() + .args(["--json", "create", "Parent task"]) + .current_dir(&tmp) + .output() + .unwrap(); + let parent: serde_json::Value = serde_json::from_slice(&parent_out.stdout).unwrap(); + let parent_id = parent["id"].as_str().unwrap(); + + // Create child under parent. + let child_out = td() + .args(["--json", "create", "Child task", "--parent", parent_id]) + .current_dir(&tmp) + .output() + .unwrap(); + let child: serde_json::Value = serde_json::from_slice(&child_out.stdout).unwrap(); + let child_id = child["id"].as_str().unwrap(); + + // Child id should start with parent id. + assert!( + child_id.starts_with(parent_id), + "child id '{child_id}' should start with parent id '{parent_id}'" + ); + assert_eq!(child["parent"].as_str().unwrap(), parent_id); +} diff --git a/tests/cli_dep.rs b/tests/cli_dep.rs new file mode 100644 index 0000000000000000000000000000000000000000..0bdad01875d6f4443faf090fe2ea8bd9e9bc645a --- /dev/null +++ b/tests/cli_dep.rs @@ -0,0 +1,92 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn td() -> Command { + Command::cargo_bin("td").unwrap() +} + +fn init_tmp() -> TempDir { + let tmp = TempDir::new().unwrap(); + td().arg("init").current_dir(&tmp).assert().success(); + tmp +} + +fn create_task(dir: &TempDir, title: &str) -> String { + let out = td() + .args(["--json", "create", title]) + .current_dir(dir) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + v["id"].as_str().unwrap().to_string() +} + +fn get_task_json(dir: &TempDir, id: &str) -> serde_json::Value { + let out = td() + .args(["--json", "show", id]) + .current_dir(dir) + .output() + .unwrap(); + serde_json::from_slice(&out.stdout).unwrap() +} + +#[test] +fn dep_add_creates_blocker() { + let tmp = init_tmp(); + let a = create_task(&tmp, "Blocked task"); + let b = create_task(&tmp, "Blocker"); + + td().args(["dep", "add", &a, &b]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("blocked by")); + + let t = get_task_json(&tmp, &a); + let blockers = t["blockers"].as_array().unwrap(); + assert!(blockers.contains(&serde_json::Value::String(b))); +} + +#[test] +fn dep_rm_removes_blocker() { + let tmp = init_tmp(); + let a = create_task(&tmp, "Was blocked"); + let b = create_task(&tmp, "Was blocker"); + + td().args(["dep", "add", &a, &b]) + .current_dir(&tmp) + .assert() + .success(); + td().args(["dep", "rm", &a, &b]) + .current_dir(&tmp) + .assert() + .success(); + + let t = get_task_json(&tmp, &a); + let blockers = t["blockers"].as_array().unwrap(); + assert!(blockers.is_empty()); +} + +#[test] +fn dep_tree_shows_children() { + let tmp = init_tmp(); + let parent = create_task(&tmp, "Parent"); + + td().args(["create", "Child one", "--parent", &parent]) + .current_dir(&tmp) + .assert() + .success(); + td().args(["create", "Child two", "--parent", &parent]) + .current_dir(&tmp) + .assert() + .success(); + + td().args(["dep", "tree", &parent]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains(&parent)) + .stdout(predicate::str::contains(".1")) + .stdout(predicate::str::contains(".2")); +} diff --git a/tests/cli_init.rs b/tests/cli_init.rs new file mode 100644 index 0000000000000000000000000000000000000000..7cbafc9a73510b9ac57f47704929b254aedf3248 --- /dev/null +++ b/tests/cli_init.rs @@ -0,0 +1,80 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn td() -> Command { + Command::cargo_bin("td").unwrap() +} + +#[test] +fn init_creates_td_directory_and_database() { + let tmp = TempDir::new().unwrap(); + + td().arg("init") + .current_dir(&tmp) + .assert() + .success() + .stderr(predicate::str::contains("initialized .td/")); + + assert!(tmp.path().join(".td").is_dir()); + assert!(tmp.path().join(".td/tasks.db").is_file()); +} + +#[test] +fn init_creates_schema_with_expected_tables() { + let tmp = TempDir::new().unwrap(); + + td().arg("init").current_dir(&tmp).assert().success(); + + let conn = rusqlite::Connection::open(tmp.path().join(".td/tasks.db")).unwrap(); + + // Verify all three tables exist by querying sqlite_master. + let tables: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + + assert!(tables.contains(&"tasks".to_string())); + assert!(tables.contains(&"labels".to_string())); + assert!(tables.contains(&"blockers".to_string())); +} + +#[test] +fn init_fails_when_already_initialized() { + let tmp = TempDir::new().unwrap(); + + td().arg("init").current_dir(&tmp).assert().success(); + + td().arg("init") + .current_dir(&tmp) + .assert() + .failure() + .stderr(predicate::str::contains("already initialized")); +} + +#[test] +fn init_stealth_adds_gitignore_entry() { + let tmp = TempDir::new().unwrap(); + + td().args(["init", "--stealth"]) + .current_dir(&tmp) + .assert() + .success(); + + let gitignore = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap(); + assert!(gitignore.contains(".td/")); +} + +#[test] +fn init_json_outputs_success() { + let tmp = TempDir::new().unwrap(); + + td().args(["--json", "init"]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains(r#"{"success":true}"#)); +} diff --git a/tests/cli_io.rs b/tests/cli_io.rs new file mode 100644 index 0000000000000000000000000000000000000000..a33ac56d2a8e926e945b9f36e14caa6677ca5a65 --- /dev/null +++ b/tests/cli_io.rs @@ -0,0 +1,111 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn td() -> Command { + Command::cargo_bin("td").unwrap() +} + +fn init_tmp() -> TempDir { + let tmp = TempDir::new().unwrap(); + td().arg("init").current_dir(&tmp).assert().success(); + tmp +} + +fn create_task(dir: &TempDir, title: &str) -> String { + let out = td() + .args(["--json", "create", title]) + .current_dir(dir) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + v["id"].as_str().unwrap().to_string() +} + +#[test] +fn export_produces_jsonl() { + let tmp = init_tmp(); + create_task(&tmp, "First"); + create_task(&tmp, "Second"); + + let out = td().arg("export").current_dir(&tmp).output().unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); + let lines: Vec<&str> = stdout.lines().collect(); + assert_eq!(lines.len(), 2, "expected 2 JSONL lines, got: {stdout}"); + + // Each line should be valid JSON with an id field. + for line in &lines { + let v: serde_json::Value = serde_json::from_str(line).unwrap(); + assert!(v["id"].is_string()); + } +} + +#[test] +fn export_includes_labels_and_blockers() { + let tmp = init_tmp(); + td().args(["create", "With labels", "-l", "bug"]) + .current_dir(&tmp) + .assert() + .success(); + + let out = td().arg("export").current_dir(&tmp).output().unwrap(); + let line = String::from_utf8(out.stdout).unwrap(); + let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap(); + assert!(v["labels"].is_array()); + assert!(v["blockers"].is_array()); +} + +#[test] +fn import_round_trips_with_export() { + let tmp = init_tmp(); + create_task(&tmp, "Alpha"); + + td().args(["create", "Bravo", "-l", "important"]) + .current_dir(&tmp) + .assert() + .success(); + + // Export. + let export_out = td().arg("export").current_dir(&tmp).output().unwrap(); + let exported = String::from_utf8(export_out.stdout).unwrap(); + + // Write to a file. + let export_file = tmp.path().join("backup.jsonl"); + std::fs::write(&export_file, &exported).unwrap(); + + // Create a fresh directory, init, import. + let tmp2 = TempDir::new().unwrap(); + td().arg("init").current_dir(&tmp2).assert().success(); + + td().args(["import", export_file.to_str().unwrap()]) + .current_dir(&tmp2) + .assert() + .success() + .stderr(predicate::str::contains("import complete")); + + // Verify tasks exist in the new database. + let out = td() + .args(["--json", "list"]) + .current_dir(&tmp2) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let titles: Vec<&str> = v + .as_array() + .unwrap() + .iter() + .map(|t| t["title"].as_str().unwrap()) + .collect(); + assert!(titles.contains(&"Alpha")); + assert!(titles.contains(&"Bravo")); + + // Verify labels survived. + let bravo = v + .as_array() + .unwrap() + .iter() + .find(|t| t["title"] == "Bravo") + .unwrap(); + let labels = bravo["labels"].as_array().unwrap(); + assert!(labels.contains(&serde_json::Value::String("important".into()))); +} diff --git a/tests/cli_label.rs b/tests/cli_label.rs new file mode 100644 index 0000000000000000000000000000000000000000..fa6f9052e5407241e2b7ddaab2621cdd775d3f75 --- /dev/null +++ b/tests/cli_label.rs @@ -0,0 +1,89 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn td() -> Command { + Command::cargo_bin("td").unwrap() +} + +fn init_tmp() -> TempDir { + let tmp = TempDir::new().unwrap(); + td().arg("init").current_dir(&tmp).assert().success(); + tmp +} + +fn create_task(dir: &TempDir, title: &str) -> String { + let out = td() + .args(["--json", "create", title]) + .current_dir(dir) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + v["id"].as_str().unwrap().to_string() +} + +#[test] +fn label_add_and_list() { + let tmp = init_tmp(); + let id = create_task(&tmp, "Tag me"); + + td().args(["label", "add", &id, "important"]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("added")); + + td().args(["label", "list", &id]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("important")); +} + +#[test] +fn label_rm_removes_label() { + let tmp = init_tmp(); + let id = create_task(&tmp, "Untag me"); + + td().args(["label", "add", &id, "temp"]) + .current_dir(&tmp) + .assert() + .success(); + td().args(["label", "rm", &id, "temp"]) + .current_dir(&tmp) + .assert() + .success(); + + td().args(["label", "list", &id]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::is_empty()); +} + +#[test] +fn label_list_all_shows_distinct_labels() { + let tmp = init_tmp(); + let a = create_task(&tmp, "A"); + let b = create_task(&tmp, "B"); + + td().args(["label", "add", &a, "bug"]) + .current_dir(&tmp) + .assert() + .success(); + td().args(["label", "add", &b, "bug"]) + .current_dir(&tmp) + .assert() + .success(); + td().args(["label", "add", &b, "ui"]) + .current_dir(&tmp) + .assert() + .success(); + + td().args(["label", "list-all"]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("bug")) + .stdout(predicate::str::contains("ui")); +} diff --git a/tests/cli_list_show.rs b/tests/cli_list_show.rs new file mode 100644 index 0000000000000000000000000000000000000000..0e4b0643bd7ddd0c1dd615979b1d1a956bf7fffd --- /dev/null +++ b/tests/cli_list_show.rs @@ -0,0 +1,179 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn td() -> Command { + Command::cargo_bin("td").unwrap() +} + +fn init_tmp() -> TempDir { + let tmp = TempDir::new().unwrap(); + td().arg("init").current_dir(&tmp).assert().success(); + tmp +} + +/// Create a task and return its JSON id. +fn create_task(dir: &TempDir, title: &str) -> String { + let out = td() + .args(["--json", "create", title]) + .current_dir(dir) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + v["id"].as_str().unwrap().to_string() +} + +// ── list ───────────────────────────────────────────────────────────── + +#[test] +fn list_shows_created_tasks() { + let tmp = init_tmp(); + create_task(&tmp, "Alpha"); + create_task(&tmp, "Bravo"); + + td().arg("list") + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("Alpha")) + .stdout(predicate::str::contains("Bravo")); +} + +#[test] +fn list_json_returns_array() { + let tmp = init_tmp(); + create_task(&tmp, "One"); + + let out = td() + .args(["--json", "list"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!(v.is_array(), "expected JSON array, got: {v}"); + assert_eq!(v.as_array().unwrap().len(), 1); + assert_eq!(v[0]["title"].as_str().unwrap(), "One"); +} + +#[test] +fn list_filter_by_status() { + let tmp = init_tmp(); + create_task(&tmp, "Open task"); + + // No closed tasks yet. + let out = td() + .args(["--json", "list", "-s", "closed"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v.as_array().unwrap().len(), 0); +} + +#[test] +fn list_filter_by_priority() { + let tmp = init_tmp(); + + td().args(["create", "Low prio", "-p", "3"]) + .current_dir(&tmp) + .assert() + .success(); + td().args(["create", "High prio", "-p", "1"]) + .current_dir(&tmp) + .assert() + .success(); + + let out = td() + .args(["--json", "list", "-p", "1"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let tasks = v.as_array().unwrap(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0]["title"].as_str().unwrap(), "High prio"); +} + +#[test] +fn list_filter_by_label() { + let tmp = init_tmp(); + + td().args(["create", "Tagged", "-l", "urgent"]) + .current_dir(&tmp) + .assert() + .success(); + td().args(["create", "Untagged"]) + .current_dir(&tmp) + .assert() + .success(); + + let out = td() + .args(["--json", "list", "-l", "urgent"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let tasks = v.as_array().unwrap(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0]["title"].as_str().unwrap(), "Tagged"); +} + +// ── show ───────────────────────────────────────────────────────────── + +#[test] +fn show_displays_task() { + let tmp = init_tmp(); + let id = create_task(&tmp, "Details here"); + + td().args(["show", &id]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("Details here")) + .stdout(predicate::str::contains(&id)); +} + +#[test] +fn show_json_includes_labels_and_blockers() { + let tmp = init_tmp(); + + td().args(["create", "With labels", "-l", "bug,ui"]) + .current_dir(&tmp) + .assert() + .success(); + + // Get the id via list. + let out = td() + .args(["--json", "list"]) + .current_dir(&tmp) + .output() + .unwrap(); + let list: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let id = list[0]["id"].as_str().unwrap(); + + let out = td() + .args(["--json", "show", id]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["title"].as_str().unwrap(), "With labels"); + + let labels = v["labels"].as_array().unwrap(); + assert!(labels.contains(&serde_json::Value::String("bug".into()))); + assert!(labels.contains(&serde_json::Value::String("ui".into()))); + + // Blockers should be present (even if empty). + assert!(v["blockers"].is_array()); +} + +#[test] +fn show_nonexistent_task_fails() { + let tmp = init_tmp(); + + td().args(["show", "td-nope"]) + .current_dir(&tmp) + .assert() + .failure() + .stderr(predicate::str::contains("not found")); +} diff --git a/tests/cli_query.rs b/tests/cli_query.rs new file mode 100644 index 0000000000000000000000000000000000000000..913ed5ff9da053ae09f78535dc17d145cb03bd77 --- /dev/null +++ b/tests/cli_query.rs @@ -0,0 +1,153 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn td() -> Command { + Command::cargo_bin("td").unwrap() +} + +fn init_tmp() -> TempDir { + let tmp = TempDir::new().unwrap(); + td().arg("init").current_dir(&tmp).assert().success(); + tmp +} + +fn create_task(dir: &TempDir, title: &str) -> String { + let out = td() + .args(["--json", "create", title]) + .current_dir(dir) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + v["id"].as_str().unwrap().to_string() +} + +// ── search ─────────────────────────────────────────────────────────── + +#[test] +fn search_matches_title() { + let tmp = init_tmp(); + create_task(&tmp, "Fix login page"); + create_task(&tmp, "Update docs"); + + td().args(["search", "login"]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("Fix login page")); +} + +#[test] +fn search_matches_description() { + let tmp = init_tmp(); + + td().args(["create", "Vague title", "-d", "The frobnicator is broken"]) + .current_dir(&tmp) + .assert() + .success(); + + td().args(["search", "frobnicator"]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("Vague title")); +} + +#[test] +fn search_json_returns_array() { + let tmp = init_tmp(); + create_task(&tmp, "Needle in haystack"); + + let out = td() + .args(["--json", "search", "Needle"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!(v.is_array()); + assert_eq!(v[0]["title"].as_str().unwrap(), "Needle in haystack"); +} + +// ── ready ──────────────────────────────────────────────────────────── + +#[test] +fn ready_excludes_blocked_tasks() { + let tmp = init_tmp(); + let a = create_task(&tmp, "Ready task"); + let b = create_task(&tmp, "Blocked task"); + let c = create_task(&tmp, "Blocker task"); + + td().args(["dep", "add", &b, &c]) + .current_dir(&tmp) + .assert() + .success(); + + let out = td() + .args(["--json", "ready"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let titles: Vec<&str> = v + .as_array() + .unwrap() + .iter() + .map(|t| t["title"].as_str().unwrap()) + .collect(); + + assert!(titles.contains(&"Ready task")); + assert!(titles.contains(&"Blocker task")); + assert!(!titles.contains(&"Blocked task")); + + // Close the blocker — now the blocked task should become ready. + td().args(["done", &c]).current_dir(&tmp).assert().success(); + + let out = td() + .args(["--json", "ready"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let titles: Vec<&str> = v + .as_array() + .unwrap() + .iter() + .map(|t| t["title"].as_str().unwrap()) + .collect(); + assert!(titles.contains(&"Blocked task")); + // a is still ready + assert!(titles.contains(&"Ready task")); +} + +// ── stats ──────────────────────────────────────────────────────────── + +#[test] +fn stats_counts_tasks() { + let tmp = init_tmp(); + let id = create_task(&tmp, "Open one"); + create_task(&tmp, "Open two"); + td().args(["done", &id]) + .current_dir(&tmp) + .assert() + .success(); + + let out = td().args(["stats"]).current_dir(&tmp).output().unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["total"].as_i64().unwrap(), 2); + assert_eq!(v["open"].as_i64().unwrap(), 1); + assert_eq!(v["closed"].as_i64().unwrap(), 1); +} + +// ── compact ────────────────────────────────────────────────────────── + +#[test] +fn compact_succeeds() { + let tmp = init_tmp(); + create_task(&tmp, "Anything"); + + td().arg("compact") + .current_dir(&tmp) + .assert() + .success() + .stderr(predicate::str::contains("done")); +} diff --git a/tests/cli_update.rs b/tests/cli_update.rs new file mode 100644 index 0000000000000000000000000000000000000000..531074c2e1d599f4a274be3ee86f4f1c4c042a53 --- /dev/null +++ b/tests/cli_update.rs @@ -0,0 +1,159 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn td() -> Command { + Command::cargo_bin("td").unwrap() +} + +fn init_tmp() -> TempDir { + let tmp = TempDir::new().unwrap(); + td().arg("init").current_dir(&tmp).assert().success(); + tmp +} + +fn create_task(dir: &TempDir, title: &str) -> String { + let out = td() + .args(["--json", "create", title]) + .current_dir(dir) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + v["id"].as_str().unwrap().to_string() +} + +fn get_task_json(dir: &TempDir, id: &str) -> serde_json::Value { + let out = td() + .args(["--json", "show", id]) + .current_dir(dir) + .output() + .unwrap(); + serde_json::from_slice(&out.stdout).unwrap() +} + +// ── update ─────────────────────────────────────────────────────────── + +#[test] +fn update_changes_status() { + let tmp = init_tmp(); + let id = create_task(&tmp, "In progress"); + + td().args(["update", &id, "-s", "in_progress"]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("updated")); + + let t = get_task_json(&tmp, &id); + assert_eq!(t["status"].as_str().unwrap(), "in_progress"); +} + +#[test] +fn update_changes_priority() { + let tmp = init_tmp(); + let id = create_task(&tmp, "Reprioritise"); + + td().args(["update", &id, "-p", "1"]) + .current_dir(&tmp) + .assert() + .success(); + + let t = get_task_json(&tmp, &id); + assert_eq!(t["priority"].as_i64().unwrap(), 1); +} + +#[test] +fn update_changes_title() { + let tmp = init_tmp(); + let id = create_task(&tmp, "Old title"); + + td().args(["update", &id, "-t", "New title"]) + .current_dir(&tmp) + .assert() + .success(); + + let t = get_task_json(&tmp, &id); + assert_eq!(t["title"].as_str().unwrap(), "New title"); +} + +#[test] +fn update_changes_description() { + let tmp = init_tmp(); + let id = create_task(&tmp, "Describe me"); + + td().args(["update", &id, "-d", "Now with details"]) + .current_dir(&tmp) + .assert() + .success(); + + let t = get_task_json(&tmp, &id); + assert_eq!(t["description"].as_str().unwrap(), "Now with details"); +} + +#[test] +fn update_json_returns_task() { + let tmp = init_tmp(); + let id = create_task(&tmp, "JSON update"); + + let out = td() + .args(["--json", "update", &id, "-p", "1"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["priority"].as_i64().unwrap(), 1); +} + +// ── done ───────────────────────────────────────────────────────────── + +#[test] +fn done_closes_task() { + let tmp = init_tmp(); + let id = create_task(&tmp, "Close me"); + + td().args(["done", &id]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("closed")); + + let t = get_task_json(&tmp, &id); + assert_eq!(t["status"].as_str().unwrap(), "closed"); +} + +#[test] +fn done_closes_multiple_tasks() { + let tmp = init_tmp(); + let id1 = create_task(&tmp, "First"); + let id2 = create_task(&tmp, "Second"); + + td().args(["done", &id1, &id2]) + .current_dir(&tmp) + .assert() + .success(); + + assert_eq!(get_task_json(&tmp, &id1)["status"], "closed"); + assert_eq!(get_task_json(&tmp, &id2)["status"], "closed"); +} + +// ── reopen ─────────────────────────────────────────────────────────── + +#[test] +fn reopen_reopens_closed_task() { + let tmp = init_tmp(); + let id = create_task(&tmp, "Reopen me"); + + td().args(["done", &id]) + .current_dir(&tmp) + .assert() + .success(); + assert_eq!(get_task_json(&tmp, &id)["status"], "closed"); + + td().args(["reopen", &id]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("reopened")); + + assert_eq!(get_task_json(&tmp, &id)["status"], "open"); +}