diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bf4e07..f8323d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,6 +153,7 @@ jobs: run: cargo install rust-script - name: Cache cargo registry + continue-on-error: true uses: actions/cache@v5 with: path: | @@ -192,6 +193,7 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Cache cargo registry + continue-on-error: true uses: actions/cache@v5 with: path: | @@ -231,6 +233,7 @@ jobs: components: llvm-tools-preview - name: Cache cargo registry + continue-on-error: true uses: actions/cache@v5 with: path: | @@ -271,6 +274,7 @@ jobs: run: cargo install rust-script - name: Cache cargo registry + continue-on-error: true uses: actions/cache@v5 with: path: | diff --git a/.gitignore b/.gitignore index 2863516..ef84cf0 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ doc/ .env .env.local *.local +.agent-plan.json # Log files *.log diff --git a/Cargo.lock b/Cargo.lock index 928c721..4560f4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,15 @@ 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" @@ -61,6 +70,67 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "clap" version = "4.5.60" @@ -108,58 +178,184 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "ctor" -version = "0.4.3" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "ctor-proc-macro", - "dtor", + "libc", ] [[package]] -name = "ctor-proc-macro" -version = "0.0.6" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] [[package]] -name = "dotenvy" -version = "0.15.7" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "dtor" -version = "0.0.6" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "dtor-proc-macro", + "libc", + "windows-sys", ] [[package]] -name = "dtor-proc-macro" -version = "0.0.5" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "example-sum-package-name" -version = "0.17.0" +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 = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "clap", - "lino-arguments", - "regex", - "walkdir", + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +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.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -167,24 +363,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] -name = "lino-arguments" -version = "0.3.0" +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be512a5c5eacea6ef5ec015fb0c7e1725c8e4cda1befd31606e203f281069968" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ - "clap", - "ctor", - "dotenvy", - "lino-env", - "serde", - "thiserror", + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", ] [[package]] -name = "lino-env" +name = "leb128fmt" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f453c53827aabe91a3d3856d61d14ae3867ab1a4344db22f9fa5396664c8d0e" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "memchr" @@ -192,12 +410,57 @@ 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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plan-to-git" +version = "0.17.0" +dependencies = [ + "chrono", + "clap", + "regex", + "serde", + "serde_json", + "sha2", + "tempfile", + "walkdir", +] + +[[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.103" @@ -216,6 +479,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "regex" version = "1.12.3" @@ -245,6 +514,25 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "same-file" version = "1.0.6" @@ -254,6 +542,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -284,6 +578,42 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "strsim" version = "0.11.1" @@ -302,24 +632,23 @@ dependencies = [ ] [[package]] -name = "thiserror" -version = "1.0.69" +name = "tempfile" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "thiserror-impl", + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", ] [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "typenum" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" @@ -327,12 +656,24 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -343,6 +684,103 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[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 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +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 = "winapi-util" version = "0.1.11" @@ -352,12 +790,65 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -366,3 +857,103 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[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 = "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 index e06ecb0..5b3dc03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,15 @@ [package] -name = "example-sum-package-name" +name = "plan-to-git" version = "0.17.0" edition = "2021" -description = "A Rust package template for AI-driven development" +description = "Capture agent plans and sync them to GitHub pull requests" readme = "README.md" license = "Unlicense" -keywords = ["template", "rust", "ai-driven"] -categories = ["development-tools"] -repository = "https://github.com/link-foundation/rust-ai-driven-development-pipeline-template" -documentation = "https://github.com/link-foundation/rust-ai-driven-development-pipeline-template" -rust-version = "1.70" +keywords = ["agents", "github", "pull-request", "codex"] +categories = ["command-line-utilities", "development-tools"] +repository = "https://github.com/ProverCoderAI/plan-to-git" +documentation = "https://github.com/ProverCoderAI/plan-to-git" +rust-version = "1.85" # Narrow allowlist of files shipped in the published `.crate` archive. # Keeping this list tight prevents docs, case studies, generated CI artifacts, @@ -18,25 +18,29 @@ rust-version = "1.70" include = [ "src/**/*.rs", "examples/**/*.rs", - "README.md", - "LICENSE", - "CHANGELOG.md", + "/README.md", + "/LICENSE", + "/CHANGELOG.md", ] [lib] -name = "example_sum_package_name" +name = "plan_to_git" path = "src/lib.rs" [[bin]] -name = "example-sum-package-name" +name = "plan-to-git" path = "src/main.rs" [dependencies] -lino-arguments = "0.3" clap = { version = "4.4", features = ["derive", "env"] } +chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] } +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" [dev-dependencies] -regex = "1" +tempfile = "3" walkdir = "2" [lints.rust] diff --git a/README.md b/README.md index 2e615f5..05af6f4 100644 --- a/README.md +++ b/README.md @@ -1,332 +1,61 @@ -# rust-ai-driven-development-pipeline-template +# plan-to-git -A comprehensive template for AI-driven Rust development with full CI/CD pipeline support. +`plan-to-git` captures explicit plans produced by coding agents and posts new pull request comments for plan updates. -[![CI/CD Pipeline](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template/workflows/CI%2FCD%20Pipeline/badge.svg)](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template/actions?workflow=CI%2FCD+Pipeline) -[![Crates.io](https://img.shields.io/crates/v/example-sum-package-name?label=crates.io&style=flat)](https://crates.io/crates/example-sum-package-name) -[![Docs.rs](https://docs.rs/example-sum-package-name/badge.svg)](https://docs.rs/example-sum-package-name) -[![Rust Version](https://img.shields.io/badge/rust-1.70%2B-blue.svg)](https://www.rust-lang.org/) -[![Codecov](https://codecov.io/gh/link-foundation/rust-ai-driven-development-pipeline-template/branch/main/graph/badge.svg)](https://codecov.io/gh/link-foundation/rust-ai-driven-development-pipeline-template) -[![License: Unlicense](https://img.shields.io/badge/license-Unlicense-blue.svg)](http://unlicense.org/) +The MVP is Codex-first: -## Features +- reads Codex hook JSON from stdin; +- captures only explicit plan blocks such as `...` or `## Accepted Plan`; +- stores captured plans and planning Q/A decisions in `.agent-plan.json`; +- posts a new PR comment with newly captured current-branch items when a PR exists; +- leaves the local stack queued when no PR exists yet. -- **Rust stable support**: Works with Rust stable version -- **Cross-platform testing**: CI runs on Ubuntu, macOS, and Windows -- **Comprehensive testing**: Unit tests, integration tests, and doc tests -- **Code quality**: rustfmt + Clippy with pedantic lints -- **Pre-commit hooks**: Automated code quality checks before commits -- **CI/CD pipeline**: GitHub Actions with multi-platform support -- **Changelog management**: Fragment-based changelog (like Changesets/Scriv) -- **Code coverage**: Automated coverage reports with cargo-llvm-cov and Codecov -- **Release automation**: Automatic GitHub releases, crates.io publishing, and optional Docker Hub image publishing -- **Template-safe defaults**: CI/CD skips publishing when package name is `example-sum-package-name` - -## Quick Start - -### Using This Template - -1. Click "Use this template" on GitHub to create a new repository -2. Clone your new repository -3. Update `Cargo.toml`: - - Change `name` from `example-sum-package-name` to your package name - - Update `description`, `repository`, and `documentation` URLs - - Update `[lib]` name and `[[bin]]` name -4. Update imports in `src/main.rs`, `tests/`, and `examples/` -5. Build and start developing! - -### Development Setup - -```bash -# Clone the repository -git clone https://github.com/link-foundation/rust-ai-driven-development-pipeline-template.git -cd rust-ai-driven-development-pipeline-template - -# Build the project -cargo build - -# Run tests -cargo test - -# Run the CLI binary -cargo run -- --a 3 --b 7 - -# Run an example -cargo run --example basic_usage -``` - -### Running Tests - -```bash -# Run all tests -cargo test - -# Run tests with verbose output -cargo test --verbose - -# Run doc tests -cargo test --doc - -# Run a specific test -cargo test test_sum_positive_numbers - -# Run tests with output -cargo test -- --nocapture -``` - -CI caps each test-matrix job at 10 minutes. `cargo test` does not provide a portable global per-test timeout, so long-running network, IO, or async tests should use explicit test-level timeouts. Repositories that adopt `cargo nextest` can configure runner deadlines with options such as `--slow-timeout` and `--leak-timeout`. - -### Code Quality Checks +## CLI ```bash -# Format code -cargo fmt - -# Check formatting (CI style) -cargo fmt --check - -# Run Clippy lints -cargo clippy --all-targets --all-features - -# Check file size limits (requires rust-script: cargo install rust-script) -rust-script scripts/check-file-size.rs - -# Check the packaged crate stays under the crates.io 10 MiB upload limit -rust-script scripts/check-crate-size.rs - -# Run all checks -cargo fmt --check && cargo clippy --all-targets --all-features && rust-script scripts/check-file-size.rs -``` - -## Project Structure - -``` -. -├── .github/ -│ └── workflows/ -│ └── release.yml # CI/CD pipeline configuration -├── changelog.d/ # Changelog fragments -│ ├── README.md # Fragment instructions -│ └── *.md # Individual changelog entries -├── examples/ -│ └── basic_usage.rs # Usage examples -├── experiments/ # Experiment and debug scripts -│ ├── test-changelog-parsing.rs # Changelog parsing validation -│ └── test-crates-io-check.rs # Crates.io version check validation -├── scripts/ # Rust scripts (via rust-script) -│ ├── bump-version.rs # Version bumping utility -│ ├── check-changelog-fragment.rs # Changelog fragment validation -│ ├── check-crate-size.rs # Crate archive size guard (crates.io 10 MiB limit) -│ ├── check-file-size.rs # File size validation script -│ ├── check-release-needed.rs # Release necessity check -│ ├── check-version-modification.rs # Version modification detection -│ ├── collect-changelog.rs # Changelog collection script -│ ├── create-changelog-fragment.rs # Changelog fragment creation -│ ├── create-github-release.rs # GitHub release creation -│ ├── detect-code-changes.rs # Code change detection for CI -│ ├── get-bump-type.rs # Version bump type determination -│ ├── get-version.rs # Version extraction from Cargo.toml -│ ├── git-config.rs # Git configuration for CI -│ ├── publish-crate.rs # Crates.io publishing -│ ├── rust-paths.rs # Rust root path detection -│ ├── version-and-commit.rs # CI/CD version management -│ └── wait-for-crate.rs # Crates.io availability wait before image publishing -├── src/ -│ ├── lib.rs # Library entry point -│ ├── main.rs # CLI binary (uses lino-arguments) -│ └── sum.rs # Sum function module -├── tests/ -│ ├── unit_tests.rs # Unit test entry point -│ ├── unit/ -│ │ ├── mod.rs -│ │ ├── sum.rs # Unit tests for sum function -│ │ └── ci-cd/ -│ │ ├── mod.rs -│ │ └── changelog_parsing.rs # CI/CD changelog parsing tests -│ ├── integration_tests.rs # Integration test entry point -│ └── integration/ -│ ├── mod.rs -│ └── sum.rs # CLI integration tests -├── .gitignore # Git ignore patterns -├── .pre-commit-config.yaml # Pre-commit hooks configuration -├── Cargo.toml # Project configuration -├── CHANGELOG.md # Project changelog -├── CONTRIBUTING.md # Contribution guidelines -├── LICENSE # Unlicense (public domain) -└── README.md # This file +plan-to-git hook --source codex < hook-payload.json +plan-to-git show +plan-to-git render +plan-to-git sync +plan-to-git import-codex --dry-run +plan-to-git import-codex +plan-to-git clear --yes ``` -## Design Choices - -### Example Application - -The template includes a simple CLI sum application using [lino-arguments](https://github.com/link-foundation/lino-arguments) (a drop-in replacement for clap that also supports `.lenv` and `.env` files). This demonstrates: - -- Library module (`src/sum.rs`) with a pure function -- CLI binary (`src/main.rs`) using `lino-arguments` for argument parsing -- Unit tests (`tests/unit/sum.rs`) testing the function directly -- Integration tests (`tests/integration/sum.rs`) testing the full CLI binary - -### Code Quality Tools - -- **rustfmt**: Standard Rust code formatter -- **Clippy**: Rust linter with pedantic and nursery lints enabled -- **Pre-commit hooks**: Automated checks before each commit - -### Testing Strategy +`hook` is intentionally quiet on stdout, because Codex hook stdout is interpreted by Codex. Operational messages go to stderr. -The template supports multiple levels of testing: +## Codex Hook Example -- **Unit tests**: In `tests/unit/` directory, testing functions directly -- **Integration tests**: In `tests/integration/` directory, testing CLI binary -- **CI/CD tests**: In `tests/unit/ci-cd/` directory, testing CI/CD script logic -- **Doc tests**: In documentation examples using `///` comments -- **Examples**: In `examples/` directory (also serve as documentation) +Add the command to Codex hook configuration for `Stop` and `UserPromptSubmit` events: -Users can easily delete CI/CD tests in `tests/unit/ci-cd/` if not needed. +```toml +[[hooks.UserPromptSubmit]] +[[hooks.UserPromptSubmit.hooks]] +type = "command" +command = "plan-to-git hook --source codex" -### Changelog Management - -This template uses a fragment-based changelog system similar to [Changesets](https://github.com/changesets/changesets) and [Scriv](https://scriv.readthedocs.io/). - -```bash -# Create a changelog fragment -touch changelog.d/$(date +%Y%m%d_%H%M%S)_my_change.md - -# Edit the fragment to document your changes +[[hooks.Stop]] +[[hooks.Stop.hooks]] +type = "command" +command = "plan-to-git hook --source codex" ``` -### CI/CD Pipeline - -The GitHub Actions workflow provides: - -1. **Change detection**: Only runs relevant jobs based on changed files -2. **Changelog check**: Validates changelog fragments on PRs with code changes -3. **Version check**: Prevents manual version modification in PRs -4. **Linting**: rustfmt and Clippy checks -5. **Test matrix**: 3 OS (Ubuntu, macOS, Windows) with Rust stable -6. **Code coverage**: cargo-llvm-cov with Codecov upload -7. **Building**: Release build and package validation -8. **Auto release**: Automatic releases when changelog fragments are merged to main -9. **Manual release**: Workflow dispatch with version bump type selection -10. **Optional Docker Hub publishing**: Pushes `latest` and version tags after the matching crates.io version is visible -11. **Documentation**: Automatic docs deployment to GitHub Pages after release - -### Template-Safe Defaults - -The default package name `example-sum-package-name` triggers skip logic in CI/CD scripts: -- `publish-crate.rs` skips crates.io publishing -- `create-github-release.rs` skips GitHub release creation -- Docker Hub publishing stays disabled unless `DOCKERHUB_IMAGE` is configured and a root `Dockerfile` exists - -Rename the package in `Cargo.toml` to enable full CI/CD publishing. - -## Configuration - -### Updating Package Name - -After creating a repository from this template: - -1. Update `Cargo.toml`: - - Change `name` field from `example-sum-package-name` - - Update `repository` and `documentation` URLs - - Change `[lib]` name and `[[bin]]` name - -2. Update imports: - - `src/main.rs` - - `tests/unit/sum.rs` - - `tests/integration/sum.rs` - - `examples/basic_usage.rs` - -3. Update badges in this `README.md` - -### Optional Docker Hub Publishing - -Projects that ship a Docker image can publish Docker Hub releases from the same Rust release workflow. Add a root `Dockerfile`, then configure: - -| Name | Type | Example | Purpose | -| ---- | ---- | ------- | ------- | -| `DOCKERHUB_IMAGE` | Repository variable | `my-dockerhub-user/my-image` | Docker Hub repository to publish | -| `DOCKERHUB_USERNAME` | Repository variable or secret | `my-dockerhub-user` | Docker Hub login username | -| `DOCKERHUB_TOKEN` | Repository secret | Docker Hub access token | Docker Hub login token | +Exact hook configuration shape can vary by Codex release. The hook command itself expects the release behavior documented by Codex hooks: `Stop` includes `last_assistant_message`, and `UserPromptSubmit` includes `prompt`. -When configured, the release workflow publishes both `latest` and the Cargo package version tag, for example `my-dockerhub-user/my-image:0.10.0`. Docker publishing runs only after crates.io reports the matching version as available, and release checks rerun missing Docker Hub or GitHub release artifacts without bumping the version again. +## Pull Request Comments -Add a visible Docker Hub badge next to the crates.io badge in repositories that enable image publishing: +When `gh pr view` finds a PR for the current branch, `plan-to-git` creates a new issue comment on that PR containing items that have not been posted before: ```markdown -[![Docker Hub](https://img.shields.io/docker/v/my-dockerhub-user/my-image?label=docker%20hub)](https://hub.docker.com/r/my-dockerhub-user/my-image) -``` - -## Deploying API documentation - -The `deploy-docs` job in `.github/workflows/release.yml` publishes `cargo doc --no-deps --all-features` output to GitHub Pages on every push to `main` and on `workflow_dispatch` with `release_mode == 'instant'`. It uses the official `actions/configure-pages` / `actions/upload-pages-artifact` / `actions/deploy-pages` flow, which requires the repository's Pages source to be set to **GitHub Actions**. - -Before the first run on `main`, open **Settings → Pages** of the new repository and set **Source = GitHub Actions**. This is a one-time manual step and cannot be configured from a workflow. The `deploy-docs` job will then provision the Pages site on its first run. - -If this step is skipped, the first `deploy-docs` run fails on `actions/deploy-pages@v5` with `Error: Get Pages site failed.` / `Error: Failed to create deployment`. Flip the Pages source as described above and re-run the failed job; no workflow changes are required. - -## Scripts Reference - -All scripts in `scripts/` are Rust scripts that use [rust-script](https://github.com/fornwall/rust-script). -Install rust-script with: `cargo install rust-script` - -| Command | Description | -| ------------------------------------- | ------------------------ | -| `cargo test` | Run all tests | -| `cargo fmt` | Format code | -| `cargo clippy` | Run lints | -| `cargo run -- --a 3 --b 7` | Run CLI (sum 3 + 7) | -| `cargo run --example basic_usage` | Run example | -| `rust-script scripts/check-file-size.rs` | Check file size limits | -| `rust-script scripts/check-crate-size.rs` | Check crate archive size (crates.io 10 MiB limit) | -| `rust-script scripts/bump-version.rs` | Bump version | - -## Example Usage +## Agent Plan Update -```rust -use example_sum_package_name::sum; - -fn main() { - let result = sum(2, 3); - println!("2 + 3 = {result}"); -} +... ``` -See `examples/basic_usage.rs` for more examples. - -## Contributing - -Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. - -### Development Workflow - -1. Fork the repository -2. Create a feature branch: `git checkout -b feature/my-feature` -3. Make your changes and add tests -4. Run quality checks: `cargo fmt && cargo clippy && cargo test` -5. Add a changelog fragment -6. Commit your changes (pre-commit hooks will run automatically) -7. Push and create a Pull Request - -## License - -[Unlicense](LICENSE) - Public Domain - -This is free and unencumbered software released into the public domain. See [LICENSE](LICENSE) for details. - -## Acknowledgments +The PR description is not edited. After a comment is created, `.agent-plan.json` records the posted item hashes and GitHub comment id so repeated `sync`, `hook`, or `import-codex` runs do not post the same plan again, including on a later PR. -Inspired by: -- [js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template) -- [python-ai-driven-development-pipeline-template](https://github.com/link-foundation/python-ai-driven-development-pipeline-template) -- [lino-arguments](https://github.com/link-foundation/lino-arguments) -- [trees-rs](https://github.com/linksplatform/trees-rs) +## Safety -## Resources +The hook path only uses stable hook payload fields and explicitly marked plan text. `import-codex` can backfill previous plans from `~/.codex/sessions`, but it only reads assistant message events from sessions that match the current repository and branch, and it still imports only explicit markers such as `...` or `## Accepted Plan`. -- [Rust Book](https://doc.rust-lang.org/book/) -- [Cargo Book](https://doc.rust-lang.org/cargo/) -- [Clippy Documentation](https://rust-lang.github.io/rust-clippy/) -- [rustfmt Documentation](https://rust-lang.github.io/rustfmt/) -- [Pre-commit Documentation](https://pre-commit.com/) +Captured content is redacted before local storage and PR sync. `.agent-plan.json` also acts as the sent-plan registry: content hashes prevent the same plan from being added and commented again. diff --git a/changelog.d/20260530_104500_plan_to_git_mvp.md b/changelog.d/20260530_104500_plan_to_git_mvp.md new file mode 100644 index 0000000..5bae08f --- /dev/null +++ b/changelog.d/20260530_104500_plan_to_git_mvp.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added +- Added the `plan-to-git` CLI for capturing Codex plan hooks, storing a local plan stack, and posting new GitHub pull request comments for unposted plans. +- Added `plan-to-git import-codex` for backfilling explicitly marked plans from matching Codex session history without re-uploading duplicates. diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index be9a37f..78f05b5 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -1,7 +1,23 @@ -use example_sum_package_name::sum; +use plan_to_git::render::render_plan_block; +use plan_to_git::store::{AgentPlanState, AgentSource, NewPlanItem}; fn main() { - println!("2 + 3 = {}", sum(2, 3)); - println!("-5 + 10 = {}", sum(-5, 10)); - println!("1000 + 2000 = {}", sum(1000, 2000)); + let mut state = AgentPlanState::default(); + state.set_context( + Some("example/repo".to_owned()), + Some("feature/plan-sync".to_owned()), + Some("abcdef1234567890".to_owned()), + ); + state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: Some("Example Plan".to_owned()), + content: "- Capture the plan.\n- Sync it to the pull request.".to_owned(), + branch: Some("feature/plan-sync".to_owned()), + head_sha: Some("abcdef1234567890".to_owned()), + session_id: None, + turn_id: None, + created_at: None, + }); + + println!("{}", render_plan_block(&state)); } diff --git a/src/capture.rs b/src/capture.rs new file mode 100644 index 0000000..61f6088 --- /dev/null +++ b/src/capture.rs @@ -0,0 +1,138 @@ +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +use crate::error::AppResult; +use crate::git; +use crate::github::{self, SyncStatus}; +use crate::normalize::{extract_marked_plans, extract_questions}; +use crate::store::{ + load_state, save_state, AgentSource, NewDecision, NewPendingQuestion, NewPlanItem, + PendingQuestion, STATE_FILE_NAME, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookOutcome { + pub changed: bool, + pub captured_plans: usize, + pub captured_decisions: usize, + pub pending_questions: usize, + pub sync_status: SyncStatus, +} + +#[derive(Debug, Deserialize)] +struct CodexHookInput { + #[serde(default)] + session_id: Option, + #[serde(default)] + cwd: Option, + hook_event_name: String, + #[serde(default)] + turn_id: Option, + #[serde(default)] + prompt: Option, + #[serde(default)] + last_assistant_message: Option, +} + +pub fn process_codex_hook(input: &str) -> AppResult { + let hook_input: CodexHookInput = serde_json::from_str(input)?; + let start_dir = hook_input.cwd.as_deref().unwrap_or_else(|| Path::new(".")); + let context = git::discover(start_dir)?; + let state_path = context.repo_root.join(STATE_FILE_NAME); + let mut state = load_state(&state_path)?; + state.set_context( + context.repo_slug.clone(), + context.branch.clone(), + context.head_sha.clone(), + ); + + let mut captured_plans = 0; + let mut captured_decisions = 0; + let mut changed = false; + + match hook_input.hook_event_name.as_str() { + "Stop" => { + if let Some(message) = hook_input.last_assistant_message.as_deref() { + for plan in extract_marked_plans(message) { + let added = state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: plan.title, + content: plan.content, + branch: context.branch.clone(), + head_sha: context.head_sha.clone(), + session_id: hook_input.session_id.clone(), + turn_id: hook_input.turn_id.clone(), + created_at: None, + }); + if added { + captured_plans += 1; + changed = true; + } + } + + if captured_plans == 0 { + let questions = extract_questions(message); + if state.add_pending_question(NewPendingQuestion { + source: AgentSource::Codex, + questions, + branch: context.branch.clone(), + head_sha: context.head_sha.clone(), + session_id: hook_input.session_id.clone(), + turn_id: hook_input.turn_id.clone(), + }) { + changed = true; + } + } + } + } + "UserPromptSubmit" => { + if let Some(prompt) = hook_input.prompt.as_deref() { + if !prompt.trim().is_empty() { + let questions = drain_relevant_questions(&mut state.pending_questions); + if state.answer_pending_questions(NewDecision { + source: AgentSource::Codex, + questions, + answer: prompt.to_owned(), + branch: context.branch.clone(), + head_sha: context.head_sha.clone(), + session_id: hook_input.session_id.clone(), + turn_id: hook_input.turn_id.clone(), + }) { + captured_decisions += 1; + changed = true; + } + } + } + } + _ => {} + } + + if changed || !state.items.is_empty() || !state.pending_questions.is_empty() { + save_state(&state_path, &state)?; + } + + let sync_status = github::sync_state(&context, &mut state)?; + if changed || !state.items.is_empty() || !state.pending_questions.is_empty() { + save_state(&state_path, &state)?; + } + + Ok(HookOutcome { + changed, + captured_plans, + captured_decisions, + pending_questions: state.pending_questions.len(), + sync_status, + }) +} + +fn drain_relevant_questions(pending_questions: &mut Vec) -> Vec { + let mut questions = Vec::new(); + for pending_question in pending_questions.drain(..) { + for question in pending_question.questions { + if !questions.iter().any(|existing| existing == &question) { + questions.push(question); + } + } + } + questions +} diff --git a/src/codex_history.rs b/src/codex_history.rs new file mode 100644 index 0000000..da897c8 --- /dev/null +++ b/src/codex_history.rs @@ -0,0 +1,544 @@ +use serde_json::Value; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use crate::error::AppResult; +use crate::git::{parse_github_slug, GitContext}; +use crate::normalize::extract_marked_plans; +use crate::pr_body::{END_MARKER, START_MARKER}; +use crate::store::{AgentPlanState, AgentSource, NewPlanItem}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodexHistoryImportOutcome { + pub files_scanned: usize, + pub files_matched: usize, + pub lines_scanned: usize, + pub parse_errors: usize, + pub plans_found: usize, + pub plans_added: usize, + pub duplicates: usize, + pub rendered_stacks_skipped: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SessionMetadata { + id: Option, + repo_slug: Option, + branch: Option, + cwd: Option, +} + +pub fn import_codex_history( + codex_home: &Path, + context: &GitContext, + state: &mut AgentPlanState, +) -> AppResult { + let mut outcome = CodexHistoryImportOutcome { + files_scanned: 0, + files_matched: 0, + lines_scanned: 0, + parse_errors: 0, + plans_found: 0, + plans_added: 0, + duplicates: 0, + rendered_stacks_skipped: 0, + }; + + let mut files = codex_session_files(codex_home)?; + files.sort(); + + for path in files { + outcome.files_scanned += 1; + import_session_file(&path, context, state, &mut outcome)?; + } + + Ok(outcome) +} + +fn import_session_file( + path: &Path, + context: &GitContext, + state: &mut AgentPlanState, + outcome: &mut CodexHistoryImportOutcome, +) -> AppResult<()> { + let file = File::open(path)?; + let reader = BufReader::new(file); + let mut metadata: Option = None; + let mut file_matches = false; + + for (line_index, line) in reader.lines().enumerate() { + outcome.lines_scanned += 1; + let line = line?; + let Ok(event) = serde_json::from_str::(&line) else { + outcome.parse_errors += 1; + continue; + }; + + if event.get("type").and_then(Value::as_str) == Some("session_meta") { + metadata = Some(parse_session_metadata(&event)); + file_matches = metadata + .as_ref() + .is_some_and(|session| session_matches_context(session, context)); + if file_matches { + outcome.files_matched += 1; + } + continue; + } + + if !file_matches { + continue; + } + + let Some(message) = plan_message_text(&event) else { + continue; + }; + + let session_id = metadata + .as_ref() + .and_then(|session| session.id.clone()) + .or_else(|| session_id_from_path(path)); + let turn_id = event + .get("payload") + .and_then(|payload| payload.get("turn_id")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .or_else(|| line_turn_id(path, line_index + 1)); + let created_at = event + .get("timestamp") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + + for plan in extract_marked_plans(&message) { + outcome.plans_found += 1; + if looks_like_rendered_plan_stack(&plan.content) { + outcome.rendered_stacks_skipped += 1; + continue; + } + let added = state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: plan.title, + content: plan.content, + branch: context.branch.clone(), + head_sha: context.head_sha.clone(), + session_id: session_id.clone(), + turn_id: turn_id.clone(), + created_at: created_at.clone(), + }); + + if added { + outcome.plans_added += 1; + } else { + outcome.duplicates += 1; + } + } + } + + Ok(()) +} + +fn codex_session_files(codex_home: &Path) -> AppResult> { + let sessions_dir = codex_home.join("sessions"); + if !sessions_dir.exists() { + return Ok(Vec::new()); + } + + let mut files = Vec::new(); + collect_jsonl_files(&sessions_dir, &mut files)?; + Ok(files) +} + +fn collect_jsonl_files(dir: &Path, files: &mut Vec) -> AppResult<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_jsonl_files(&path, files)?; + } else if path + .extension() + .is_some_and(|extension| extension == "jsonl") + { + files.push(path); + } + } + Ok(()) +} + +fn looks_like_rendered_plan_stack(content: &str) -> bool { + content.contains("## Agent Plan Stack") + && content.contains(START_MARKER) + && content.contains(END_MARKER) +} + +fn parse_session_metadata(event: &Value) -> SessionMetadata { + let payload = event.get("payload"); + let id = payload + .and_then(|payload| payload.get("id")) + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let branch = payload + .and_then(|payload| payload.get("git")) + .and_then(|git| git.get("branch")) + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let repo_slug = payload + .and_then(|payload| payload.get("git")) + .and_then(|git| git.get("repository_url")) + .and_then(Value::as_str) + .and_then(parse_github_slug); + let cwd = payload + .and_then(|payload| payload.get("cwd")) + .and_then(Value::as_str) + .map(PathBuf::from); + + SessionMetadata { + id, + repo_slug, + branch, + cwd, + } +} + +fn session_matches_context(session: &SessionMetadata, context: &GitContext) -> bool { + let repo_matches = match (&context.repo_slug, &session.repo_slug) { + (Some(current), Some(history)) => current == history, + _ => session + .cwd + .as_ref() + .is_some_and(|cwd| cwd.starts_with(&context.repo_root)), + }; + let branch_matches = match (&context.branch, &session.branch) { + (Some(current), Some(history)) => current == history, + _ => true, + }; + + repo_matches && branch_matches +} + +fn plan_message_text(event: &Value) -> Option { + assistant_message_text(event).or_else(|| task_complete_message_text(event)) +} + +fn assistant_message_text(event: &Value) -> Option { + if event.get("type").and_then(Value::as_str) != Some("response_item") { + return None; + } + + let payload = event.get("payload")?; + if payload.get("type").and_then(Value::as_str) != Some("message") { + return None; + } + if payload.get("role").and_then(Value::as_str) != Some("assistant") { + return None; + } + + let text = payload + .get("content")? + .as_array()? + .iter() + .filter_map(|content| content.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + + (!text.trim().is_empty()).then_some(text) +} + +fn task_complete_message_text(event: &Value) -> Option { + if event.get("type").and_then(Value::as_str) != Some("event_msg") { + return None; + } + + let payload = event.get("payload")?; + if payload.get("type").and_then(Value::as_str) != Some("task_complete") { + return None; + } + + let text = payload + .get("last_agent_message") + .or_else(|| payload.get("last_assistant_message")) + .and_then(Value::as_str)?; + + (!text.trim().is_empty()).then_some(text.to_owned()) +} + +fn session_id_from_path(path: &Path) -> Option { + path.file_stem() + .and_then(|stem| stem.to_str()) + .map(ToOwned::to_owned) +} + +fn line_turn_id(path: &Path, line_number: usize) -> Option { + let stem = path.file_stem()?.to_str()?; + Some(format!("{stem}:{line_number}")) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use std::{fs, path::Path}; + + use tempfile::tempdir; + + use crate::git::GitContext; + use crate::store::AgentPlanState; + + use super::import_codex_history; + + fn json_line(value: &serde_json::Value) -> String { + serde_json::to_string(value).expect("serialize jsonl event") + } + + fn session_meta_line(cwd: &Path, branch: &str) -> String { + json_line(&json!({ + "type": "session_meta", + "payload": { + "id": "session", + "cwd": cwd.to_string_lossy().into_owned(), + "git": { + "branch": branch, + "repository_url": "https://github.com/example/repo.git" + } + } + })) + } + + fn message_line(role: &str, content_type: &str, text: &str) -> String { + json_line(&json!({ + "type": "response_item", + "payload": { + "type": "message", + "role": role, + "content": [{ + "type": content_type, + "text": text + }] + } + })) + } + + fn task_complete_line(timestamp: &str, text: &str) -> String { + json_line(&json!({ + "timestamp": timestamp, + "type": "event_msg", + "payload": { + "type": "task_complete", + "last_agent_message": text + } + })) + } + + fn write_jsonl(path: &Path, lines: &[String]) { + fs::write(path, format!("{}\n", lines.join("\n"))).expect("write session"); + } + + #[test] + fn test_jsonl_helpers_escape_windows_paths() { + let line = session_meta_line(Path::new(r"C:\Users\dev\repo"), "feature/test"); + let event = serde_json::from_str::(&line).expect("parse session meta"); + + assert_eq!( + event + .get("payload") + .and_then(|payload| payload.get("cwd")) + .and_then(serde_json::Value::as_str), + Some(r"C:\Users\dev\repo") + ); + } + + #[test] + fn imports_marked_assistant_plans_and_skips_duplicates() { + let temp_dir = tempdir().expect("temp dir"); + let repo_root = temp_dir.path().join("repo"); + let codex_home = temp_dir.path().join("codex"); + let session_dir = codex_home.join("sessions/2026/05/31"); + fs::create_dir_all(&repo_root).expect("repo root"); + fs::create_dir_all(&session_dir).expect("session dir"); + + let session = session_dir.join("rollout-2026-05-31T00-00-00-session.jsonl"); + write_jsonl( + &session, + &[ + session_meta_line(&repo_root, "feature/test"), + message_line( + "user", + "input_text", + "ignore user text", + ), + message_line("assistant", "output_text", "not a marked plan"), + message_line( + "assistant", + "output_text", + "\n# Backfill\n\n- Import old plans\n- Keep api_key=secret-value private\n", + ), + message_line( + "assistant", + "output_text", + "\n# Backfill\n\n- Import old plans\n- Keep api_key=secret-value private\n", + ), + ], + ); + + let context = GitContext { + repo_root, + repo_slug: Some("example/repo".to_owned()), + branch: Some("feature/test".to_owned()), + head_sha: Some("abcdef".to_owned()), + }; + let mut state = AgentPlanState::default(); + + let outcome = import_codex_history(&codex_home, &context, &mut state).expect("import"); + + assert_eq!(outcome.files_scanned, 1); + assert_eq!(outcome.files_matched, 1); + assert_eq!(outcome.plans_found, 2); + assert_eq!(outcome.plans_added, 1); + assert_eq!(outcome.duplicates, 1); + assert_eq!(state.items.len(), 1); + assert!(state.items[0].content.contains("Import old plans")); + assert!(state.items[0].content.contains("api_key=[REDACTED]")); + assert!(!state.items[0].content.contains("secret-value")); + assert_eq!( + state.items[0].turn_id.as_deref(), + Some("rollout-2026-05-31T00-00-00-session:4") + ); + } + + #[test] + fn skips_sessions_from_other_branches() { + let temp_dir = tempdir().expect("temp dir"); + let repo_root = temp_dir.path().join("repo"); + let codex_home = temp_dir.path().join("codex"); + let session_dir = codex_home.join("sessions/2026/05/31"); + fs::create_dir_all(&repo_root).expect("repo root"); + fs::create_dir_all(&session_dir).expect("session dir"); + + write_jsonl( + &session_dir.join("rollout-2026-05-31T00-00-00-other.jsonl"), + &[ + session_meta_line(&repo_root, "feature/other"), + message_line( + "assistant", + "output_text", + "\n# Other\n\n- Wrong branch\n", + ), + ], + ); + + let context = GitContext { + repo_root, + repo_slug: Some("example/repo".to_owned()), + branch: Some("feature/test".to_owned()), + head_sha: Some("abcdef".to_owned()), + }; + let mut state = AgentPlanState::default(); + + let outcome = import_codex_history(&codex_home, &context, &mut state).expect("import"); + + assert_eq!(outcome.files_scanned, 1); + assert_eq!(outcome.files_matched, 0); + assert_eq!(outcome.plans_found, 0); + assert!(state.items.is_empty()); + } + + #[test] + fn imports_task_complete_last_agent_message() { + let temp_dir = tempdir().expect("temp dir"); + let repo_root = temp_dir.path().join("repo"); + let codex_home = temp_dir.path().join("codex"); + let session_dir = codex_home.join("sessions/2026/05/31"); + fs::create_dir_all(&repo_root).expect("repo root"); + fs::create_dir_all(&session_dir).expect("session dir"); + + write_jsonl( + &session_dir.join("rollout-2026-05-31T00-00-00-complete.jsonl"), + &[ + session_meta_line(&repo_root, "feature/test"), + task_complete_line( + "2026-05-31T12:34:56Z", + "\n# Complete\n\n- Import completion text\n", + ), + ], + ); + + let context = GitContext { + repo_root, + repo_slug: Some("example/repo".to_owned()), + branch: Some("feature/test".to_owned()), + head_sha: Some("abcdef".to_owned()), + }; + let mut state = AgentPlanState::default(); + + let outcome = import_codex_history(&codex_home, &context, &mut state).expect("import"); + + assert_eq!(outcome.plans_added, 1); + assert!(state.items[0].content.contains("Import completion text")); + assert_eq!(state.items[0].created_at, "2026-05-31T12:34:56Z"); + } + + #[test] + fn skips_sessions_without_positive_repo_or_cwd_match() { + let temp_dir = tempdir().expect("temp dir"); + let repo_root = temp_dir.path().join("repo"); + let codex_home = temp_dir.path().join("codex"); + let session_dir = codex_home.join("sessions/2026/05/31"); + fs::create_dir_all(&repo_root).expect("repo root"); + fs::create_dir_all(&session_dir).expect("session dir"); + + fs::write( + session_dir.join("rollout-2026-05-31T00-00-00-no-context.jsonl"), + r#"{"type":"session_meta","payload":{"id":"session","git":{"branch":"feature/test"}}} +{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"\n# No Context\n\n- Do not import\n"}]}} +"#, + ) + .expect("write session"); + + let context = GitContext { + repo_root, + repo_slug: Some("example/repo".to_owned()), + branch: Some("feature/test".to_owned()), + head_sha: Some("abcdef".to_owned()), + }; + let mut state = AgentPlanState::default(); + + let outcome = import_codex_history(&codex_home, &context, &mut state).expect("import"); + + assert_eq!(outcome.files_matched, 0); + assert!(state.items.is_empty()); + } + + #[test] + fn skips_rendered_plan_stack_blocks() { + let temp_dir = tempdir().expect("temp dir"); + let repo_root = temp_dir.path().join("repo"); + let codex_home = temp_dir.path().join("codex"); + let session_dir = codex_home.join("sessions/2026/05/31"); + fs::create_dir_all(&repo_root).expect("repo root"); + fs::create_dir_all(&session_dir).expect("session dir"); + + write_jsonl( + &session_dir.join("rollout-2026-05-31T00-00-00-rendered.jsonl"), + &[ + session_meta_line(&repo_root, "feature/test"), + message_line( + "assistant", + "output_text", + "\n\n## Agent Plan Stack\n\n", + ), + ], + ); + + let context = GitContext { + repo_root, + repo_slug: Some("example/repo".to_owned()), + branch: Some("feature/test".to_owned()), + head_sha: Some("abcdef".to_owned()), + }; + let mut state = AgentPlanState::default(); + + let outcome = import_codex_history(&codex_home, &context, &mut state).expect("import"); + + assert_eq!(outcome.plans_found, 1); + assert_eq!(outcome.rendered_stacks_skipped, 1); + assert!(state.items.is_empty()); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..4d695a1 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,26 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +pub type AppResult = Result>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppError { + message: String, +} + +impl AppError { + #[must_use] + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl Display for AppError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for AppError {} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..f7de0a5 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,66 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::error::{AppError, AppResult}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitContext { + pub repo_root: PathBuf, + pub repo_slug: Option, + pub branch: Option, + pub head_sha: Option, +} + +pub fn discover(start: &Path) -> AppResult { + let repo_root = git_output(start, ["rev-parse", "--show-toplevel"])?; + let repo_root = PathBuf::from(repo_root.trim()); + let branch = git_output(&repo_root, ["rev-parse", "--abbrev-ref", "HEAD"]).ok(); + let head_sha = git_output(&repo_root, ["rev-parse", "HEAD"]).ok(); + let remote = git_output(&repo_root, ["remote", "get-url", "origin"]).ok(); + + Ok(GitContext { + repo_root, + repo_slug: remote.as_deref().and_then(parse_github_slug), + branch: branch.map(|value| value.trim().to_owned()), + head_sha: head_sha.map(|value| value.trim().to_owned()), + }) +} + +#[must_use] +pub fn parse_github_slug(remote: &str) -> Option { + let remote = remote.trim().trim_end_matches(".git"); + + if let Some(path) = remote.strip_prefix("git@github.com:") { + return normalize_slug(path); + } + + if let Some(path) = remote.strip_prefix("https://github.com/") { + return normalize_slug(path); + } + + if let Some(path) = remote.strip_prefix("ssh://git@github.com/") { + return normalize_slug(path); + } + + None +} + +fn normalize_slug(path: &str) -> Option { + let mut parts = path.split('/'); + let owner = parts.next()?; + let repo = parts.next()?; + if owner.is_empty() || repo.is_empty() { + return None; + } + Some(format!("{owner}/{repo}")) +} + +fn git_output(cwd: &Path, args: [&str; N]) -> AppResult { + let output = Command::new("git").arg("-C").arg(cwd).args(args).output()?; + if output.status.success() { + return Ok(String::from_utf8(output.stdout)?.trim().to_owned()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + Err(AppError::new(format!("git command failed: {stderr}")).into()) +} diff --git a/src/github.rs b/src/github.rs new file mode 100644 index 0000000..9df1617 --- /dev/null +++ b/src/github.rs @@ -0,0 +1,127 @@ +use std::fs; +use std::path::Path; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Deserialize; + +use crate::error::{AppError, AppResult}; +use crate::git::GitContext; +use crate::render::render_plan_comment; +use crate::store::AgentPlanState; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SyncStatus { + NoItems, + NoPullRequest, + Unchanged { + number: u64, + }, + Commented { + number: u64, + comment_id: u64, + items: usize, + }, +} + +#[derive(Debug, Deserialize)] +struct PullRequest { + number: u64, +} + +#[derive(Debug, Deserialize)] +struct IssueComment { + id: u64, +} + +pub fn sync_state(context: &GitContext, state: &mut AgentPlanState) -> AppResult { + if !state.has_current_branch_items() { + return Ok(SyncStatus::NoItems); + } + + let Some(pull_request) = view_current_pr(&context.repo_root)? else { + return Ok(SyncStatus::NoPullRequest); + }; + + let (comment_body, item_ids, item_count) = { + let items = state.unposted_items_for_pr(pull_request.number); + if items.is_empty() { + return Ok(SyncStatus::Unchanged { + number: pull_request.number, + }); + } + let item_ids = items.iter().map(|item| item.id.clone()).collect::>(); + (render_plan_comment(state, &items), item_ids, items.len()) + }; + + let comment_id = create_issue_comment(context, pull_request.number, &comment_body)?; + state.mark_items_commented(pull_request.number, &item_ids, Some(comment_id)); + + Ok(SyncStatus::Commented { + number: pull_request.number, + comment_id, + items: item_count, + }) +} + +fn view_current_pr(repo_root: &Path) -> AppResult> { + let output = Command::new("gh") + .current_dir(repo_root) + .args(["pr", "view", "--json", "number"]) + .output()?; + + if output.status.success() { + return Ok(Some(serde_json::from_slice(&output.stdout)?)); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("no pull requests found") { + return Ok(None); + } + + Err(AppError::new(format!("gh pr view failed: {stderr}")).into()) +} + +fn create_issue_comment(context: &GitContext, number: u64, body: &str) -> AppResult { + let repo_slug = context + .repo_slug + .as_deref() + .ok_or_else(|| AppError::new("cannot sync PR comments without a GitHub origin remote"))?; + let request_file = temp_request_path(); + let request = serde_json::json!({ "body": body }); + fs::write(&request_file, serde_json::to_vec(&request)?)?; + + let output = Command::new("gh") + .current_dir(&context.repo_root) + .args(["api", "--method", "POST"]) + .arg(format!("repos/{repo_slug}/issues/{number}/comments")) + .args(["--input"]) + .arg(&request_file) + .output(); + + let remove_result = fs::remove_file(&request_file); + let output = output?; + if let Err(error) = remove_result { + return Err(error.into()); + } + + if output.status.success() { + let comment: IssueComment = serde_json::from_slice(&output.stdout)?; + return Ok(comment.id); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + Err(AppError::new(format!("gh api PR comment failed: {stderr}")).into()) +} + +fn temp_request_path() -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + path.push(format!( + "plan-to-git-pr-comment-{}-{timestamp}.json", + std::process::id() + )); + path +} diff --git a/src/lib.rs b/src/lib.rs index 490125b..17b3357 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,10 @@ -pub mod sum; - -pub use sum::sum; +pub mod capture; +pub mod codex_history; +pub mod error; +pub mod git; +pub mod github; +pub mod normalize; +pub mod pr_body; +pub mod redact; +pub mod render; +pub mod store; diff --git a/src/main.rs b/src/main.rs index 3b8bdfe..c4d75a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,221 @@ -use lino_arguments::Parser; +use clap::{Parser, Subcommand, ValueEnum}; +use std::fs; +use std::io::{self, Read}; +use std::path::PathBuf; -use example_sum_package_name::sum; +use plan_to_git::capture; +use plan_to_git::codex_history::{self, CodexHistoryImportOutcome}; +use plan_to_git::error::{AppError, AppResult}; +use plan_to_git::git; +use plan_to_git::github::{self, SyncStatus}; +use plan_to_git::render::render_plan_comment; +use plan_to_git::store::{load_state, save_state, STATE_FILE_NAME}; #[derive(Parser, Debug)] -#[command(name = "example-sum-package-name", about = "Sum two numbers")] -struct Args { - #[arg(long, env = "A", default_value = "0", allow_hyphen_values = true)] - a: i64, +#[command( + name = "plan-to-git", + about = "Capture agent plans and sync them to GitHub pull requests" +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Process an agent hook JSON payload from stdin. + Hook { + #[arg(long, value_enum)] + source: HookSource, + }, + /// Import explicitly marked plans from previous Codex session files. + #[command(visible_alias = "backfill-codex")] + ImportCodex { + /// Codex home directory. Defaults to `CODEX_HOME` or `~/.codex`. + #[arg(long)] + codex_home: Option, + /// Scan and report what would be imported without writing or syncing. + #[arg(long)] + dry_run: bool, + /// Save imported plans locally without posting a pull request comment. + #[arg(long)] + no_sync: bool, + }, + /// Post newly captured plan items to the current branch pull request. + Sync, + /// Print the local plan stack JSON. + Show, + /// Render the local plan stack markdown. + Render, + /// Clear local plan stack state. + Clear { + #[arg(long)] + yes: bool, + }, +} - #[arg(long, env = "B", default_value = "0", allow_hyphen_values = true)] - b: i64, +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +enum HookSource { + Codex, } fn main() { - let args = Args::parse(); - println!("{}", sum(args.a, args.b)); + let cli = Cli::parse(); + + match &cli.command { + Commands::Hook { source } => { + if let Err(error) = run_hook(*source) { + eprintln!("plan-to-git hook error: {error}"); + } + } + command => { + if let Err(error) = run(command) { + eprintln!("plan-to-git error: {error}"); + std::process::exit(1); + } + } + } +} + +fn run(command: &Commands) -> AppResult<()> { + match command { + Commands::Hook { .. } => Ok(()), + Commands::ImportCodex { + codex_home, + dry_run, + no_sync, + } => { + let (context, state_path) = state_context()?; + let mut state = load_state(&state_path)?; + state.set_context( + context.repo_slug.clone(), + context.branch.clone(), + context.head_sha.clone(), + ); + + let codex_home = codex_home + .clone() + .or_else(default_codex_home) + .ok_or_else(|| AppError::new("cannot locate Codex home directory"))?; + let outcome = codex_history::import_codex_history(&codex_home, &context, &mut state)?; + + if !*dry_run && outcome.plans_added > 0 { + save_state(&state_path, &state)?; + } + + print_import_outcome(&outcome, *dry_run); + + if *dry_run || *no_sync || outcome.plans_found == 0 { + return Ok(()); + } + + let sync_status = github::sync_state(&context, &mut state)?; + save_state(&state_path, &state)?; + print_sync_status(&sync_status); + Ok(()) + } + Commands::Sync => { + let (context, state_path) = state_context()?; + let mut state = load_state(&state_path)?; + state.set_context( + context.repo_slug.clone(), + context.branch.clone(), + context.head_sha.clone(), + ); + let sync_status = github::sync_state(&context, &mut state)?; + save_state(&state_path, &state)?; + print_sync_status(&sync_status); + Ok(()) + } + Commands::Show => { + let (_, state_path) = state_context()?; + let state = load_state(&state_path)?; + println!("{}", serde_json::to_string_pretty(&state)?); + Ok(()) + } + Commands::Render => { + let (_, state_path) = state_context()?; + let state = load_state(&state_path)?; + println!( + "{}", + render_plan_comment(&state, &state.unposted_items_for_pr(0)) + ); + Ok(()) + } + Commands::Clear { yes } => { + if !*yes { + return Err(AppError::new("refusing to clear state without --yes").into()); + } + let (_, state_path) = state_context()?; + if state_path.exists() { + fs::remove_file(state_path)?; + } + println!("cleared {STATE_FILE_NAME}"); + Ok(()) + } + } +} + +fn run_hook(source: HookSource) -> AppResult<()> { + let mut input = String::new(); + io::stdin().read_to_string(&mut input)?; + + match source { + HookSource::Codex => { + let outcome = capture::process_codex_hook(&input)?; + eprintln!( + "plan-to-git: captured {} plan(s), {} decision(s), {} pending question set(s), sync={:?}", + outcome.captured_plans, + outcome.captured_decisions, + outcome.pending_questions, + outcome.sync_status + ); + } + } + + Ok(()) +} + +fn state_context() -> AppResult<(git::GitContext, std::path::PathBuf)> { + let cwd = std::env::current_dir()?; + let context = git::discover(&cwd)?; + let state_path = context.repo_root.join(STATE_FILE_NAME); + Ok((context, state_path)) +} + +fn print_sync_status(status: &SyncStatus) { + match status { + SyncStatus::NoItems => println!("no captured plan items to sync"), + SyncStatus::NoPullRequest => println!("no pull request found for the current branch"), + SyncStatus::Unchanged { number } => { + println!("no new plan items to comment on pull request #{number}"); + } + SyncStatus::Commented { + number, + comment_id, + items, + } => { + println!("posted {items} plan item(s) to pull request #{number} comment #{comment_id}"); + } + } +} + +fn default_codex_home() -> Option { + std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".codex"))) +} + +fn print_import_outcome(outcome: &CodexHistoryImportOutcome, dry_run: bool) { + let mode = if dry_run { "dry-run" } else { "import" }; + println!( + "{mode}: scanned {} file(s), matched {} current repo/branch file(s), found {} plan(s), added {}, skipped {} duplicate(s), skipped {} rendered stack(s), parse errors {}", + outcome.files_scanned, + outcome.files_matched, + outcome.plans_found, + outcome.plans_added, + outcome.duplicates, + outcome.rendered_stacks_skipped, + outcome.parse_errors + ); } diff --git a/src/normalize.rs b/src/normalize.rs new file mode 100644 index 0000000..4a10e24 --- /dev/null +++ b/src/normalize.rs @@ -0,0 +1,178 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapturedPlan { + pub title: Option, + pub content: String, +} + +#[must_use] +pub fn extract_marked_plans(message: &str) -> Vec { + let mut plans = extract_tagged_plans(message); + plans.extend(extract_accepted_plan_headings(message)); + plans + .into_iter() + .filter(|plan| !plan.content.trim().is_empty()) + .collect() +} + +#[must_use] +pub fn extract_questions(message: &str) -> Vec { + let mut questions = Vec::new(); + for line in message.lines() { + let candidate = strip_list_marker(line.trim()); + if !candidate.ends_with('?') || candidate.len() < 4 { + continue; + } + + let question = candidate.trim().trim_matches('`').trim(); + if question.len() > 240 { + continue; + } + + if !questions.iter().any(|existing| existing == question) { + questions.push(question.to_owned()); + } + + if questions.len() == 10 { + break; + } + } + questions +} + +fn extract_tagged_plans(message: &str) -> Vec { + let lower = message.to_lowercase(); + let open_tag = ""; + let close_tag = ""; + let mut cursor = 0; + let mut plans = Vec::new(); + + while let Some(relative_start) = lower[cursor..].find(open_tag) { + let content_start = cursor + relative_start + open_tag.len(); + let mut close_cursor = content_start; + + let Some(content_end) = (loop { + let Some(relative_end) = lower[close_cursor..].find(close_tag) else { + break None; + }; + let candidate_start = close_cursor + relative_end; + let candidate_end = candidate_start + close_tag.len(); + if closes_plan_block(message, candidate_end) { + break Some(candidate_start); + } + close_cursor = candidate_end; + }) else { + break; + }; + + let content = message[content_start..content_end].trim(); + if !content.is_empty() { + plans.push(CapturedPlan { + title: first_heading(content), + content: content.to_owned(), + }); + } + cursor = content_end + close_tag.len(); + } + + plans +} + +fn closes_plan_block(message: &str, close_tag_end: usize) -> bool { + message[close_tag_end..] + .lines() + .next() + .is_none_or(|rest_of_line| rest_of_line.trim().is_empty()) +} + +fn extract_accepted_plan_headings(message: &str) -> Vec { + let lines: Vec<&str> = message.lines().collect(); + let mut plans = Vec::new(); + let mut index = 0; + + while index < lines.len() { + let trimmed = lines[index].trim(); + if !is_accepted_plan_heading(trimmed) { + index += 1; + continue; + } + + let current_heading_level = heading_level(trimmed).unwrap_or(6); + let title = clean_heading(trimmed); + let mut content_lines = Vec::new(); + index += 1; + + while index < lines.len() { + let next = lines[index].trim(); + if heading_level(next).is_some_and(|next_level| next_level <= current_heading_level) { + break; + } + content_lines.push(lines[index]); + index += 1; + } + + let content = content_lines.join("\n").trim().to_owned(); + if !content.is_empty() { + plans.push(CapturedPlan { + title: Some(title), + content, + }); + } + } + + plans +} + +fn is_accepted_plan_heading(line: &str) -> bool { + let normalized = clean_heading(line).to_lowercase(); + matches!( + normalized.as_str(), + "accepted plan" | "принятый план" | "актуальный план" + ) +} + +fn clean_heading(line: &str) -> String { + line.trim_start_matches('#') + .trim() + .trim_end_matches(':') + .trim() + .to_owned() +} + +fn first_heading(content: &str) -> Option { + content + .lines() + .map(str::trim) + .find(|line| heading_level(line).is_some()) + .map(clean_heading) +} + +fn heading_level(line: &str) -> Option { + let hashes = line + .chars() + .take_while(|character| *character == '#') + .count(); + if hashes == 0 || hashes > 6 { + return None; + } + line.chars() + .nth(hashes) + .is_some_and(char::is_whitespace) + .then_some(hashes) +} + +fn strip_list_marker(line: &str) -> &str { + let without_bullet = line + .strip_prefix("- ") + .or_else(|| line.strip_prefix("* ")) + .unwrap_or(line); + + let Some((marker, rest)) = without_bullet.split_once(". ") else { + return without_bullet; + }; + + if marker.chars().all(|character| character.is_ascii_digit()) { + rest + } else { + without_bullet + } +} diff --git a/src/pr_body.rs b/src/pr_body.rs new file mode 100644 index 0000000..5a177a5 --- /dev/null +++ b/src/pr_body.rs @@ -0,0 +1,33 @@ +use crate::error::{AppError, AppResult}; + +pub const START_MARKER: &str = ""; +pub const END_MARKER: &str = ""; + +pub fn upsert_marker_block(body: &str, block: &str) -> AppResult { + let start = body.find(START_MARKER); + let end = body.rfind(END_MARKER); + + match (start, end) { + (None, None) => Ok(append_block(body, block)), + (Some(start_index), Some(end_index)) if start_index < end_index => { + let replace_end = end_index + END_MARKER.len(); + let mut updated = String::new(); + updated.push_str(body[..start_index].trim_end()); + updated.push_str("\n\n"); + updated.push_str(block.trim()); + updated.push_str("\n\n"); + updated.push_str(body[replace_end..].trim_start()); + Ok(updated.trim_end().to_owned()) + } + (Some(_), Some(_)) => Err(AppError::new("plan-to-git PR markers are out of order").into()), + _ => Err(AppError::new("plan-to-git PR body has only one marker").into()), + } +} + +fn append_block(body: &str, block: &str) -> String { + if body.trim().is_empty() { + return block.trim().to_owned(); + } + + format!("{}\n\n{}", body.trim_end(), block.trim()) +} diff --git a/src/redact.rs b/src/redact.rs new file mode 100644 index 0000000..3be36a3 --- /dev/null +++ b/src/redact.rs @@ -0,0 +1,27 @@ +use regex::Regex; + +#[must_use] +pub fn redact(input: &str) -> String { + let mut output = input.to_owned(); + for (pattern, replacement) in secret_patterns() { + if let Ok(regex) = Regex::new(pattern) { + output = regex.replace_all(&output, replacement).into_owned(); + } + } + output +} + +const fn secret_patterns() -> [(&'static str, &'static str); 7] { + [ + ( + r#"(?i)(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*['"]?[^'"\s`]+"#, + "$1=[REDACTED]", + ), + (r"(?i)bearer\s+[a-z0-9._\-]{16,}", "Bearer [REDACTED]"), + (r"sk-[a-zA-Z0-9_\-]{16,}", "[REDACTED_OPENAI_KEY]"), + (r"ghp_[a-zA-Z0-9_]{20,}", "[REDACTED_GITHUB_TOKEN]"), + (r"github_pat_[a-zA-Z0-9_]{20,}", "[REDACTED_GITHUB_TOKEN]"), + (r"xox[baprs]-[a-zA-Z0-9\-]{20,}", "[REDACTED_SLACK_TOKEN]"), + (r"AKIA[0-9A-Z]{16}", "[REDACTED_AWS_KEY]"), + ] +} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..6dc1536 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,122 @@ +use crate::pr_body::{END_MARKER, START_MARKER}; +use crate::store::{AgentPlanState, AgentSource, PlanItemKind, PlanStackItem}; + +#[must_use] +pub fn render_plan_block(state: &AgentPlanState) -> String { + let mut output = String::new(); + output.push_str(START_MARKER); + output.push('\n'); + output.push_str("## Agent Plan Stack\n\n"); + push_context_line(&mut output, state); + + let items = current_branch_items(state); + + if items.is_empty() { + output.push_str("_No captured plans yet._\n"); + } else { + for (index, item) in items.iter().enumerate() { + output.push_str("### "); + output.push_str(&(index + 1).to_string()); + output.push_str(". "); + output.push_str(item_title(item.kind)); + output.push('\n'); + output.push_str("Source: "); + output.push_str(source_label(item.source)); + output.push_str(" - Captured: "); + output.push_str(&item.created_at); + output.push_str("\n\n"); + output.push_str(&escape_plan_markers(item.content.trim())); + output.push_str("\n\n"); + } + } + + output.push_str(END_MARKER); + output +} + +#[must_use] +pub fn render_plan_comment(state: &AgentPlanState, items: &[&PlanStackItem]) -> String { + let mut output = String::from("## Agent Plan Update\n\n"); + push_context_line(&mut output, state); + push_plan_items(&mut output, items); + output.trim_end().to_owned() +} + +#[must_use] +pub fn has_current_branch_items(state: &AgentPlanState) -> bool { + state + .items + .iter() + .any(|item| matches_current_branch(item, state.branch.as_deref())) +} + +fn current_branch_items(state: &AgentPlanState) -> Vec<&PlanStackItem> { + state + .items + .iter() + .filter(|item| matches_current_branch(item, state.branch.as_deref())) + .collect() +} + +fn push_context_line(output: &mut String, state: &AgentPlanState) { + if let Some(branch) = &state.branch { + output.push_str("_Branch: `"); + output.push_str(branch); + output.push('`'); + if let Some(head_sha) = &state.head_sha { + output.push_str(" at `"); + output.push_str(short_sha(head_sha)); + output.push('`'); + } + output.push_str("._\n\n"); + } +} + +fn push_plan_items(output: &mut String, items: &[&PlanStackItem]) { + for (index, item) in items.iter().enumerate() { + output.push_str("### "); + output.push_str(&(index + 1).to_string()); + output.push_str(". "); + output.push_str(item_title(item.kind)); + output.push('\n'); + output.push_str("Source: "); + output.push_str(source_label(item.source)); + output.push_str(" - Captured: "); + output.push_str(&item.created_at); + output.push_str("\n\n"); + output.push_str(&escape_plan_markers(item.content.trim())); + output.push_str("\n\n"); + } +} + +fn matches_current_branch(item: &PlanStackItem, current_branch: Option<&str>) -> bool { + match (item.branch.as_deref(), current_branch) { + (Some(item_branch), Some(branch)) => item_branch == branch, + (Some(_), None) | (None, _) => true, + } +} + +const fn item_title(kind: PlanItemKind) -> &'static str { + match kind { + PlanItemKind::Plan => "Plan", + PlanItemKind::Decision => "Planning Decision", + } +} + +const fn source_label(source: AgentSource) -> &'static str { + match source { + AgentSource::Codex => "codex", + AgentSource::Claude => "claude", + AgentSource::Manual => "manual", + } +} + +fn short_sha(sha: &str) -> &str { + sha.get(..7).unwrap_or(sha) +} + +fn escape_plan_markers(content: &str) -> String { + content + .replace(START_MARKER, "<!-- plan-to-git:start -->") + .replace(END_MARKER, "<!-- plan-to-git:end -->") +} diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..4714d12 --- /dev/null +++ b/src/store.rs @@ -0,0 +1,406 @@ +use chrono::{SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::Path; + +use crate::error::AppResult; +use crate::redact::redact; + +pub const STATE_FILE_NAME: &str = ".agent-plan.json"; +const SCHEMA_VERSION: u8 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentPlanState { + pub schema_version: u8, + pub repo: Option, + pub branch: Option, + pub head_sha: Option, + #[serde(default)] + pub items: Vec, + #[serde(default)] + pub pending_questions: Vec, + #[serde(default)] + pub posted_comments: Vec, +} + +impl Default for AgentPlanState { + fn default() -> Self { + Self { + schema_version: SCHEMA_VERSION, + repo: None, + branch: None, + head_sha: None, + items: Vec::new(), + pending_questions: Vec::new(), + posted_comments: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PlanStackItem { + pub id: String, + pub kind: PlanItemKind, + pub source: AgentSource, + pub title: Option, + pub content: String, + #[serde(default)] + pub questions: Vec, + pub answer: Option, + pub branch: Option, + pub head_sha: Option, + pub session_id: Option, + pub turn_id: Option, + pub content_hash: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PlanItemKind { + Plan, + Decision, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AgentSource { + Codex, + Claude, + Manual, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PendingQuestion { + pub id: String, + pub source: AgentSource, + pub questions: Vec, + pub branch: Option, + pub head_sha: Option, + pub session_id: Option, + pub turn_id: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PostedPlanComment { + pub pr_number: u64, + pub item_id: String, + pub content_hash: String, + pub comment_id: Option, + pub posted_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewPlanItem { + pub source: AgentSource, + pub title: Option, + pub content: String, + pub branch: Option, + pub head_sha: Option, + pub session_id: Option, + pub turn_id: Option, + pub created_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewPendingQuestion { + pub source: AgentSource, + pub questions: Vec, + pub branch: Option, + pub head_sha: Option, + pub session_id: Option, + pub turn_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewDecision { + pub source: AgentSource, + pub questions: Vec, + pub answer: String, + pub branch: Option, + pub head_sha: Option, + pub session_id: Option, + pub turn_id: Option, +} + +impl AgentPlanState { + pub fn set_context( + &mut self, + repo: Option, + branch: Option, + head_sha: Option, + ) { + self.repo = repo; + self.branch = branch; + self.head_sha = head_sha; + } + + pub fn add_plan(&mut self, new_item: NewPlanItem) -> bool { + let content = redact(new_item.content.trim()); + if content.is_empty() { + return false; + } + + let content_hash = item_hash( + PlanItemKind::Plan, + new_item.source, + &content, + &[], + None, + new_item.branch.as_deref(), + ); + + if self + .items + .iter() + .any(|item| item.content_hash == content_hash) + { + return false; + } + + self.items.push(PlanStackItem { + id: item_id("plan", &content_hash), + kind: PlanItemKind::Plan, + source: new_item.source, + title: new_item.title.map(|title| redact(title.trim())), + content, + questions: Vec::new(), + answer: None, + branch: new_item.branch, + head_sha: new_item.head_sha, + session_id: new_item.session_id, + turn_id: new_item.turn_id, + content_hash, + created_at: new_item.created_at.unwrap_or_else(timestamp), + }); + + true + } + + #[must_use] + pub fn unposted_items_for_pr(&self, _pr_number: u64) -> Vec<&PlanStackItem> { + self.items + .iter() + .filter(|item| self.matches_current_branch(item)) + .filter(|item| !self.is_posted(item)) + .collect() + } + + #[must_use] + pub fn has_current_branch_items(&self) -> bool { + self.items + .iter() + .any(|item| self.matches_current_branch(item)) + } + + pub fn mark_items_commented( + &mut self, + pr_number: u64, + item_ids: &[String], + comment_id: Option, + ) { + let posted_at = timestamp(); + let mut new_comments = Vec::new(); + for item_id in item_ids { + let Some(item) = self.items.iter().find(|item| &item.id == item_id) else { + continue; + }; + if self.is_posted(item) { + continue; + } + new_comments.push(PostedPlanComment { + pr_number, + item_id: item.id.clone(), + content_hash: item.content_hash.clone(), + comment_id, + posted_at: posted_at.clone(), + }); + } + self.posted_comments.extend(new_comments); + } + + fn matches_current_branch(&self, item: &PlanStackItem) -> bool { + match (item.branch.as_deref(), self.branch.as_deref()) { + (Some(item_branch), Some(branch)) => item_branch == branch, + (Some(_), None) | (None, _) => true, + } + } + + fn is_posted(&self, item: &PlanStackItem) -> bool { + self.posted_comments.iter().any(|posted| { + (posted.item_id.as_str(), posted.content_hash.as_str()) + == (item.id.as_str(), item.content_hash.as_str()) + }) + } + + pub fn add_pending_question(&mut self, new_question: NewPendingQuestion) -> bool { + let questions: Vec = new_question + .questions + .into_iter() + .map(|question| redact(question.trim())) + .filter(|question| !question.is_empty()) + .collect(); + + if questions.is_empty() { + return false; + } + + let question_hash = stable_hash(&questions.join("\n")); + if self + .pending_questions + .iter() + .any(|question| question.id == item_id("question", &question_hash)) + { + return false; + } + + self.pending_questions.push(PendingQuestion { + id: item_id("question", &question_hash), + source: new_question.source, + questions, + branch: new_question.branch, + head_sha: new_question.head_sha, + session_id: new_question.session_id, + turn_id: new_question.turn_id, + created_at: timestamp(), + }); + + true + } + + pub fn answer_pending_questions(&mut self, new_decision: NewDecision) -> bool { + let answer = redact(new_decision.answer.trim()); + if answer.is_empty() || new_decision.questions.is_empty() { + return false; + } + + let questions: Vec = new_decision + .questions + .into_iter() + .map(|question| redact(question.trim())) + .filter(|question| !question.is_empty()) + .collect(); + + if questions.is_empty() { + return false; + } + + let content = render_decision_content(&questions, &answer); + let content_hash = item_hash( + PlanItemKind::Decision, + new_decision.source, + &content, + &questions, + Some(&answer), + new_decision.branch.as_deref(), + ); + + if self + .items + .iter() + .any(|item| item.content_hash == content_hash) + { + self.pending_questions.clear(); + return false; + } + + self.items.push(PlanStackItem { + id: item_id("decision", &content_hash), + kind: PlanItemKind::Decision, + source: new_decision.source, + title: Some(String::from("Planning decision")), + content, + questions, + answer: Some(answer), + branch: new_decision.branch, + head_sha: new_decision.head_sha, + session_id: new_decision.session_id, + turn_id: new_decision.turn_id, + content_hash, + created_at: timestamp(), + }); + self.pending_questions.clear(); + + true + } +} + +pub fn load_state(path: &Path) -> AppResult { + if !path.exists() { + return Ok(AgentPlanState::default()); + } + + let content = fs::read_to_string(path)?; + let mut state: AgentPlanState = serde_json::from_str(&content)?; + if state.schema_version == 0 { + state.schema_version = SCHEMA_VERSION; + } + Ok(state) +} + +pub fn save_state(path: &Path, state: &AgentPlanState) -> AppResult<()> { + let content = serde_json::to_string_pretty(state)?; + fs::write(path, format!("{content}\n"))?; + Ok(()) +} + +fn render_decision_content(questions: &[String], answer: &str) -> String { + let mut content = String::from("Questions:\n"); + for question in questions { + content.push_str("- "); + content.push_str(question); + content.push('\n'); + } + content.push_str("\nAnswer:\n"); + content.push_str(answer); + content +} + +fn item_hash( + kind: PlanItemKind, + source: AgentSource, + content: &str, + questions: &[String], + answer: Option<&str>, + branch: Option<&str>, +) -> String { + let mut input = format!("{kind:?}\n{source:?}\n{}\n", content.trim()); + if !questions.is_empty() { + input.push_str(&questions.join("\n")); + } + if let Some(answer) = answer { + input.push_str(answer); + } + if let Some(branch) = branch { + input.push_str(branch); + } + stable_hash(&input) +} + +fn item_id(prefix: &str, hash: &str) -> String { + let short_hash: String = hash.chars().take(12).collect(); + format!("{prefix}-{short_hash}") +} + +fn stable_hash(input: &str) -> String { + let digest = Sha256::digest(input.as_bytes()); + let mut output = String::with_capacity(digest.len() * 2); + for byte in digest { + output.push(hex_char(byte >> 4)); + output.push(hex_char(byte & 0x0f)); + } + output +} + +const fn hex_char(value: u8) -> char { + match value { + 0..=9 => (b'0' + value) as char, + _ => (b'a' + (value - 10)) as char, + } +} + +fn timestamp() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) +} diff --git a/src/sum.rs b/src/sum.rs deleted file mode 100644 index e0960d2..0000000 --- a/src/sum.rs +++ /dev/null @@ -1,4 +0,0 @@ -#[must_use] -pub const fn sum(a: i64, b: i64) -> i64 { - a + b -} diff --git a/tests/integration/cli.rs b/tests/integration/cli.rs new file mode 100644 index 0000000..ee15256 --- /dev/null +++ b/tests/integration/cli.rs @@ -0,0 +1,291 @@ +#[cfg(unix)] +mod unix { + use std::fs; + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + use std::path::Path; + use std::process::{Command, Stdio}; + + use plan_to_git::store::STATE_FILE_NAME; + + #[test] + fn hook_captures_plan_and_handles_missing_pr() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp_dir.path().join("bin"); + let repo_dir = temp_dir.path().join("repo"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + write_fake_git(&bin_dir, &repo_dir); + write_fake_gh_no_pr(&bin_dir); + + let payload = format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"Stop", + "turn_id":"turn", + "last_assistant_message":"\n# MVP\n\n- Capture plan\n" + }}"#, + repo_dir.display() + ); + + let mut child = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) + .arg("hook") + .arg("--source") + .arg("codex") + .current_dir(&repo_dir) + .env("PATH", path_with_fake_bin(&bin_dir)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn plan-to-git"); + + child + .stdin + .as_mut() + .expect("stdin") + .write_all(payload.as_bytes()) + .expect("write payload"); + + let output = child.wait_with_output().expect("wait"); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("Capture plan")); + } + + #[test] + fn hook_records_question_answer_decision() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp_dir.path().join("bin"); + let repo_dir = temp_dir.path().join("repo"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + write_fake_git(&bin_dir, &repo_dir); + write_fake_gh_no_pr(&bin_dir); + + run_hook( + &repo_dir, + &bin_dir, + &format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"Stop", + "turn_id":"turn-1", + "last_assistant_message":"Should sync be automatic?" + }}"#, + repo_dir.display() + ), + ); + + run_hook( + &repo_dir, + &bin_dir, + &format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"UserPromptSubmit", + "turn_id":"turn-2", + "prompt":"Yes, sync automatically when a PR exists." + }}"#, + repo_dir.display() + ), + ); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("Should sync be automatic?")); + assert!(state.contains("Yes, sync automatically")); + assert!(state.contains("\"pending_questions\": []")); + } + + #[test] + fn hook_posts_open_pr_comment_through_gh_api() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp_dir.path().join("bin"); + let repo_dir = temp_dir.path().join("repo"); + let captured_request = temp_dir.path().join("request.json"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + write_fake_git(&bin_dir, &repo_dir); + write_fake_gh_open_pr(&bin_dir, &captured_request); + + run_hook( + &repo_dir, + &bin_dir, + &format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"Stop", + "turn_id":"turn", + "last_assistant_message":"\n# MVP\n\n- Post PR comment\n" + }}"#, + repo_dir.display() + ), + ); + + let request = fs::read_to_string(captured_request).expect("captured request"); + assert!(request.contains("Agent Plan Update")); + assert!(request.contains("Post PR comment")); + assert!(!request.contains("Original PR body")); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("\"posted_comments\"")); + assert!(state.contains("\"comment_id\": 12345")); + } + + #[test] + fn import_codex_backfills_history_once_without_syncing() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp_dir.path().join("bin"); + let repo_dir = temp_dir.path().join("repo"); + let codex_home = temp_dir.path().join("codex"); + let session_dir = codex_home.join("sessions/2026/05/31"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + fs::create_dir_all(&session_dir).expect("session dir"); + write_fake_git(&bin_dir, &repo_dir); + + fs::write( + session_dir.join("rollout-2026-05-31T00-00-00-session.jsonl"), + format!( + r#"{{"type":"session_meta","payload":{{"id":"session","cwd":"{}","git":{{"branch":"feature/test","repository_url":"https://github.com/example/repo.git"}}}}}} +{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"\n# Archived Plan\n\n- Import archived plan\n"}}]}}}} +"#, + repo_dir.display() + ), + ) + .expect("write session"); + + let first = run_import_codex(&repo_dir, &bin_dir, &codex_home); + assert!(first.contains("found 1 plan(s), added 1")); + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("Import archived plan")); + + let second = run_import_codex(&repo_dir, &bin_dir, &codex_home); + assert!(second.contains("found 1 plan(s), added 0")); + assert!(second.contains("skipped 1 duplicate(s)")); + } + + fn run_hook(repo_dir: &Path, bin_dir: &Path, payload: &str) { + let mut child = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) + .arg("hook") + .arg("--source") + .arg("codex") + .current_dir(repo_dir) + .env("PATH", path_with_fake_bin(bin_dir)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn plan-to-git"); + + child + .stdin + .as_mut() + .expect("stdin") + .write_all(payload.as_bytes()) + .expect("write payload"); + + let output = child.wait_with_output().expect("wait"); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); + } + + fn run_import_codex(repo_dir: &Path, bin_dir: &Path, codex_home: &Path) -> String { + let output = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) + .arg("import-codex") + .arg("--codex-home") + .arg(codex_home) + .arg("--no-sync") + .current_dir(repo_dir) + .env("PATH", path_with_fake_bin(bin_dir)) + .output() + .expect("run import-codex"); + + assert!(output.status.success()); + String::from_utf8(output.stdout).expect("stdout") + } + + fn write_fake_git(bin_dir: &Path, repo_dir: &Path) { + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == "-C" ]]; then + shift 2 +fi +case "$*" in + "rev-parse --show-toplevel") + printf '%s\n' "{}" + ;; + "rev-parse --abbrev-ref HEAD") + printf '%s\n' "feature/test" + ;; + "rev-parse HEAD") + printf '%s\n' "abcdef1234567890" + ;; + "remote get-url origin") + printf '%s\n' "https://github.com/example/repo.git" + ;; + *) + echo "unexpected git args: $*" >&2 + exit 1 + ;; +esac +"#, + repo_dir.display() + ); + write_executable(&bin_dir.join("git"), &script); + } + + fn write_fake_gh_no_pr(bin_dir: &Path) { + let script = r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "pr view --json number" ]]; then + echo 'no pull requests found for branch "feature/test"' >&2 + exit 1 +fi +echo "unexpected gh args: $*" >&2 +exit 1 +"#; + write_executable(&bin_dir.join("gh"), script); + } + + fn write_fake_gh_open_pr(bin_dir: &Path, captured_request: &Path) { + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "pr view --json number" ]]; then + printf '%s\n' '{{"number":17}}' + exit 0 +fi +if [[ "$1 $2 $3" == "api --method POST" && "$4" == "repos/example/repo/issues/17/comments" && "$5" == "--input" ]]; then + cp "$6" "{}" + printf '%s\n' '{{"id":12345}}' + exit 0 +fi +echo "unexpected gh args: $*" >&2 +exit 1 +"#, + captured_request.display() + ); + write_executable(&bin_dir.join("gh"), &script); + } + + fn write_executable(path: &Path, content: &str) { + fs::write(path, content).expect("write script"); + let mut permissions = fs::metadata(path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("permissions"); + } + + fn path_with_fake_bin(bin_dir: &Path) -> String { + format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ) + } +} diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs index 47e2280..26710c1 100644 --- a/tests/integration/mod.rs +++ b/tests/integration/mod.rs @@ -1 +1 @@ -mod sum; +mod cli; diff --git a/tests/integration/sum.rs b/tests/integration/sum.rs deleted file mode 100644 index b0d15bc..0000000 --- a/tests/integration/sum.rs +++ /dev/null @@ -1,36 +0,0 @@ -use std::process::Command; - -#[test] -fn test_cli_default_args() { - let output = Command::new(env!("CARGO_BIN_EXE_example-sum-package-name")) - .output() - .expect("Failed to execute binary"); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "0"); -} - -#[test] -fn test_cli_sum_two_numbers() { - let output = Command::new(env!("CARGO_BIN_EXE_example-sum-package-name")) - .args(["--a", "3", "--b", "7"]) - .output() - .expect("Failed to execute binary"); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "10"); -} - -#[test] -fn test_cli_negative_numbers() { - let output = Command::new(env!("CARGO_BIN_EXE_example-sum-package-name")) - .args(["--a", "-5", "--b", "3"]) - .output() - .expect("Failed to execute binary"); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "-2"); -} diff --git a/tests/unit/mod.rs b/tests/unit/mod.rs index 7c158e6..cb1013d 100644 --- a/tests/unit/mod.rs +++ b/tests/unit/mod.rs @@ -1,4 +1,4 @@ -mod sum; +mod plan_capture; #[path = "ci-cd/mod.rs"] mod ci_cd; diff --git a/tests/unit/plan_capture.rs b/tests/unit/plan_capture.rs new file mode 100644 index 0000000..cb693ac --- /dev/null +++ b/tests/unit/plan_capture.rs @@ -0,0 +1,308 @@ +use plan_to_git::normalize::{extract_marked_plans, extract_questions}; +use plan_to_git::pr_body::{upsert_marker_block, END_MARKER, START_MARKER}; +use plan_to_git::redact::redact; +use plan_to_git::render::{has_current_branch_items, render_plan_block, render_plan_comment}; +use plan_to_git::store::{ + AgentPlanState, AgentSource, NewDecision, NewPendingQuestion, NewPlanItem, +}; + +#[test] +fn extracts_proposed_plan_blocks() { + let message = r" +before + +# Implement Plan + +- Add capture. +- Add sync. + +after +"; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert_eq!(plans[0].title.as_deref(), Some("Implement Plan")); + assert!(plans[0].content.contains("- Add capture.")); +} + +#[test] +fn tagged_plan_ignores_inline_tag_examples() { + let message = r" + +# Parser Plan + +- Capture `...` examples as prose. +- Continue until the real closing marker. + +"; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert!(plans[0].content.contains("Continue until the real")); +} + +#[test] +fn extracts_single_line_tagged_plan() { + let message = "# Inline\n- Keep it"; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert!(plans[0].content.contains("Keep it")); +} + +#[test] +fn extracts_accepted_plan_headings() { + let message = r" +## Accepted Plan +- Capture marked plans only. +- Sync to PR markers. + +## Summary +Do not include this. +"; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert_eq!(plans[0].title.as_deref(), Some("Accepted Plan")); + assert!(!plans[0].content.contains("Summary")); +} + +#[test] +fn extracts_accepted_plan_label_until_next_heading() { + let message = r" +Accepted Plan: +- Keep this. + +### Notes +Do not include this. +"; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert_eq!(plans[0].title.as_deref(), Some("Accepted Plan")); + assert!(!plans[0].content.contains("Notes")); +} + +#[test] +fn rejects_unmarked_plan_like_text() { + let message = "Plan:\n- Read everything\n- Upload all context"; + + assert!(extract_marked_plans(message).is_empty()); +} + +#[test] +fn extracts_assistant_questions() { + let message = r" +I need two choices: +- Which agent should be first? +1. Should sync be automatic? +This line is not a question. +"; + + let questions = extract_questions(message); + + assert_eq!( + questions, + vec![ + "Which agent should be first?".to_owned(), + "Should sync be automatic?".to_owned() + ] + ); +} + +#[test] +fn redacts_common_secret_shapes() { + let redacted = redact( + "api_key=sk-abcdefghijklmnopqrstuvwxyz token: ghp_abcdefghijklmnopqrstuvwxyz ghp_abcdefghijklmnopqrstuvwxyz", + ); + + assert!(redacted.contains("api_key=[REDACTED]")); + assert!(redacted.contains("token=[REDACTED]")); + assert!(redacted.contains("[REDACTED_GITHUB_TOKEN]")); + assert!(!redacted.contains("ghp_abcdefghijklmnopqrstuvwxyz")); +} + +#[test] +fn state_deduplicates_plans_and_records_decisions() { + let mut state = AgentPlanState::default(); + + let first = state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: Some("Implement Plan".to_owned()), + content: "- Step 1".to_owned(), + branch: Some("feature".to_owned()), + head_sha: Some("abc123".to_owned()), + session_id: Some("session".to_owned()), + turn_id: Some("turn".to_owned()), + created_at: None, + }); + let duplicate = state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: Some("Implement Plan".to_owned()), + content: "- Step 1".to_owned(), + branch: Some("feature".to_owned()), + head_sha: Some("abc123".to_owned()), + session_id: Some("session".to_owned()), + turn_id: Some("turn".to_owned()), + created_at: None, + }); + + assert!(first); + assert!(!duplicate); + assert_eq!(state.items.len(), 1); + + assert!(state.add_pending_question(NewPendingQuestion { + source: AgentSource::Codex, + questions: vec!["Auto sync?".to_owned()], + branch: Some("feature".to_owned()), + head_sha: Some("abc123".to_owned()), + session_id: Some("session".to_owned()), + turn_id: Some("turn-2".to_owned()), + })); + + assert!(state.answer_pending_questions(NewDecision { + source: AgentSource::Codex, + questions: vec!["Auto sync?".to_owned()], + answer: "Yes, sync automatically.".to_owned(), + branch: Some("feature".to_owned()), + head_sha: Some("abc123".to_owned()), + session_id: Some("session".to_owned()), + turn_id: Some("turn-3".to_owned()), + })); + + assert_eq!(state.pending_questions.len(), 0); + assert_eq!(state.items.len(), 2); + assert!(state.items[1].content.contains("Yes, sync automatically.")); +} + +#[test] +fn render_filters_items_to_current_branch() { + let mut state = AgentPlanState::default(); + state.set_context( + Some("example/repo".to_owned()), + Some("feature/current".to_owned()), + Some("abcdef1234567890".to_owned()), + ); + assert!(state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: Some("Current".to_owned()), + content: "- Current branch plan".to_owned(), + branch: Some("feature/current".to_owned()), + head_sha: Some("abcdef1234567890".to_owned()), + session_id: None, + turn_id: None, + created_at: None, + })); + assert!(state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: Some("Other".to_owned()), + content: "- Other branch plan".to_owned(), + branch: Some("feature/other".to_owned()), + head_sha: Some("1234567890abcdef".to_owned()), + session_id: None, + turn_id: None, + created_at: None, + })); + + let rendered = render_plan_block(&state); + + assert!(has_current_branch_items(&state)); + assert!(rendered.contains("Current branch plan")); + assert!(!rendered.contains("Other branch plan")); +} + +#[test] +fn render_escapes_nested_plan_markers() { + let mut state = AgentPlanState::default(); + state.set_context(None, Some("feature/current".to_owned()), None); + assert!(state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: Some("Markers".to_owned()), + content: format!("inside {START_MARKER} and {END_MARKER}"), + branch: Some("feature/current".to_owned()), + head_sha: None, + session_id: None, + turn_id: None, + created_at: None, + })); + + let rendered = render_plan_block(&state); + + assert_eq!(rendered.matches(START_MARKER).count(), 1); + assert_eq!(rendered.matches(END_MARKER).count(), 1); + assert!(rendered.contains("<!-- plan-to-git:start -->")); +} + +#[test] +fn state_tracks_commented_items_per_pr() { + let mut state = AgentPlanState::default(); + state.set_context(None, Some("feature/current".to_owned()), None); + assert!(state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: Some("Commented".to_owned()), + content: "- Post once".to_owned(), + branch: Some("feature/current".to_owned()), + head_sha: None, + session_id: None, + turn_id: None, + created_at: None, + })); + + let item_ids = state + .unposted_items_for_pr(17) + .iter() + .map(|item| item.id.clone()) + .collect::>(); + let comment = render_plan_comment(&state, &state.unposted_items_for_pr(17)); + + assert!(comment.contains("Agent Plan Update")); + assert!(comment.contains("Post once")); + state.mark_items_commented(17, &item_ids, Some(12345)); + + assert!(state.unposted_items_for_pr(17).is_empty()); + assert!(state.unposted_items_for_pr(18).is_empty()); + assert_eq!(state.posted_comments[0].comment_id, Some(12345)); +} + +#[test] +fn pr_body_appends_and_replaces_marker_block() { + let original = "Existing body"; + let block = format!("{START_MARKER}\n## Agent Plan Stack\n{END_MARKER}"); + + let appended = upsert_marker_block(original, &block).expect("append should work"); + assert!(appended.contains(original)); + assert!(appended.contains(START_MARKER)); + + let replacement = format!("{START_MARKER}\n## Updated\n{END_MARKER}"); + let replaced = upsert_marker_block(&appended, &replacement).expect("replace should work"); + assert!(replaced.contains("## Updated")); + assert!(!replaced.contains("## Agent Plan Stack")); +} + +#[test] +fn pr_body_replaces_through_last_marker() { + let original = + format!("Intro\n\n{START_MARKER}\nold\n{END_MARKER}\nstale\n{END_MARKER}\nOutro"); + let block = format!("{START_MARKER}\nnew\n{END_MARKER}"); + + let replaced = upsert_marker_block(&original, &block).expect("replace should work"); + + assert!(replaced.contains("Intro")); + assert!(replaced.contains("new")); + assert!(replaced.contains("Outro")); + assert!(!replaced.contains("old")); + assert!(!replaced.contains("stale")); +} + +#[test] +fn pr_body_rejects_partial_markers() { + let body = format!("Existing body\n\n{START_MARKER}\nmissing end"); + let block = format!("{START_MARKER}\n## Agent Plan Stack\n{END_MARKER}"); + + assert!(upsert_marker_block(&body, &block).is_err()); +} diff --git a/tests/unit/sum.rs b/tests/unit/sum.rs deleted file mode 100644 index 84937f6..0000000 --- a/tests/unit/sum.rs +++ /dev/null @@ -1,26 +0,0 @@ -use example_sum_package_name::sum; - -#[test] -fn test_sum_positive_numbers() { - assert_eq!(sum(2, 3), 5); -} - -#[test] -fn test_sum_negative_numbers() { - assert_eq!(sum(-1, -2), -3); -} - -#[test] -fn test_sum_zero() { - assert_eq!(sum(5, 0), 5); -} - -#[test] -fn test_sum_large_numbers() { - assert_eq!(sum(1_000_000, 2_000_000), 3_000_000); -} - -#[test] -fn test_sum_mixed_sign() { - assert_eq!(sum(-100, 50), -50); -}