Initial release: Citrine v1.0.0

Minimalist plaintext project-based todo CLI in Rust.
Commands: init, add, list, set, done, rm.
Supports .citrine + .citrine.local merging, partial hash matching,
status filtering, and colorized output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-02-14 17:49:32 +01:00
commit a6b6cddb49
17 changed files with 1898 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/target
/dist
.claude
CITRINE_SPEC.md

980
Cargo.lock generated Normal file
View File

@@ -0,0 +1,980 @@
# 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 0.61.2",
]
[[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 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[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 = "bumpalo"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[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.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "citrine"
version = "1.0.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"colored",
"rand",
"regex",
"tempfile",
]
[[package]]
name = "clap"
version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
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 = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[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 0.61.2",
]
[[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 = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[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 = "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.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "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 = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[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 = "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 = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[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 = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[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.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
[[package]]
name = "rustix"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[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",
]
[[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 = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
dependencies = [
"fastrand",
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "unicode-ident"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
[[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 = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[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.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
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.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[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 = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[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 = "zerocopy"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

20
Cargo.toml Normal file
View File

@@ -0,0 +1,20 @@
[package]
name = "citrine"
version = "1.0.0"
edition = "2021"
[[bin]]
name = "citrine"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
rand = "0.8"
regex = "1"
chrono = "0.4"
anyhow = "1"
colored = "2"
[dev-dependencies]
tempfile = "3"
regex = "1"

81
README.md Normal file
View File

@@ -0,0 +1,81 @@
# Citrine
Minimalist, plaintext, project-based todo CLI. Creates `.citrine` files in your project directory — human-readable, git-friendly, and AI-agent-parseable.
## Install
Download a binary from [releases](https://github.com/syntaxbullet/citrine/releases) or build from source:
```bash
cargo install --path .
```
## Quick Start
```bash
citrine init
citrine add "Implement caching layer"
citrine add "Fix mobile layout" --local
citrine list
citrine set a4f9 progress
citrine done a4f9
citrine rm a4f9
```
## Commands
| Command | Description |
|---|---|
| `citrine init` | Initialize citrine in the current directory |
| `citrine add <description> [--local]` | Add a new task (`--local` for `.citrine.local`) |
| `citrine list [--status <s>] [--local]` | List tasks, optionally filtered |
| `citrine set <hash> <status>` | Change task status |
| `citrine done <hash>` | Mark task as done (shorthand for `set <hash> done`) |
| `citrine rm <hash>` | Remove a task |
**Statuses:** `todo`, `progress`, `blocked`, `done`
**Hashes** can be partial — `a4` works if it's unambiguous.
## File Format
```
## Backend
[a4f9] [ ] Implement caching layer @priority:high @backend
[b2e1] [>] Refactor auth module @assigned:cursor
[c3d8] [-] Deploy to staging @blocked:waiting-for-credentials
[f1a2] [x] Set up Docker config @completed:2025-02-14
## Frontend
[d4b3] [ ] Build settings page
[e5c2] [>] Fix mobile layout issues @priority:medium
```
### Status Symbols
| Symbol | Meaning |
|---|---|
| `[ ]` | Todo |
| `[>]` | In Progress |
| `[-]` | Blocked |
| `[x]` | Done |
### Metadata
Append `@tag` or `@key:value` to any task. Free-form, not validated. `@completed:YYYY-MM-DD` is auto-added when marking a task done.
## Files
- **`.citrine`** — shared project tasks, commit to git
- **`.citrine.local`** — personal tasks, gitignored
Both share the same hash namespace. `citrine list` merges both by default.
## Exit Codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Generic error |
| 2 | Invalid arguments |
| 3 | Not initialized |

35
src/commands/add.rs Normal file
View File

@@ -0,0 +1,35 @@
use crate::file::{collect_all_hashes, CitrineFile};
use crate::hash::generate_hash;
use crate::task::{Line, Status, Task};
use anyhow::Result;
use std::path::Path;
pub fn run(dir: &Path, description: &str, local: bool) -> Result<()> {
let citrine_path = dir.join(".citrine");
if !citrine_path.exists() {
anyhow::bail!("Not initialized. Run 'citrine init' first");
}
let existing = collect_all_hashes(dir);
let hash = generate_hash(&existing)?;
let target = if local {
dir.join(".citrine.local")
} else {
citrine_path
};
let mut file = CitrineFile::read_or_empty(&target);
let task = Task {
hash: hash.clone(),
status: Status::Todo,
description: description.to_string(),
metadata: String::new(),
};
file.lines.push(Line::Task(task));
file.write(&target)?;
let suffix = if local { " (local)" } else { "" };
println!("Added task [{}]: {}{}", hash, description, suffix);
Ok(())
}

28
src/commands/init.rs Normal file
View File

@@ -0,0 +1,28 @@
use anyhow::Result;
use std::fs;
use std::path::Path;
pub fn run(dir: &Path) -> Result<()> {
let citrine_path = dir.join(".citrine");
if !citrine_path.exists() {
fs::write(&citrine_path, "")?;
}
println!("Initialized citrine in {}", dir.display());
let gitignore_path = dir.join(".gitignore");
let entry = ".citrine.local";
if gitignore_path.exists() {
let content = fs::read_to_string(&gitignore_path)?;
if !content.lines().any(|l| l.trim() == entry) {
let separator = if content.ends_with('\n') || content.is_empty() { "" } else { "\n" };
fs::write(&gitignore_path, format!("{}{}{}\n", content, separator, entry))?;
println!("Added {} to .gitignore", entry);
}
} else {
fs::write(&gitignore_path, format!("{}\n", entry))?;
println!("Added {} to .gitignore", entry);
}
Ok(())
}

74
src/commands/list.rs Normal file
View File

@@ -0,0 +1,74 @@
use crate::file::CitrineFile;
use crate::task::{Line, Status};
use anyhow::Result;
use colored::Colorize;
use std::path::Path;
pub fn run(dir: &Path, status_filter: Option<&str>, local_only: bool) -> Result<()> {
let citrine_path = dir.join(".citrine");
if !citrine_path.exists() {
anyhow::bail!("Not initialized. Run 'citrine init' first");
}
let filter = match status_filter {
Some(s) => Some(Status::from_name(s).ok_or_else(|| {
anyhow::anyhow!("Invalid status '{}'. Valid statuses: todo, progress, blocked, done", s)
})?),
None => None,
};
let files: Vec<CitrineFile> = if local_only {
vec![CitrineFile::read_or_empty(&dir.join(".citrine.local"))]
} else {
vec![
CitrineFile::read_or_empty(&citrine_path),
CitrineFile::read_or_empty(&dir.join(".citrine.local")),
]
};
let mut has_output = false;
for file in &files {
for line in &file.lines {
match line {
Line::Task(t) => {
if filter.as_ref().map_or(true, |f| t.status == *f) {
let status_str = match t.status {
Status::Todo => format!("[{}]", t.status.symbol()),
Status::InProgress => format!("[{}]", t.status.symbol()).yellow().to_string(),
Status::Blocked => format!("[{}]", t.status.symbol()).red().to_string(),
Status::Done => format!("[{}]", t.status.symbol()).green().to_string(),
};
let meta = if t.metadata.is_empty() {
String::new()
} else {
format!(" {}", t.metadata.dimmed())
};
println!("[{}] {} {}{}", t.hash, status_str, t.description, meta);
has_output = true;
}
}
Line::Section(s) => {
if filter.is_none() {
println!("{}", format!("## {}", s).bold());
has_output = true;
}
}
Line::Other(s) if !s.trim().is_empty() && filter.is_none() => {
println!("{}", s);
has_output = true;
}
Line::Other(_) if filter.is_none() => {
println!();
has_output = true;
}
_ => {}
}
}
}
if !has_output && filter.is_some() {
println!("No tasks matching filter.");
}
Ok(())
}

5
src/commands/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod init;
pub mod add;
pub mod list;
pub mod set;
pub mod rm;

29
src/commands/rm.rs Normal file
View File

@@ -0,0 +1,29 @@
use crate::file::{resolve_hash, CitrineFile};
use anyhow::Result;
use std::path::Path;
pub fn run(dir: &Path, partial_hash: &str) -> Result<()> {
let citrine_path = dir.join(".citrine");
if !citrine_path.exists() {
anyhow::bail!("Not initialized. Run 'citrine init' first");
}
let mut main_file = CitrineFile::read_or_empty(&citrine_path);
let mut local_file = CitrineFile::read_or_empty(&dir.join(".citrine.local"));
let (hash, is_local) = resolve_hash(&main_file, &local_file, partial_hash)?;
let file = if is_local { &mut local_file } else { &mut main_file };
let task = file.remove_task(&hash).unwrap();
println!("Removed [{}]: {}", hash, task.description);
let path = if is_local {
dir.join(".citrine.local")
} else {
citrine_path
};
file.write(&path)?;
Ok(())
}

49
src/commands/set.rs Normal file
View File

@@ -0,0 +1,49 @@
use crate::file::{resolve_hash, CitrineFile};
use crate::task::Status;
use anyhow::Result;
use chrono::Local;
use std::path::Path;
pub fn run(dir: &Path, partial_hash: &str, status_name: &str) -> Result<()> {
let citrine_path = dir.join(".citrine");
if !citrine_path.exists() {
anyhow::bail!("Not initialized. Run 'citrine init' first");
}
let new_status = Status::from_name(status_name).ok_or_else(|| {
anyhow::anyhow!("Invalid status '{}'. Valid statuses: todo, progress, blocked, done", status_name)
})?;
let mut main_file = CitrineFile::read_or_empty(&citrine_path);
let mut local_file = CitrineFile::read_or_empty(&dir.join(".citrine.local"));
let (hash, is_local) = resolve_hash(&main_file, &local_file, partial_hash)?;
let file = if is_local { &mut local_file } else { &mut main_file };
let task = file.find_task_mut(&hash).unwrap();
let old_status = task.status.clone();
task.status = new_status.clone();
if new_status == Status::Done && !task.metadata.contains("@completed:") {
let date = Local::now().format("%Y-%m-%d").to_string();
let completed_tag = format!("@completed:{}", date);
if task.metadata.is_empty() {
task.metadata = completed_tag;
} else {
task.metadata = format!("{} {}", task.metadata, completed_tag);
}
println!("Added @completed:{}", date);
}
println!("Updated [{}]: {}{}", hash, old_status, new_status);
let path = if is_local {
dir.join(".citrine.local")
} else {
citrine_path
};
file.write(&path)?;
Ok(())
}

89
src/file.rs Normal file
View File

@@ -0,0 +1,89 @@
use crate::task::{parse_line, Line, Task};
use anyhow::{Context, Result};
use std::collections::HashSet;
use std::fs;
use std::path::Path;
pub struct CitrineFile {
pub lines: Vec<Line>,
}
impl CitrineFile {
pub fn read(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let lines = content.lines().map(parse_line).collect();
Ok(CitrineFile { lines })
}
pub fn read_or_empty(path: &Path) -> Self {
Self::read(path).unwrap_or(CitrineFile { lines: vec![] })
}
pub fn write(&self, path: &Path) -> Result<()> {
let content: String = self.lines.iter().map(|l| l.to_string()).collect::<Vec<_>>().join("\n");
let content = if content.is_empty() { content } else { content + "\n" };
fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))
}
pub fn tasks(&self) -> Vec<&Task> {
self.lines.iter().filter_map(|l| match l {
Line::Task(t) => Some(t),
_ => None,
}).collect()
}
pub fn hashes(&self) -> HashSet<String> {
self.tasks().iter().map(|t| t.hash.clone()).collect()
}
pub fn find_task_mut(&mut self, hash: &str) -> Option<&mut Task> {
self.lines.iter_mut().find_map(|l| match l {
Line::Task(t) if t.hash == hash => Some(t),
_ => None,
})
}
pub fn remove_task(&mut self, hash: &str) -> Option<Task> {
let pos = self.lines.iter().position(|l| matches!(l, Line::Task(t) if t.hash == hash));
if let Some(i) = pos {
if let Line::Task(t) = self.lines.remove(i) {
return Some(t);
}
}
None
}
}
pub fn collect_all_hashes(dir: &Path) -> HashSet<String> {
let mut hashes = HashSet::new();
let main_file = CitrineFile::read_or_empty(&dir.join(".citrine"));
let local_file = CitrineFile::read_or_empty(&dir.join(".citrine.local"));
hashes.extend(main_file.hashes());
hashes.extend(local_file.hashes());
hashes
}
pub fn resolve_hash(main: &CitrineFile, local: &CitrineFile, partial: &str) -> Result<(String, bool)> {
let mut matches: Vec<(String, bool)> = Vec::new();
for t in main.tasks() {
if t.hash.starts_with(partial) {
matches.push((t.hash.clone(), false));
}
}
for t in local.tasks() {
if t.hash.starts_with(partial) {
matches.push((t.hash.clone(), true));
}
}
match matches.len() {
0 => anyhow::bail!("Task not found: {}", partial),
1 => Ok(matches.remove(0)),
_ => {
let hash_list: Vec<&str> = matches.iter().map(|(h, _)| h.as_str()).collect();
anyhow::bail!("Ambiguous hash '{}': matches {}", partial, hash_list.join(", "));
}
}
}

35
src/hash.rs Normal file
View File

@@ -0,0 +1,35 @@
use anyhow::{bail, Result};
use rand::Rng;
use std::collections::HashSet;
pub fn generate_hash(existing: &HashSet<String>) -> Result<String> {
let mut rng = rand::thread_rng();
for _ in 0..10 {
let hash = format!("{:04x}", rng.gen_range(0..0x10000u32));
if !existing.contains(&hash) {
return Ok(hash);
}
}
bail!("Failed to generate unique hash after 10 attempts")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_hash() {
let existing = HashSet::new();
let hash = generate_hash(&existing).unwrap();
assert_eq!(hash.len(), 4);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_no_collision() {
let mut existing = HashSet::new();
existing.insert("abcd".to_string());
let hash = generate_hash(&existing).unwrap();
assert_ne!(hash, "abcd");
}
}

4
src/lib.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod task;
pub mod hash;
pub mod file;
pub mod commands;

84
src/main.rs Normal file
View File

@@ -0,0 +1,84 @@
use clap::{Parser, Subcommand};
use std::env;
use std::process;
mod task;
mod hash;
mod file;
mod commands;
#[derive(Parser)]
#[command(name = "citrine", version, about = "Minimalist plaintext todo CLI")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize citrine in current directory
Init,
/// Add a new task
Add {
/// Task description
description: String,
/// Add to .citrine.local instead
#[arg(long)]
local: bool,
},
/// List tasks
List {
/// Filter by status: todo, progress, blocked, done
#[arg(long)]
status: Option<String>,
/// Show only local tasks
#[arg(long)]
local: bool,
},
/// Set task status
Set {
/// Task hash (full or partial)
hash: String,
/// New status: todo, progress, blocked, done
status: String,
},
/// Mark task as done
Done {
/// Task hash (full or partial)
hash: String,
},
/// Remove a task
Rm {
/// Task hash (full or partial)
hash: String,
},
}
fn main() {
let cli = Cli::parse();
let dir = env::current_dir().unwrap_or_else(|e| {
eprintln!("Error: {}", e);
process::exit(1);
});
let result = match cli.command {
Commands::Init => commands::init::run(&dir),
Commands::Add { description, local } => commands::add::run(&dir, &description, local),
Commands::List { status, local } => commands::list::run(&dir, status.as_deref(), local),
Commands::Set { hash, status } => commands::set::run(&dir, &hash, &status),
Commands::Done { hash } => commands::set::run(&dir, &hash, "done"),
Commands::Rm { hash } => commands::rm::run(&dir, &hash),
};
if let Err(e) = result {
let msg = e.to_string();
eprintln!("Error: {}", msg);
if msg.contains("Not initialized") {
process::exit(3);
} else if msg.contains("Invalid status") || msg.contains("Ambiguous hash") {
process::exit(2);
} else {
process::exit(1);
}
}
}

168
src/task.rs Normal file
View File

@@ -0,0 +1,168 @@
use regex::Regex;
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub enum Status {
Todo,
InProgress,
Blocked,
Done,
}
impl Status {
pub fn symbol(&self) -> char {
match self {
Status::Todo => ' ',
Status::InProgress => '>',
Status::Blocked => '-',
Status::Done => 'x',
}
}
pub fn from_symbol(c: char) -> Option<Self> {
match c {
' ' => Some(Status::Todo),
'>' => Some(Status::InProgress),
'-' => Some(Status::Blocked),
'x' => Some(Status::Done),
_ => None,
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
"todo" => Some(Status::Todo),
"progress" => Some(Status::InProgress),
"blocked" => Some(Status::Blocked),
"done" => Some(Status::Done),
" " => Some(Status::Todo),
">" => Some(Status::InProgress),
"-" => Some(Status::Blocked),
"x" => Some(Status::Done),
_ => None,
}
}
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}]", self.symbol())
}
}
#[derive(Debug, Clone)]
pub struct Task {
pub hash: String,
pub status: Status,
pub description: String,
pub metadata: String,
}
impl Task {
pub fn to_line(&self) -> String {
let meta = if self.metadata.is_empty() {
String::new()
} else {
format!(" {}", self.metadata)
};
format!("[{}] {} {}{}", self.hash, self.status, self.description, meta)
}
}
#[derive(Debug, Clone)]
pub enum Line {
Task(Task),
Section(String),
Other(String),
}
impl Line {
pub fn to_string(&self) -> String {
match self {
Line::Task(t) => t.to_line(),
Line::Section(s) => format!("## {}", s),
Line::Other(s) => s.clone(),
}
}
}
pub fn parse_line(line: &str) -> Line {
let task_re = Regex::new(r"^\[([0-9a-f]{4})\]\s+\[([x\->]| )\]\s+(.+?)(?:\s+(@.*))?$").unwrap();
let section_re = Regex::new(r"^##\s+(.+)$").unwrap();
if let Some(caps) = task_re.captures(line) {
let hash = caps[1].to_string();
let status = Status::from_symbol(caps[2].chars().next().unwrap()).unwrap();
let description = caps[3].to_string();
let metadata = caps.get(4).map_or(String::new(), |m| m.as_str().to_string());
Line::Task(Task { hash, status, description, metadata })
} else if let Some(caps) = section_re.captures(line) {
Line::Section(caps[1].to_string())
} else {
Line::Other(line.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_task_line() {
let line = "[a4f9] [ ] Implement caching layer @priority:high @backend";
if let Line::Task(t) = parse_line(line) {
assert_eq!(t.hash, "a4f9");
assert_eq!(t.status, Status::Todo);
assert_eq!(t.description, "Implement caching layer");
assert_eq!(t.metadata, "@priority:high @backend");
} else {
panic!("Expected task");
}
}
#[test]
fn test_parse_done_task() {
let line = "[f1a2] [x] Set up Docker config @completed:2025-02-14";
if let Line::Task(t) = parse_line(line) {
assert_eq!(t.status, Status::Done);
} else {
panic!("Expected task");
}
}
#[test]
fn test_parse_section() {
let line = "## Backend";
if let Line::Section(s) = parse_line(line) {
assert_eq!(s, "Backend");
} else {
panic!("Expected section");
}
}
#[test]
fn test_parse_other() {
let line = "some random text";
assert!(matches!(parse_line(line), Line::Other(_)));
}
#[test]
fn test_task_roundtrip() {
let line = "[a4f9] [ ] Implement caching layer @priority:high @backend";
if let Line::Task(t) = parse_line(line) {
assert_eq!(t.to_line(), line);
}
}
#[test]
fn test_task_no_metadata() {
let line = "[d4b3] [ ] Build settings page";
if let Line::Task(t) = parse_line(line) {
assert_eq!(t.description, "Build settings page");
assert_eq!(t.metadata, "");
assert_eq!(t.to_line(), line);
} else {
panic!("Expected task");
}
}
}

9
tests/fixtures/sample.citrine vendored Normal file
View File

@@ -0,0 +1,9 @@
## Backend
[a4f9] [ ] Implement caching layer @priority:high @backend
[b2e1] [>] Refactor auth module @assigned:cursor
[c3d8] [-] Deploy to staging @blocked:waiting-for-credentials
[f1a2] [x] Set up Docker config @completed:2025-02-14
## Frontend
[d4b3] [ ] Build settings page
[e5c2] [>] Fix mobile layout issues @priority:medium

204
tests/integration.rs Normal file
View File

@@ -0,0 +1,204 @@
use std::process::Command;
use std::path::PathBuf;
use tempfile::TempDir;
fn citrine_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_citrine"))
}
fn run_citrine(dir: &std::path::Path, args: &[&str]) -> std::process::Output {
Command::new(citrine_bin())
.current_dir(dir)
.args(args)
.output()
.expect("Failed to execute citrine")
}
fn stdout(output: &std::process::Output) -> String {
String::from_utf8_lossy(&output.stdout).to_string()
}
fn stderr(output: &std::process::Output) -> String {
String::from_utf8_lossy(&output.stderr).to_string()
}
#[test]
fn test_init() {
let dir = TempDir::new().unwrap();
let out = run_citrine(dir.path(), &["init"]);
assert!(out.status.success());
assert!(stdout(&out).contains("Initialized citrine"));
assert!(dir.path().join(".citrine").exists());
assert!(dir.path().join(".gitignore").exists());
let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(gitignore.contains(".citrine.local"));
}
#[test]
fn test_init_idempotent() {
let dir = TempDir::new().unwrap();
run_citrine(dir.path(), &["init"]);
let out = run_citrine(dir.path(), &["init"]);
assert!(out.status.success());
}
#[test]
fn test_add_and_list() {
let dir = TempDir::new().unwrap();
run_citrine(dir.path(), &["init"]);
let out = run_citrine(dir.path(), &["add", "My first task"]);
assert!(out.status.success());
assert!(stdout(&out).contains("Added task"));
assert!(stdout(&out).contains("My first task"));
let out = run_citrine(dir.path(), &["list"]);
assert!(out.status.success());
assert!(stdout(&out).contains("My first task"));
}
#[test]
fn test_add_local() {
let dir = TempDir::new().unwrap();
run_citrine(dir.path(), &["init"]);
let out = run_citrine(dir.path(), &["add", "Local task", "--local"]);
assert!(out.status.success());
assert!(stdout(&out).contains("(local)"));
assert!(dir.path().join(".citrine.local").exists());
// Should appear in list
let out = run_citrine(dir.path(), &["list"]);
assert!(stdout(&out).contains("Local task"));
// Should appear in list --local
let out = run_citrine(dir.path(), &["list", "--local"]);
assert!(stdout(&out).contains("Local task"));
}
#[test]
fn test_add_not_initialized() {
let dir = TempDir::new().unwrap();
let out = run_citrine(dir.path(), &["add", "Some task"]);
assert!(!out.status.success());
assert_eq!(out.status.code(), Some(3));
assert!(stderr(&out).contains("Not initialized"));
}
#[test]
fn test_set_status() {
let dir = TempDir::new().unwrap();
run_citrine(dir.path(), &["init"]);
let out = run_citrine(dir.path(), &["add", "Test task"]);
let hash = extract_hash(&stdout(&out));
let out = run_citrine(dir.path(), &["set", &hash, "progress"]);
assert!(out.status.success());
assert!(stdout(&out).contains(""));
let out = run_citrine(dir.path(), &["list"]);
assert!(stdout(&out).contains("[>]"));
}
#[test]
fn test_done_adds_completed() {
let dir = TempDir::new().unwrap();
run_citrine(dir.path(), &["init"]);
let out = run_citrine(dir.path(), &["add", "Finish this"]);
let hash = extract_hash(&stdout(&out));
let out = run_citrine(dir.path(), &["done", &hash]);
assert!(out.status.success());
assert!(stdout(&out).contains("@completed:"));
let content = std::fs::read_to_string(dir.path().join(".citrine")).unwrap();
assert!(content.contains("[x]"));
assert!(content.contains("@completed:"));
}
#[test]
fn test_rm() {
let dir = TempDir::new().unwrap();
run_citrine(dir.path(), &["init"]);
let out = run_citrine(dir.path(), &["add", "Remove me"]);
let hash = extract_hash(&stdout(&out));
let out = run_citrine(dir.path(), &["rm", &hash]);
assert!(out.status.success());
assert!(stdout(&out).contains("Removed"));
let out = run_citrine(dir.path(), &["list"]);
assert!(!stdout(&out).contains("Remove me"));
}
#[test]
fn test_partial_hash() {
let dir = TempDir::new().unwrap();
run_citrine(dir.path(), &["init"]);
let out = run_citrine(dir.path(), &["add", "Partial hash test"]);
let hash = extract_hash(&stdout(&out));
let partial = &hash[..2];
let out = run_citrine(dir.path(), &["set", partial, "progress"]);
assert!(out.status.success());
}
#[test]
fn test_invalid_status() {
let dir = TempDir::new().unwrap();
run_citrine(dir.path(), &["init"]);
let out = run_citrine(dir.path(), &["add", "Task"]);
let hash = extract_hash(&stdout(&out));
let out = run_citrine(dir.path(), &["set", &hash, "invalid"]);
assert!(!out.status.success());
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn test_hash_not_found() {
let dir = TempDir::new().unwrap();
run_citrine(dir.path(), &["init"]);
let out = run_citrine(dir.path(), &["set", "ffff", "done"]);
assert!(!out.status.success());
assert!(stderr(&out).contains("Task not found"));
}
#[test]
fn test_status_filter() {
let dir = TempDir::new().unwrap();
run_citrine(dir.path(), &["init"]);
run_citrine(dir.path(), &["add", "Todo task"]);
let out = run_citrine(dir.path(), &["add", "Done task"]);
let hash = extract_hash(&stdout(&out));
run_citrine(dir.path(), &["done", &hash]);
let out = run_citrine(dir.path(), &["list", "--status", "todo"]);
let s = stdout(&out);
assert!(s.contains("Todo task"));
assert!(!s.contains("Done task"));
let out = run_citrine(dir.path(), &["list", "--status", "done"]);
let s = stdout(&out);
assert!(!s.contains("Todo task"));
assert!(s.contains("Done task"));
}
#[test]
fn test_merge_main_and_local() {
let dir = TempDir::new().unwrap();
run_citrine(dir.path(), &["init"]);
run_citrine(dir.path(), &["add", "Main task"]);
run_citrine(dir.path(), &["add", "Local task", "--local"]);
let out = run_citrine(dir.path(), &["list"]);
let s = stdout(&out);
assert!(s.contains("Main task"));
assert!(s.contains("Local task"));
}
fn extract_hash(output: &str) -> String {
let re = regex::Regex::new(r"\[([0-9a-f]{4})\]").unwrap();
re.captures(output).unwrap()[1].to_string()
}