.gitignore 🔗
@@ -0,0 +1 @@
+target/
Amolith created
.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, 3,386 insertions(+)
@@ -0,0 +1 @@
+target/
@@ -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"
@@ -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"
@@ -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"
@@ -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] <COMMAND>
+
+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
+```
@@ -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<String>,
+
+ /// 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<String>,
+
+ /// Parent task ID (creates a subtask)
+ #[arg(long)]
+ parent: Option<String>,
+
+ /// Labels (comma-separated)
+ #[arg(short, long)]
+ labels: Option<String>,
+ },
+
+ /// List tasks
+ #[command(visible_alias = "ls")]
+ List {
+ /// Filter by status
+ #[arg(short, long)]
+ status: Option<String>,
+
+ /// Filter by priority
+ #[arg(short, long)]
+ priority: Option<i32>,
+
+ /// Filter by label
+ #[arg(short, long)]
+ label: Option<String>,
+ },
+
+ /// Show task details
+ Show {
+ /// Task ID
+ id: String,
+ },
+
+ /// Update a task
+ Update {
+ /// Task ID
+ id: String,
+
+ /// Set status
+ #[arg(short, long)]
+ status: Option<String>,
+
+ /// Set priority
+ #[arg(short, long)]
+ priority: Option<i32>,
+
+ /// Set title
+ #[arg(short = 't', long)]
+ title: Option<String>,
+
+ /// Set description
+ #[arg(short = 'd', long = "desc")]
+ desc: Option<String>,
+ },
+
+ /// Mark task(s) as closed
+ #[command(visible_alias = "close")]
+ Done {
+ /// Task IDs
+ #[arg(required = true)]
+ ids: Vec<String>,
+ },
+
+ /// Reopen task(s)
+ Reopen {
+ /// Task IDs
+ #[arg(required = true)]
+ ids: Vec<String>,
+ },
+
+ /// 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,
+}
@@ -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(())
+}
@@ -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(())
+}
@@ -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<String> = stmt
+ .query_map([id], |r| r.get(0))?
+ .collect::<rusqlite::Result<_>>()?;
+ for child in &children {
+ println!(" {child}");
+ }
+ }
+ }
+
+ Ok(())
+}
@@ -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<serde_json::Value> = ids
+ .iter()
+ .map(|id| {
+ Ok(serde_json::json!({
+ "id": id,
+ "status": "closed",
+ }))
+ })
+ .collect::<Result<_>>()?;
+ println!("{}", serde_json::to_string(&details)?);
+ }
+
+ Ok(())
+}
@@ -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<db::Task> = stmt
+ .query_map([], db::row_to_task)?
+ .collect::<rusqlite::Result<_>>()?;
+
+ 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(())
+}
@@ -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<String>,
+ #[serde(default)]
+ blockers: Vec<String>,
+}
+
+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<dyn BufRead> = 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(())
+}
@@ -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(())
+}
@@ -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<String> = stmt
+ .query_map([], |r| r.get(0))?
+ .collect::<rusqlite::Result<_>>()?;
+ if json {
+ println!("{}", serde_json::to_string(&labels)?);
+ } else {
+ for l in &labels {
+ println!("{l}");
+ }
+ }
+ }
+ }
+
+ Ok(())
+}
@@ -0,0 +1,81 @@
+use anyhow::Result;
+use std::path::Path;
+
+use crate::db;
+
+pub fn run(
+ root: &Path,
+ status: Option<&str>,
+ priority: Option<i32>,
+ 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<Box<dyn rusqlite::types::ToSql>> = 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<db::Task> = stmt
+ .query_map(param_refs.as_slice(), db::row_to_task)?
+ .collect::<rusqlite::Result<_>>()?;
+
+ if json {
+ let details: Vec<db::TaskDetail> = 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::<Result<_>>()?;
+ 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(())
+}
@@ -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<std::path::PathBuf> {
+ 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)
+ }
+ }
+}
@@ -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<db::Task> = stmt
+ .query_map([], db::row_to_task)?
+ .collect::<rusqlite::Result<_>>()?;
+
+ if json {
+ let summary: Vec<serde_json::Value> = 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(())
+}
@@ -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<serde_json::Value> = ids
+ .iter()
+ .map(|id| {
+ Ok(serde_json::json!({
+ "id": id,
+ "status": "open",
+ }))
+ })
+ .collect::<Result<_>>()?;
+ println!("{}", serde_json::to_string(&details)?);
+ }
+
+ Ok(())
+}
@@ -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<db::Task> = stmt
+ .query_map([&pattern], db::row_to_task)?
+ .collect::<rusqlite::Result<_>>()?;
+
+ if json {
+ let summary: Vec<serde_json::Value> = 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(())
+}
@@ -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(())
+}
@@ -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(())
+}
@@ -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<i32>,
+ 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<Box<dyn rusqlite::types::ToSql>> = 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(())
+}
@@ -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
+ }
+}
@@ -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<String>,
+ pub blockers: Vec<String>,
+}
+
+/// 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<Task> {
+ 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<Vec<String>> {
+ 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::<rusqlite::Result<Vec<String>>>()?;
+ Ok(labels)
+}
+
+/// Load blockers for a task.
+pub fn load_blockers(conn: &Connection, task_id: &str) -> Result<Vec<String>> {
+ 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::<rusqlite::Result<Vec<String>>>()?;
+ Ok(blockers)
+}
+
+/// Load a full task with labels and blockers.
+pub fn load_task_detail(conn: &Connection, id: &str) -> Result<TaskDetail> {
+ 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<PathBuf> {
+ 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<Connection> {
+ 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<Connection> {
+ 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)
+}
@@ -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)
+}
@@ -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);
+ }
+}
@@ -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);
+}
@@ -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"));
+}
@@ -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<String> = 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}"#));
+}
@@ -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())));
+}
@@ -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"));
+}
@@ -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"));
+}
@@ -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"));
+}
@@ -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");
+}