heblo

Amolith created

Change summary

.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(+)

Detailed changes

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"

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"

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"

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] <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
+```

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<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,
+}

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(())
+}

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(())
+}

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<String> = stmt
+                .query_map([id], |r| r.get(0))?
+                .collect::<rusqlite::Result<_>>()?;
+            for child in &children {
+                println!("  {child}");
+            }
+        }
+    }
+
+    Ok(())
+}

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<serde_json::Value> = ids
+            .iter()
+            .map(|id| {
+                Ok(serde_json::json!({
+                    "id": id,
+                    "status": "closed",
+                }))
+            })
+            .collect::<Result<_>>()?;
+        println!("{}", serde_json::to_string(&details)?);
+    }
+
+    Ok(())
+}

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<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(())
+}

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<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(())
+}

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(())
+}

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<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(())
+}

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<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(())
+}

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<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)
+        }
+    }
+}

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<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(())
+}

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<serde_json::Value> = ids
+            .iter()
+            .map(|id| {
+                Ok(serde_json::json!({
+                    "id": id,
+                    "status": "open",
+                }))
+            })
+            .collect::<Result<_>>()?;
+        println!("{}", serde_json::to_string(&details)?);
+    }
+
+    Ok(())
+}

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<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(())
+}

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(())
+}

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(())
+}

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<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(())
+}

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
+    }
+}

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<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)
+}

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)
+}

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);
+    }
+}

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);
+}

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"));
+}

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<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}"#));
+}

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())));
+}

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"));
+}

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"));
+}

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"));
+}

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");
+}