From a6b6cddb4951c67cdad6150344d4939c30329eec Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 14 Feb 2026 17:49:32 +0100 Subject: [PATCH] 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 --- .gitignore | 4 + Cargo.lock | 980 ++++++++++++++++++++++++++++++++++ Cargo.toml | 20 + README.md | 81 +++ src/commands/add.rs | 35 ++ src/commands/init.rs | 28 + src/commands/list.rs | 74 +++ src/commands/mod.rs | 5 + src/commands/rm.rs | 29 + src/commands/set.rs | 49 ++ src/file.rs | 89 +++ src/hash.rs | 35 ++ src/lib.rs | 4 + src/main.rs | 84 +++ src/task.rs | 168 ++++++ tests/fixtures/sample.citrine | 9 + tests/integration.rs | 204 +++++++ 17 files changed, 1898 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/commands/add.rs create mode 100644 src/commands/init.rs create mode 100644 src/commands/list.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/rm.rs create mode 100644 src/commands/set.rs create mode 100644 src/file.rs create mode 100644 src/hash.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/task.rs create mode 100644 tests/fixtures/sample.citrine create mode 100644 tests/integration.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c00589 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +/dist +.claude +CITRINE_SPEC.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..34485db --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f8b408c --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..55491c1 --- /dev/null +++ b/README.md @@ -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 [--local]` | Add a new task (`--local` for `.citrine.local`) | +| `citrine list [--status ] [--local]` | List tasks, optionally filtered | +| `citrine set ` | Change task status | +| `citrine done ` | Mark task as done (shorthand for `set done`) | +| `citrine rm ` | 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 | diff --git a/src/commands/add.rs b/src/commands/add.rs new file mode 100644 index 0000000..d7708e3 --- /dev/null +++ b/src/commands/add.rs @@ -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(()) +} diff --git a/src/commands/init.rs b/src/commands/init.rs new file mode 100644 index 0000000..44d454d --- /dev/null +++ b/src/commands/init.rs @@ -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(()) +} diff --git a/src/commands/list.rs b/src/commands/list.rs new file mode 100644 index 0000000..2945648 --- /dev/null +++ b/src/commands/list.rs @@ -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 = 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(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..f584fc0 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,5 @@ +pub mod init; +pub mod add; +pub mod list; +pub mod set; +pub mod rm; diff --git a/src/commands/rm.rs b/src/commands/rm.rs new file mode 100644 index 0000000..7447153 --- /dev/null +++ b/src/commands/rm.rs @@ -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(()) +} diff --git a/src/commands/set.rs b/src/commands/set.rs new file mode 100644 index 0000000..4a03e91 --- /dev/null +++ b/src/commands/set.rs @@ -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(()) +} diff --git a/src/file.rs b/src/file.rs new file mode 100644 index 0000000..6c2beaf --- /dev/null +++ b/src/file.rs @@ -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, +} + +impl CitrineFile { + pub fn read(path: &Path) -> Result { + 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::>().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 { + 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 { + 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 { + 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(", ")); + } + } +} diff --git a/src/hash.rs b/src/hash.rs new file mode 100644 index 0000000..47c8175 --- /dev/null +++ b/src/hash.rs @@ -0,0 +1,35 @@ +use anyhow::{bail, Result}; +use rand::Rng; +use std::collections::HashSet; + +pub fn generate_hash(existing: &HashSet) -> Result { + 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"); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ceacebf --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,4 @@ +pub mod task; +pub mod hash; +pub mod file; +pub mod commands; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f6a1726 --- /dev/null +++ b/src/main.rs @@ -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, + /// 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); + } + } +} diff --git a/src/task.rs b/src/task.rs new file mode 100644 index 0000000..8ec93ea --- /dev/null +++ b/src/task.rs @@ -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 { + match c { + ' ' => Some(Status::Todo), + '>' => Some(Status::InProgress), + '-' => Some(Status::Blocked), + 'x' => Some(Status::Done), + _ => None, + } + } + + pub fn from_name(name: &str) -> Option { + 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"); + } + } +} diff --git a/tests/fixtures/sample.citrine b/tests/fixtures/sample.citrine new file mode 100644 index 0000000..1b9502a --- /dev/null +++ b/tests/fixtures/sample.citrine @@ -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 diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..c02005a --- /dev/null +++ b/tests/integration.rs @@ -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() +}