From 5b84011791ea6a8b7c1121e7f6d0e6d84aca144f Mon Sep 17 00:00:00 2001 From: Gabriel Wu <13583761+lucifer1004@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:49:37 +0800 Subject: [PATCH] chore: deny unwrap/expect/panic via Cargo.toml lints Add [lints.clippy] with deny rules for unwrap_used, expect_used, and panic. Refactor all production code and tests to use Result + ? instead. - Production: replace LazyLock.expect() with inline Regex::new()?, .parent().unwrap() with .ok_or_else()?, and all .expect() on Options with .ok_or_else()? - Tests: convert all test functions to return Result<(), Box>, replace unwrap/expect with ?, update common test helpers to return Result - Build script: exempt via crate-level allow (panicking on failure is correct) --- .claude-plugin/marketplace.json | 2 +- .claude/.claude-plugin/plugin.json | 2 +- .claude/skills/commit/SKILL.md | 4 +- CHANGELOG.md | 7 + Cargo.lock | 567 +++++++++++++++++- Cargo.toml | 19 +- build.rs | 2 + docs/rfc/RFC-0002.md | 54 +- ...nd-cargo-binstall-binary-distribution.toml | 98 +++ gov/releases.toml | 5 + .../RFC-0002/clauses/C-GLOBAL-COMMANDS.toml | 13 + gov/rfc/RFC-0002/clauses/C-SELF-UPDATE.toml | 31 + gov/rfc/RFC-0002/rfc.toml | 14 +- ...te-command-and-cargo-binstall-support.toml | 51 ++ src/cli.rs | 18 + src/cmd/edit/engine.rs | 82 ++- src/cmd/edit/mod.rs | 298 ++++++--- src/cmd/edit/path.rs | 116 ++-- src/cmd/edit/rules.rs | 12 +- src/cmd/edit/runtime.rs | 75 ++- src/cmd/lifecycle.rs | 11 +- src/cmd/mod.rs | 1 + src/cmd/self_update.rs | 194 ++++++ src/cmd/tag.rs | 26 +- src/command_router.rs | 237 +++++--- src/render.rs | 21 +- src/signature.rs | 13 +- src/terminal_md.rs | 39 +- src/tui/mod.rs | 23 +- src/validate.rs | 13 +- src/write.rs | 72 ++- tests/common/mod.rs | 41 +- tests/test_agent_dir.rs | 44 +- tests/test_changelog.rs | 52 +- tests/test_delete.rs | 85 +-- tests/test_describe.rs | 90 +-- tests/test_display_paths.rs | 149 ++--- tests/test_edit.rs | 533 ++++++++-------- tests/test_errors.rs | 228 ++++--- tests/test_guard.rs | 174 +++--- tests/test_happy_path.rs | 93 +-- tests/test_help.rs | 99 +-- tests/test_init.rs | 66 +- tests/test_lifecycle.rs | 316 +++++----- tests/test_lock.rs | 122 ++-- tests/test_migrate.rs | 82 +-- tests/test_move.rs | 96 +-- tests/test_rfc_lifecycle.rs | 17 +- tests/test_scan.rs | 190 +++--- tests/test_source_scan.rs | 22 +- tests/test_tags.rs | 135 +++-- tests/test_verify.rs | 71 ++- 52 files changed, 3220 insertions(+), 1605 deletions(-) create mode 100644 gov/adr/ADR-0041-self-update-and-cargo-binstall-binary-distribution.toml create mode 100644 gov/rfc/RFC-0002/clauses/C-SELF-UPDATE.toml create mode 100644 gov/work/2026-04-13-implement-self-update-command-and-cargo-binstall-support.toml create mode 100644 src/cmd/self_update.rs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 060ef1c..401824b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ { "name": "govctl", "description": "Governed workflow skills, reviewer agents, and enforcement hooks for govctl", - "version": "0.8.2", + "version": "0.8.3", "source": "./.claude", "author": { "name": "govctl-org" diff --git a/.claude/.claude-plugin/plugin.json b/.claude/.claude-plugin/plugin.json index dab748e..6a6e447 100644 --- a/.claude/.claude-plugin/plugin.json +++ b/.claude/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "govctl", - "version": "0.8.2", + "version": "0.8.3", "description": "Governed workflow skills, reviewer agents, and enforcement hooks for govctl" } diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md index 85c002a..5b30bdd 100644 --- a/.claude/skills/commit/SKILL.md +++ b/.claude/skills/commit/SKILL.md @@ -24,7 +24,9 @@ Commit changes using the project's version control system, with govctl-aware che ### Step 1: Detect VCS -Run `jj root` first. If succeeds, use **Jujutsu**. If fails, run `git rev-parse --git-dir`. If succeeds, use **Git**. If both fail, stop and inform user. +Run `jj root` first. If it succeeds, use **Jujutsu** — do NOT also check git. A jj-git colocated repo has both `.jj/` and `.git/`, so checking git would also succeed and cause you to use the wrong VCS. Only if `jj root` fails, run `git rev-parse --git-dir`. If that succeeds, use **Git**. If both fail, stop and inform user. + +**CRITICAL:** Do NOT run `jj root` and `git rev-parse` in parallel. Run `jj root` first, and only proceed to git detection if jj is not found. ### Step 2: Govctl Pre-Commit Checks diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a579de..a431697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.3] - 2026-04-13 + +### Added + +- Implement govctl self-update command with --check flag (WI-2026-04-13-001) +- Add cargo-binstall metadata to Cargo.toml (WI-2026-04-13-001) + ## [0.8.2] - 2026-04-10 ### Added diff --git a/Cargo.lock b/Cargo.lock index 8e3fdd3..789839b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atomic" version = "0.6.1" @@ -170,6 +179,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bincode" version = "1.3.3" @@ -264,6 +279,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -441,9 +462,16 @@ checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", + "unicode-width", "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.10.0" @@ -453,6 +481,35 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -543,6 +600,33 @@ dependencies = [ "phf", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -589,6 +673,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -598,6 +692,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -662,6 +767,31 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "signature", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -683,6 +813,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -746,6 +885,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -757,6 +902,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -783,6 +939,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -924,8 +1081,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -971,7 +1130,7 @@ dependencies = [ [[package]] name = "govctl" -version = "0.8.2" +version = "0.8.3" dependencies = [ "ansi-to-tui", "anyhow", @@ -990,6 +1149,7 @@ dependencies = [ "rand 0.10.0", "ratatui", "regex", + "self_update", "semver", "serde", "serde_json", @@ -1307,6 +1467,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + [[package]] name = "indoc" version = "2.0.7" @@ -1515,6 +1688,18 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall 0.7.4", +] + [[package]] name = "line-clipping" version = "0.3.7" @@ -1572,6 +1757,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac_address" version = "1.1.8" @@ -1837,7 +2028,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1949,6 +2140,22 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1970,6 +2177,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -2008,6 +2224,71 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.3", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -2038,6 +2319,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.0" @@ -2049,11 +2340,33 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] [[package]] name = "rand_core" @@ -2155,6 +2468,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -2241,6 +2563,7 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -2272,6 +2595,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2301,7 +2630,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2326,6 +2657,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -2427,6 +2759,43 @@ dependencies = [ "libc", ] +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + +[[package]] +name = "self_update" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e79722b5a505d4ddc77527455a97244e9e8c4c07533ff44cf4421cce7bb6d17" +dependencies = [ + "either", + "flate2", + "http", + "indicatif", + "log", + "quick-xml", + "regex", + "reqwest", + "self-replace", + "semver", + "serde", + "serde_json", + "tar", + "tempfile", + "ureq", + "urlencoding", + "zip", + "zipsign-api", +] + [[package]] name = "semver" version = "1.0.27" @@ -2550,6 +2919,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -2606,6 +2985,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2751,6 +3151,17 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2884,12 +3295,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -2898,6 +3311,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2908,6 +3331,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.50.0" @@ -3113,6 +3551,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -3125,6 +3569,40 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "cookie_store", + "encoding_rs", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -3137,6 +3615,18 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3334,6 +3824,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-root-certs" version = "1.0.6" @@ -3343,6 +3843,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -3762,6 +4271,16 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yaml-rust" version = "0.4.5" @@ -3874,8 +4393,52 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "time", + "zopfli", +] + +[[package]] +name = "zipsign-api" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dba6063ff82cdbd9a765add16d369abe81e520f836054e997c2db217ceca40c0" +dependencies = [ + "base64", + "ed25519-dalek", + "thiserror 2.0.18", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index b301d69..a90896f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "govctl" -version = "0.8.2" +version = "0.8.3" edition = "2024" rust-version = "1.88" description = "Project governance CLI for RFC, ADR, and Work Item management" @@ -76,6 +76,10 @@ globset = "0.4" # Process-level file locking (for concurrent write safety per RFC-0004) fs2 = "0.4" +# Self-update (binary replacement from GitHub Releases) +# Implements [[RFC-0002:C-SELF-UPDATE]] +self_update = { version = "0.44", default-features = false, features = ["reqwest", "rustls", "archive-tar", "archive-zip", "compression-flate2", "compression-zip-deflate"] } + # Terminal output comfy-table = "7" owo-colors = "4" @@ -94,6 +98,19 @@ tempfile = "3" regex = "1" chrono = "0.4" +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/v{ version }/govctl-v{ version }-{ target }.{ archive-format }" +bin-dir = "govctl-v{ version }-{ target }/{ bin }{ binary-ext }" +pkg-fmt = "tgz" + +[package.metadata.binstall.overrides.x86_64-pc-windows-msvc] +pkg-fmt = "zip" + +[lints.clippy] +unwrap_used = { level = "deny", priority = 0 } +expect_used = { level = "deny", priority = 0 } +panic = { level = "deny", priority = 0 } + [build-dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/build.rs b/build.rs index 70ba3bd..1a6ade5 100644 --- a/build.rs +++ b/build.rs @@ -1,3 +1,5 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use serde::Deserialize; use serde::Serialize; use serde_json::Value; diff --git a/docs/rfc/RFC-0002.md b/docs/rfc/RFC-0002.md index b97c13e..96f9691 100644 --- a/docs/rfc/RFC-0002.md +++ b/docs/rfc/RFC-0002.md @@ -1,9 +1,9 @@ - + # RFC-0002: CLI Resource Model and Command Architecture -> **Version:** 0.7.0 | **Status:** normative | **Phase:** test +> **Version:** 0.8.0 | **Status:** normative | **Phase:** test --- @@ -558,6 +558,17 @@ Artifact-level tagging uses existing resource verbs on taggable types (rfc, clau - `govctl {rfc|clause|adr|work|guard} add tags ` — assign a tag to an artifact - `govctl {rfc|clause|adr|work|guard} remove tags ` — remove a tag from an artifact +**11. `govctl self-update`** + +Updates the govctl binary to the latest release. + +Syntax: `govctl self-update [--check]` + +Behavior: +- Downloads and replaces the running binary from GitHub Releases +- With `--check`: prints version comparison without downloading +- Full specification in [RFC-0002:C-SELF-UPDATE](../rfc/RFC-0002.md#rfc-0002c-self-update) + **Rationale:** These commands are global because they: @@ -574,6 +585,8 @@ These commands are global because they: `govctl tag` qualifies because it manages project-level configuration that applies across all resource types (criterion 1). +`govctl self-update` qualifies because it provides meta-information about the CLI itself and performs binary lifecycle management (criterion 3). + **Future Additions:** New global commands MAY be added via RFC amendment. They MUST meet at least one criterion: @@ -603,10 +616,47 @@ Work Item `verification.required_guards` remain effective regardless of the proj *Since: v0.3.0* +### [RFC-0002:C-SELF-UPDATE] Self-Update Command (Normative) + +**11. `govctl self-update`** + +Updates the govctl binary to the latest release. + +Syntax: `govctl self-update [--check]` + +Behavior: +- Queries the GitHub Releases API for the `govctl-org/govctl` repository to determine the latest published version +- Compares the latest version against the running binary's compiled version +- Without `--check`: downloads the platform-appropriate binary asset, verifies integrity, and replaces the running executable. MUST print the old and new version on success. MUST exit with code 0 if already up to date, printing a message indicating no update is needed. +- With `--check`: prints current version and latest available version without downloading. MUST exit with code 0 if up to date, exit with code 1 if a newer version is available. +- MUST detect the current platform target at compile time and select the matching release asset +- MUST display download progress when connected to a TTY +- MUST error with a clear message if the binary lacks write permission to its install location +- MUST error with a clear message if the GitHub API is unreachable or rate-limited +- SHOULD support `GITHUB_TOKEN` environment variable for authenticated API requests to avoid rate limits + +**Rationale:** + +A self-update command provides a single canonical update path that works regardless of how govctl was originally installed (cargo install, cargo binstall, or direct binary download). This meets criterion 3 of RFC-0002:C-GLOBAL-COMMANDS (meta-information about the CLI itself). + +*Since: v0.8.0* + --- ## Changelog +### v0.8.0 (2026-04-13) + +Add self-update global command (ADR-0041) + +#### Added + +- C-SELF-UPDATE clause for govctl self-update command + +#### Changed + +- C-GLOBAL-COMMANDS updated with entry 11 and rationale for self-update + ### v0.7.0 (2026-04-09) Add controlled-vocabulary tags: tags field on RFC/clause/ADR/work/guard, --tag filter on list, govctl tag new/delete/list for registry management (ADR-0040) diff --git a/gov/adr/ADR-0041-self-update-and-cargo-binstall-binary-distribution.toml b/gov/adr/ADR-0041-self-update-and-cargo-binstall-binary-distribution.toml new file mode 100644 index 0000000..3a37f08 --- /dev/null +++ b/gov/adr/ADR-0041-self-update-and-cargo-binstall-binary-distribution.toml @@ -0,0 +1,98 @@ +#:schema ../schema/adr.schema.json + +[govctl] +id = "ADR-0041" +title = "Self-update and cargo-binstall binary distribution" +status = "accepted" +date = "2026-04-13" +refs = [ + "RFC-0002", + "ADR-0018", + "ADR-0033", +] + +[content] +context = """ +govctl is distributed via `cargo install govctl` and as prebuilt binaries on GitHub Releases. The release CI (`.github/workflows/release.yml`) already produces binaries for five platform targets: `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `x86_64-apple-darwin`, `aarch64-apple-darwin`, and `x86_64-pc-windows-msvc`. + +### Problem Statement + +Two gaps exist in the binary distribution story: + +1. **No in-place update.** Users must remember to re-run `cargo install govctl` or manually download from GitHub Releases to get a new version. There is no built-in way to check for or apply updates. + +2. **No `cargo binstall` support.** `cargo-binstall` can install prebuilt binaries from GitHub Releases without compiling from source, but requires `[package.metadata.binstall]` in `Cargo.toml` to locate the correct asset. Without this metadata, `cargo binstall govctl` falls back to a full source build. + +### Constraints + +- [[RFC-0002:C-GLOBAL-COMMANDS]] requires new global commands to meet at least one criterion: (1) multi-resource, (2) project-level init/cleanup, or (3) meta-information about the CLI itself. A self-update command qualifies under criterion 3. +- [[ADR-0018]] established "one canonical way" — the update mechanism should be singular. +- Release assets use the naming convention `govctl-v{version}-{target}.{ext}` (tar.gz for Unix, zip for Windows). +- The project already depends on `reqwest` for HTTP (via other crates), so adding network capability is not a new dependency class.""" +decision = """ +We will **use the `self_update` crate for a built-in `govctl self-update` command and add `[package.metadata.binstall]` to `Cargo.toml` for `cargo-binstall` support**, because: + +1. **Existing infrastructure fits perfectly.** The release CI already produces platform binaries with naming that `self_update` expects (`govctl-v{version}-{target}.{ext}`). No CI changes needed. + +2. **Minimal effort, maximum coverage.** The `self_update` crate handles the hard parts (API queries, platform detection, archive extraction, binary replacement) in ~20 lines. `cargo-binstall` metadata is a 4-line addition to `Cargo.toml`. + +3. **Two complementary install paths, one asset layout.** Users who installed via `cargo binstall` can update via `cargo binstall govctl`. Users who installed via direct download or `govctl self-update` can update in place. Both paths consume the same GitHub Release assets.""" +consequences = """ +### Positive + +- Users can update govctl with a single command (`govctl self-update`) regardless of how it was originally installed +- `cargo binstall govctl` installs prebuilt binaries in seconds instead of compiling from source (~2 min) +- Both update paths share the same GitHub Release assets — no additional CI or hosting required +- Version check (`govctl self-update --check`) enables scripted staleness detection in CI or hooks + +### Negative + +- New runtime dependency on `self_update` crate and its transitive dependencies (mitigation: the crate is well-maintained with 8M+ downloads; feature-flag to compile only the GitHub backend + rustls) +- Binary replacement requires write permission to the install directory (mitigation: clear error message when permission is denied, suggesting `sudo` or ownership fix) +- GitHub API rate limits apply to unauthenticated requests — 60 requests/hour per IP (mitigation: self-update is infrequent; document `GITHUB_TOKEN` env var for authenticated requests if needed) + +### Neutral + +- `cargo install govctl` continues to work unchanged — this adds paths, does not replace existing ones +- Plugin users ([[ADR-0033]]) are unaffected — plugin updates are managed by Claude Code's plugin system""" + +[[content.alternatives]] +text = "self_update crate with GitHub Releases backend: Use the self_update crate (v0.44, ~8M downloads, actively maintained) to query GitHub Releases API, download platform-appropriate binary, and replace the running executable. Pair with cargo-binstall metadata in Cargo.toml so both self-update and cargo-binstall share the same release asset layout." +status = "accepted" +pros = [ + "Minimal code (~20 lines) — the crate handles API queries, platform detection, archive extraction, and binary replacement", + "Actively maintained with broad adoption (8M+ downloads)", + "Reuses existing release CI assets without changes — asset naming already matches", + "cargo-binstall support is additive metadata only, zero code", + "Both update paths share one asset layout, reducing maintenance", +] +cons = ["Adds a runtime dependency (~5 transitive crates for HTTP, archive, self-replace)"] + +[[content.alternatives]] +text = "Manual implementation with reqwest: Implement GitHub Releases API querying, asset download, archive extraction, and binary replacement manually using reqwest and flate2/tar crates." +status = "rejected" +pros = [ + "Full control over behavior and error messages", + "No dependency on third-party update crate", +] +cons = [ + "Significant implementation effort (~200+ lines) for a solved problem", + "Must handle platform detection, archive formats, binary replacement, and edge cases manually", + "Ongoing maintenance burden for update logic", +] +rejection_reason = "The self_update crate already solves this reliably. Reimplementing is unnecessary complexity for marginal control benefit." + +[[content.alternatives]] +text = "Shell out to cargo install or cargo binstall: Instead of a built-in self-update, invoke cargo install govctl or cargo binstall govctl as a subprocess." +status = "rejected" +pros = [ + "Zero new code for the update mechanism itself", + "Leverages existing package manager infrastructure", +] +cons = [ + "Requires Rust toolchain (cargo install) or cargo-binstall installed separately", + "Slow for source builds — full compilation on every update", + "Poor UX — error messages come from external tools, not govctl", + "Cannot work in environments where govctl was installed via direct binary download", +] +rejection_reason = "Depends on external toolchain being present. Users who installed from GitHub Releases would not have cargo available. Per [[ADR-0018]], prefer one canonical path." diff --git a/gov/releases.toml b/gov/releases.toml index 47ad7ba..cde2361 100644 --- a/gov/releases.toml +++ b/gov/releases.toml @@ -2,6 +2,11 @@ [govctl] +[[releases]] +version = "0.8.3" +date = "2026-04-13" +refs = ["WI-2026-04-13-001"] + [[releases]] version = "0.8.2" date = "2026-04-10" diff --git a/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml b/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml index bf624c6..2365e42 100644 --- a/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml +++ b/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml @@ -169,6 +169,17 @@ Artifact-level tagging uses existing resource verbs on taggable types (rfc, clau - `govctl {rfc|clause|adr|work|guard} add tags ` — assign a tag to an artifact - `govctl {rfc|clause|adr|work|guard} remove tags ` — remove a tag from an artifact +**11. `govctl self-update`** + +Updates the govctl binary to the latest release. + +Syntax: `govctl self-update [--check]` + +Behavior: +- Downloads and replaces the running binary from GitHub Releases +- With `--check`: prints version comparison without downloading +- Full specification in [[RFC-0002:C-SELF-UPDATE]] + **Rationale:** These commands are global because they: @@ -185,6 +196,8 @@ These commands are global because they: `govctl tag` qualifies because it manages project-level configuration that applies across all resource types (criterion 1). +`govctl self-update` qualifies because it provides meta-information about the CLI itself and performs binary lifecycle management (criterion 3). + **Future Additions:** New global commands MAY be added via RFC amendment. They MUST meet at least one criterion: diff --git a/gov/rfc/RFC-0002/clauses/C-SELF-UPDATE.toml b/gov/rfc/RFC-0002/clauses/C-SELF-UPDATE.toml new file mode 100644 index 0000000..60d25ac --- /dev/null +++ b/gov/rfc/RFC-0002/clauses/C-SELF-UPDATE.toml @@ -0,0 +1,31 @@ +#:schema ../../../schema/clause.schema.json + +[govctl] +id = "C-SELF-UPDATE" +title = "Self-Update Command" +kind = "normative" +status = "active" +since = "0.8.0" + +[content] +text = """ +**11. `govctl self-update`** + +Updates the govctl binary to the latest release. + +Syntax: `govctl self-update [--check]` + +Behavior: +- Queries the GitHub Releases API for the `govctl-org/govctl` repository to determine the latest published version +- Compares the latest version against the running binary's compiled version +- Without `--check`: downloads the platform-appropriate binary asset, verifies integrity, and replaces the running executable. MUST print the old and new version on success. MUST exit with code 0 if already up to date, printing a message indicating no update is needed. +- With `--check`: prints current version and latest available version without downloading. MUST exit with code 0 if up to date, exit with code 1 if a newer version is available. +- MUST detect the current platform target at compile time and select the matching release asset +- MUST display download progress when connected to a TTY +- MUST error with a clear message if the binary lacks write permission to its install location +- MUST error with a clear message if the GitHub API is unreachable or rate-limited +- SHOULD support `GITHUB_TOKEN` environment variable for authenticated API requests to avoid rate limits + +**Rationale:** + +A self-update command provides a single canonical update path that works regardless of how govctl was originally installed (cargo install, cargo binstall, or direct binary download). This meets criterion 3 of RFC-0002:C-GLOBAL-COMMANDS (meta-information about the CLI itself).""" diff --git a/gov/rfc/RFC-0002/rfc.toml b/gov/rfc/RFC-0002/rfc.toml index 0027e0f..c924075 100644 --- a/gov/rfc/RFC-0002/rfc.toml +++ b/gov/rfc/RFC-0002/rfc.toml @@ -3,13 +3,13 @@ [govctl] id = "RFC-0002" title = "CLI Resource Model and Command Architecture" -version = "0.7.0" +version = "0.8.0" status = "normative" phase = "test" owners = ["@govctl-org"] created = "2026-01-19" -updated = "2026-04-09" -signature = "34e3af95677441443e8931aed179b3bc3f67407276bab9c3857c3295d44e1172" +updated = "2026-04-13" +signature = "ca2304d86d19ee4b4947d84c5c218ab7d8f47e34eb30fd163e55c7c26f2a2399" [[sections]] title = "Summary" @@ -25,8 +25,16 @@ clauses = [ "clauses/C-OUTPUT-FORMAT.toml", "clauses/C-GLOBAL-COMMANDS.toml", "clauses/C-VERIFY-CONFIG.toml", + "clauses/C-SELF-UPDATE.toml", ] +[[changelog]] +version = "0.8.0" +date = "2026-04-13" +notes = "Add self-update global command (ADR-0041)" +added = ["C-SELF-UPDATE clause for govctl self-update command"] +changed = ["C-GLOBAL-COMMANDS updated with entry 11 and rationale for self-update"] + [[changelog]] version = "0.7.0" date = "2026-04-09" diff --git a/gov/work/2026-04-13-implement-self-update-command-and-cargo-binstall-support.toml b/gov/work/2026-04-13-implement-self-update-command-and-cargo-binstall-support.toml new file mode 100644 index 0000000..fb74e02 --- /dev/null +++ b/gov/work/2026-04-13-implement-self-update-command-and-cargo-binstall-support.toml @@ -0,0 +1,51 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-04-13-001" +title = "Implement self-update command and cargo-binstall support" +status = "done" +created = "2026-04-13" +started = "2026-04-13" +completed = "2026-04-13" +refs = [ + "RFC-0002", + "ADR-0041", +] + +[content] +description = "Add govctl self-update command using the self_update crate to download prebuilt binaries from GitHub Releases, and add cargo-binstall metadata to Cargo.toml. Amends RFC-0002 with C-SELF-UPDATE clause and ADR-0041 for the mechanism decision." + +[[content.journal]] +date = "2026-04-13" +scope = "cli" +content = "Implemented self-update command and cargo-binstall metadata; all 192 unit tests + integration tests pass; govctl check passes with 0 warnings" + +[[content.acceptance_criteria]] +text = "Implement govctl self-update command with --check flag" +status = "done" +category = "added" + +[[content.acceptance_criteria]] +text = "Add cargo-binstall metadata to Cargo.toml" +status = "done" +category = "added" + +[[content.acceptance_criteria]] +text = "RFC-0002 amended with C-SELF-UPDATE clause and version bumped" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "ADR-0041 accepted" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "govctl check passes" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "cargo test passes" +status = "done" +category = "chore" diff --git a/src/cli.rs b/src/cli.rs index ca56f68..2b9d11f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -349,6 +349,24 @@ NOTES: shell: clap_complete::Shell, }, + /// Update govctl binary to the latest release + #[command(name = "self-update")] + #[command(after_help = "\ +EXAMPLES: + govctl self-update + govctl self-update --check + +NOTES: + - Downloads the latest binary from GitHub Releases and replaces the current executable. + - Use `--check` to see if an update is available without installing it. + - Implements [[RFC-0002:C-SELF-UPDATE]]. +")] + SelfUpdate { + /// Check for updates without installing + #[arg(long)] + check: bool, + }, + /// Launch interactive TUI dashboard #[cfg(feature = "tui")] Tui, diff --git a/src/cmd/edit/engine.rs b/src/cmd/edit/engine.rs index 548bf86..540e2de 100644 --- a/src/cmd/edit/engine.rs +++ b/src/cmd/edit/engine.rs @@ -378,8 +378,8 @@ mod tests { use super::*; #[test] - fn test_plan_simple_path() { - let plan = plan_request("ADR-0001", Some("title")).unwrap(); + fn test_plan_simple_path() -> Result<(), Box> { + let plan = plan_request("ADR-0001", Some("title"))?; assert_eq!(plan.artifact, ArtifactType::Adr); assert_eq!( plan.field_path.as_ref().and_then(FieldPath::as_simple), @@ -400,61 +400,87 @@ mod tests { status_list: false, }) ); + Ok(()) } #[test] - fn test_plan_nested_path() { - let plan = plan_request("ADR-0001", Some("alt[0].pro[1]")).unwrap(); - let fp = plan.field_path.as_ref().expect("nested field should exist"); + fn test_plan_nested_path() -> Result<(), Box> { + let plan = plan_request("ADR-0001", Some("alt[0].pro[1]"))?; + let fp = plan + .field_path + .as_ref() + .ok_or("nested field should exist")?; assert_eq!(fp.segments[0].name, "alternatives"); assert_eq!(fp.segments[1].name, "pros"); assert_eq!(plan.verb, None); + Ok(()) } #[test] - fn test_plan_without_field() { - let plan = plan_request("ADR-0001", None).unwrap(); + fn test_plan_without_field() -> Result<(), Box> { + let plan = plan_request("ADR-0001", None)?; assert_eq!(plan.artifact, ArtifactType::Adr); assert!(plan.field_path.is_none()); assert_eq!(plan.verb, None); assert_eq!(plan.target, None); + Ok(()) } #[test] fn test_plan_unknown_artifact_fails() { - let err = plan_request("UNKNOWN", Some("title")).unwrap_err(); - assert!(err.to_string().contains("Unknown artifact type")); + let err = plan_request("UNKNOWN", Some("title")); + assert!(err.is_err()); + assert!( + err.err() + .map(|e| e.to_string()) + .unwrap_or_default() + .contains("Unknown artifact type") + ); } #[test] fn test_scope_aware_alias_only_applies_when_valid_for_artifact() { - let err = plan_request("ADR-0001", Some("desc")).unwrap_err(); - assert!(err.to_string().contains("Unknown ADR field")); + let err = plan_request("ADR-0001", Some("desc")); + assert!(err.is_err()); + assert!( + err.err() + .map(|e| e.to_string()) + .unwrap_or_default() + .contains("Unknown ADR field") + ); } #[test] - fn test_scope_aware_alias_keeps_work_short_name() { - let plan = plan_request("WI-2026-01-01-001", Some("desc")).unwrap(); - let fp = plan.field_path.expect("field path should exist"); + fn test_scope_aware_alias_keeps_work_short_name() -> Result<(), Box> { + let plan = plan_request("WI-2026-01-01-001", Some("desc"))?; + let fp = plan.field_path.ok_or("field path should exist")?; assert_eq!(fp.as_simple(), Some("description")); + Ok(()) } #[test] - fn test_scope_aware_alias_under_legacy_prefix() { - let plan = plan_request("WI-2026-01-01-001", Some("content.desc")).unwrap(); - let fp = plan.field_path.expect("field path should exist"); + fn test_scope_aware_alias_under_legacy_prefix() -> Result<(), Box> { + let plan = plan_request("WI-2026-01-01-001", Some("content.desc"))?; + let fp = plan.field_path.ok_or("field path should exist")?; assert_eq!(fp.as_simple(), Some("description")); + Ok(()) } #[test] fn test_unknown_alias_in_scope_is_not_rewritten() { - let err = plan_request("WI-2026-01-01-001", Some("alt[0].pro[0]")).unwrap_err(); - assert!(err.to_string().contains("Unknown work item field")); + let err = plan_request("WI-2026-01-01-001", Some("alt[0].pro[0]")); + assert!(err.is_err()); + assert!( + err.err() + .map(|e| e.to_string()) + .unwrap_or_default() + .contains("Unknown work item field") + ); } #[test] - fn test_plan_mutation_request_records_verb() { - let plan = plan_mutation_request("ADR-0001", "content.decision", Verb::Set).unwrap(); + fn test_plan_mutation_request_records_verb() -> Result<(), Box> { + let plan = plan_mutation_request("ADR-0001", "content.decision", Verb::Set)?; assert_eq!(plan.verb, Some(Verb::Set)); assert_eq!( plan.field_path @@ -475,11 +501,13 @@ mod tests { status_list: false, }) ); + Ok(()) } #[test] - fn test_plan_mutation_request_classifies_nested_root_item_target() { - let plan = plan_mutation_request("ADR-0001", "alternatives[0]", Verb::Remove).unwrap(); + fn test_plan_mutation_request_classifies_nested_root_item_target() + -> Result<(), Box> { + let plan = plan_mutation_request("ADR-0001", "alternatives[0]", Verb::Remove)?; assert_eq!( plan.target, Some(ResolvedTarget::IndexedItem { @@ -501,12 +529,13 @@ mod tests { status_list: true, }) ); + Ok(()) } #[test] - fn test_plan_mutation_request_classifies_nested_list_item_target() { - let plan = - plan_mutation_request("ADR-0001", "alternatives[0].pros[1]", Verb::Remove).unwrap(); + fn test_plan_mutation_request_classifies_nested_list_item_target() + -> Result<(), Box> { + let plan = plan_mutation_request("ADR-0001", "alternatives[0].pros[1]", Verb::Remove)?; assert_eq!( plan.target, Some(ResolvedTarget::IndexedItem { @@ -540,5 +569,6 @@ mod tests { status_list: false, }) ); + Ok(()) } } diff --git a/src/cmd/edit/mod.rs b/src/cmd/edit/mod.rs index e73b34f..256283a 100644 --- a/src/cmd/edit/mod.rs +++ b/src/cmd/edit/mod.rs @@ -411,10 +411,13 @@ pub(crate) fn set_field_direct( op: WriteOp, ) -> anyhow::Result<()> { let plan = plan_edit_with_field_for_verb(id, field, Some(edit_rules::Verb::Set))?; - let target = plan - .target - .as_ref() - .expect("mutation planning should produce target"); + let target = plan.target.as_ref().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "mutation planning should produce target", + id, + ) + })?; apply_set_field(config, id, target, plan.artifact, value, op, false) } @@ -604,10 +607,13 @@ pub fn edit_field( let value = resolve_owned_value(value.as_ref(), *stdin)?; let plan = plan_edit_with_field_for_verb(id, path, Some(edit_rules::Verb::Set))?; let artifact = plan.artifact; - let target = plan - .target - .as_ref() - .expect("mutation planning should produce target"); + let target = plan.target.as_ref().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "mutation planning should produce target", + id, + ) + })?; apply_set_field(config, id, target, artifact, value.as_str(), op, true)?; if !op.is_preview() { ui::field_set(id, &target.display_path(), value.as_str()); @@ -688,7 +694,13 @@ where .. } => match origin { edit_engine::TargetOrigin::Simple => { - let simple = path.as_simple().expect("simple target path expected"); + let simple = path.as_simple().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "simple target path expected", + id, + ) + })?; if allow_forced_simple_set { edit_runtime::set_simple_field_forced(artifact, &mut doc, simple, value, id)?; } else { @@ -707,9 +719,13 @@ where .. } => match origin { edit_engine::TargetOrigin::Simple => { - let simple = container_path - .as_simple() - .expect("simple indexed container expected"); + let simple = container_path.as_simple().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "simple indexed container expected", + id, + ) + })?; edit_runtime::set_simple_list_item(artifact, &mut doc, simple, *index, value, id)?; } edit_engine::TargetOrigin::Nested => { @@ -802,9 +818,13 @@ fn render_resolved_target( path, .. } => { - let simple = path - .as_simple() - .expect("simple node target should use a simple path"); + let simple = path.as_simple().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "simple node target should use a simple path", + id, + ) + })?; edit_runtime::get_simple_field(artifact, doc, simple, id) } edit_engine::ResolvedTarget::IndexedItem { @@ -813,9 +833,13 @@ fn render_resolved_target( index, .. } => { - let simple = container_path - .as_simple() - .expect("simple indexed target should use a simple container path"); + let simple = container_path.as_simple().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "simple indexed target should use a simple container path", + id, + ) + })?; edit_runtime::get_simple_list_item(artifact, doc, simple, *index, id) } edit_engine::ResolvedTarget::Node { @@ -885,9 +909,13 @@ fn set_rfc_field( item_kind: edit_engine::TargetKind::Scalar, .. } => { - let simple = container_path - .as_simple() - .expect("simple indexed container expected"); + let simple = container_path.as_simple().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "simple indexed container expected", + id, + ) + })?; edit_runtime::set_simple_list_item( ArtifactType::Rfc, &mut doc, @@ -967,9 +995,13 @@ fn set_clause_field( item_kind: edit_engine::TargetKind::Scalar, .. } => { - let simple = container_path - .as_simple() - .expect("simple indexed container expected"); + let simple = container_path.as_simple().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "simple indexed container expected", + id, + ) + })?; edit_runtime::set_simple_list_item( ArtifactType::Clause, &mut doc, @@ -1100,17 +1132,33 @@ pub fn add_to_field( ) -> anyhow::Result> { let plan = plan_edit_with_field_for_verb(id, field, Some(edit_rules::Verb::Add))?; let artifact = plan.artifact; - let fp = plan.field_path.as_ref().expect("validated above"); - let target = plan - .target - .as_ref() - .expect("mutation planning should produce target"); + let fp = plan.field_path.as_ref().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "validated above: field path must be present", + id, + ) + })?; + let target = plan.target.as_ref().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "mutation planning should produce target", + id, + ) + })?; let value = resolve_owned_value(value, stdin)?; let value = value.as_str(); // Validate tags against controlled vocabulary at add time — [[RFC-0002:C-RESOURCES]] if fp.as_simple() == Some("tags") { - if !crate::cmd::tag::TAG_RE.is_match(value) { + let tag_re = crate::cmd::tag::tag_re().map_err(|e| { + Diagnostic::new( + DiagnosticCode::E0806InvalidPattern, + format!("Failed to compile tag regex: {e}"), + id, + ) + })?; + if !tag_re.is_match(value) { return Err(Diagnostic::new( DiagnosticCode::E1101TagInvalidFormat, format!("Invalid tag format '{value}': must match ^[a-z][a-z0-9-]*$"), @@ -1294,7 +1342,13 @@ fn add_to_target_doc( match origin { edit_engine::TargetOrigin::Simple => { - let simple = path.as_simple().expect("simple list target expected"); + let simple = path.as_simple().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "simple list target expected", + id, + ) + })?; if !edit_runtime::add_simple_list_value(artifact, doc, simple, value, id)? { return Err(cannot_add_to_field_error(id, simple)); } @@ -1330,7 +1384,13 @@ fn remove_target_from_doc( .. } => match origin { edit_engine::TargetOrigin::Simple => { - let simple = path.as_simple().expect("simple list target expected"); + let simple = path.as_simple().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "simple list target expected", + id, + ) + })?; let removed = remove_simple_values_from_doc(artifact, doc, simple, id, opts)? .ok_or_else(|| cannot_remove_from_field_error(id, simple))?; Ok((simple.to_string(), removed)) @@ -1351,9 +1411,13 @@ fn remove_target_from_doc( .. } => match origin { edit_engine::TargetOrigin::Simple => { - let simple = container_path - .as_simple() - .expect("simple indexed container expected"); + let simple = container_path.as_simple().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "simple indexed container expected", + id, + ) + })?; let exact = MatchOptions { pattern: None, at: Some(*index), @@ -1409,7 +1473,13 @@ fn tick_target_in_doc( } match origin { edit_engine::TargetOrigin::Simple => { - let simple = path.as_simple().expect("simple list target expected"); + let simple = path.as_simple().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "simple list target expected", + id, + ) + })?; edit_runtime::tick_simple_status_list_item_with_matcher( artifact, doc, @@ -1468,9 +1538,13 @@ fn tick_target_in_doc( }; match origin { edit_engine::TargetOrigin::Simple => { - let simple = container_path - .as_simple() - .expect("simple indexed container expected"); + let simple = container_path.as_simple().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "simple indexed container expected", + id, + ) + })?; edit_runtime::tick_simple_status_list_item_with_matcher( artifact, doc, @@ -1528,10 +1602,13 @@ pub fn remove_from_field( ) -> anyhow::Result> { let plan = plan_edit_with_field_for_verb(id, field, Some(edit_rules::Verb::Remove))?; let artifact = plan.artifact; - let target = plan - .target - .as_ref() - .expect("mutation planning should produce target"); + let target = plan.target.as_ref().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "mutation planning should produce target", + id, + ) + })?; reject_match_flags_for_indexed_target(id, target, opts)?; match artifact { @@ -1587,10 +1664,13 @@ pub fn tick_item( ) -> anyhow::Result> { let plan = plan_edit_with_field_for_verb(id, field, Some(edit_rules::Verb::Tick))?; let artifact = plan.artifact; - let target = plan - .target - .as_ref() - .expect("mutation planning should produce target"); + let target = plan.target.as_ref().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "mutation planning should produce target", + id, + ) + })?; reject_match_flags_for_indexed_target(id, target, opts)?; let status_str = match (artifact, status) { @@ -1981,21 +2061,36 @@ mod tests { let len = items.len() as i32; let resolved = if idx < 0 { len + idx } else { idx }; if resolved < 0 || resolved >= len { - return Err(anyhow::anyhow!( - "Index {} out of range (array has {} items)", - idx, - items.len() - )); + return Err(Diagnostic::new( + DiagnosticCode::E0816PathIndexOutOfBounds, + format!( + "Index {} out of range (array has {} items)", + idx, + items.len() + ), + "", + ) + .into()); } return Ok(MatchResult::Single(resolved as usize)); } - let pattern = opts - .pattern - .ok_or_else(|| anyhow::anyhow!("Pattern or --at is required"))?; + let pattern = opts.pattern.ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0801MissingRequiredArg, + "Pattern or --at is required", + "", + ) + })?; let indices = if opts.regex { - let re = Regex::new(pattern).map_err(|e| anyhow::anyhow!("Invalid regex: {e}"))?; + let re = Regex::new(pattern).map_err(|e| { + Diagnostic::new( + DiagnosticCode::E0806InvalidPattern, + format!("Invalid regex: {e}"), + "", + ) + })?; items .iter() .enumerate() @@ -2090,55 +2185,59 @@ mod tests { // ========================================================================= #[test] - fn test_find_matches_substring_single() { + fn test_find_matches_substring_single() -> Result<(), Box> { let items = vec!["apple", "banana", "cherry"]; let opts = MatchOptions { pattern: Some("nan"), ..Default::default() }; - match find_matches(&items, &opts).unwrap() { + match find_matches(&items, &opts)? { MatchResult::Single(idx) => assert_eq!(idx, 1), - _ => panic!("Expected single match"), + _ => return Err("Expected single match".into()), } + Ok(()) } #[test] - fn test_find_matches_substring_case_insensitive() { + fn test_find_matches_substring_case_insensitive() -> Result<(), Box> { let items = vec!["Apple", "BANANA", "Cherry"]; let opts = MatchOptions { pattern: Some("banana"), ..Default::default() }; - match find_matches(&items, &opts).unwrap() { + match find_matches(&items, &opts)? { MatchResult::Single(idx) => assert_eq!(idx, 1), - _ => panic!("Expected single match"), + _ => return Err("Expected single match".into()), } + Ok(()) } #[test] - fn test_find_matches_substring_multiple() { + fn test_find_matches_substring_multiple() -> Result<(), Box> { let items = vec!["test-one", "test-two", "other"]; let opts = MatchOptions { pattern: Some("test"), ..Default::default() }; - match find_matches(&items, &opts).unwrap() { + match find_matches(&items, &opts)? { MatchResult::Multiple(indices) => assert_eq!(indices, vec![0, 1]), - _ => panic!("Expected multiple matches"), + _ => return Err("Expected multiple matches".into()), } + Ok(()) } #[test] - fn test_find_matches_substring_none() { + fn test_find_matches_substring_none() -> Result<(), Box> { let items = vec!["apple", "banana", "cherry"]; let opts = MatchOptions { pattern: Some("xyz"), ..Default::default() }; - match find_matches(&items, &opts).unwrap() { + match find_matches(&items, &opts)? { MatchResult::None => {} - _ => panic!("Expected no match"), + _ => return Err("Expected no match".into()), } + Ok(()) } // ========================================================================= @@ -2146,45 +2245,48 @@ mod tests { // ========================================================================= #[test] - fn test_find_matches_exact_match() { + fn test_find_matches_exact_match() -> Result<(), Box> { let items = vec!["test", "testing", "test"]; let opts = MatchOptions { pattern: Some("test"), exact: true, ..Default::default() }; - match find_matches(&items, &opts).unwrap() { + match find_matches(&items, &opts)? { MatchResult::Multiple(indices) => assert_eq!(indices, vec![0, 2]), - _ => panic!("Expected multiple matches"), + _ => return Err("Expected multiple matches".into()), } + Ok(()) } #[test] - fn test_find_matches_exact_case_sensitive() { + fn test_find_matches_exact_case_sensitive() -> Result<(), Box> { let items = vec!["Test", "test", "TEST"]; let opts = MatchOptions { pattern: Some("test"), exact: true, ..Default::default() }; - match find_matches(&items, &opts).unwrap() { + match find_matches(&items, &opts)? { MatchResult::Single(idx) => assert_eq!(idx, 1), - _ => panic!("Expected single match"), + _ => return Err("Expected single match".into()), } + Ok(()) } #[test] - fn test_find_matches_exact_no_match() { + fn test_find_matches_exact_no_match() -> Result<(), Box> { let items = vec!["testing", "tested"]; let opts = MatchOptions { pattern: Some("test"), exact: true, ..Default::default() }; - match find_matches(&items, &opts).unwrap() { + match find_matches(&items, &opts)? { MatchResult::None => {} - _ => panic!("Expected no match"), + _ => return Err("Expected no match".into()), } + Ok(()) } // ========================================================================= @@ -2192,55 +2294,59 @@ mod tests { // ========================================================================= #[test] - fn test_find_matches_at_positive() { + fn test_find_matches_at_positive() -> Result<(), Box> { let items = vec!["a", "b", "c"]; let opts = MatchOptions { at: Some(1), ..Default::default() }; - match find_matches(&items, &opts).unwrap() { + match find_matches(&items, &opts)? { MatchResult::Single(idx) => assert_eq!(idx, 1), - _ => panic!("Expected single match"), + _ => return Err("Expected single match".into()), } + Ok(()) } #[test] - fn test_find_matches_at_zero() { + fn test_find_matches_at_zero() -> Result<(), Box> { let items = vec!["first", "second"]; let opts = MatchOptions { at: Some(0), ..Default::default() }; - match find_matches(&items, &opts).unwrap() { + match find_matches(&items, &opts)? { MatchResult::Single(idx) => assert_eq!(idx, 0), - _ => panic!("Expected single match"), + _ => return Err("Expected single match".into()), } + Ok(()) } #[test] - fn test_find_matches_at_negative() { + fn test_find_matches_at_negative() -> Result<(), Box> { let items = vec!["a", "b", "c"]; let opts = MatchOptions { at: Some(-1), ..Default::default() }; - match find_matches(&items, &opts).unwrap() { + match find_matches(&items, &opts)? { MatchResult::Single(idx) => assert_eq!(idx, 2), // last item - _ => panic!("Expected single match"), + _ => return Err("Expected single match".into()), } + Ok(()) } #[test] - fn test_find_matches_at_negative_two() { + fn test_find_matches_at_negative_two() -> Result<(), Box> { let items = vec!["a", "b", "c", "d"]; let opts = MatchOptions { at: Some(-2), ..Default::default() }; - match find_matches(&items, &opts).unwrap() { + match find_matches(&items, &opts)? { MatchResult::Single(idx) => assert_eq!(idx, 2), // second to last - _ => panic!("Expected single match"), + _ => return Err("Expected single match".into()), } + Ok(()) } #[test] @@ -2268,31 +2374,33 @@ mod tests { // ========================================================================= #[test] - fn test_find_matches_regex_single() { + fn test_find_matches_regex_single() -> Result<(), Box> { let items = vec!["RFC-0001", "ADR-0001", "WI-001"]; let opts = MatchOptions { pattern: Some("RFC-.*"), regex: true, ..Default::default() }; - match find_matches(&items, &opts).unwrap() { + match find_matches(&items, &opts)? { MatchResult::Single(idx) => assert_eq!(idx, 0), - _ => panic!("Expected single match"), + _ => return Err("Expected single match".into()), } + Ok(()) } #[test] - fn test_find_matches_regex_multiple() { + fn test_find_matches_regex_multiple() -> Result<(), Box> { let items = vec!["test-1", "test-2", "other"]; let opts = MatchOptions { pattern: Some("test-\\d+"), regex: true, ..Default::default() }; - match find_matches(&items, &opts).unwrap() { + match find_matches(&items, &opts)? { MatchResult::Multiple(indices) => assert_eq!(indices, vec![0, 1]), - _ => panic!("Expected multiple matches"), + _ => return Err("Expected multiple matches".into()), } + Ok(()) } #[test] diff --git a/src/cmd/edit/path.rs b/src/cmd/edit/path.rs index 470f97d..5de21eb 100644 --- a/src/cmd/edit/path.rs +++ b/src/cmd/edit/path.rs @@ -206,28 +206,30 @@ mod tests { // ========================================================================= #[test] - fn test_simple_field() { - let p = parse_field_path("title").unwrap(); + fn test_simple_field() -> Result<(), Box> { + let p = parse_field_path("title")?; assert_eq!(p.segments.len(), 1); assert_eq!(p.segments[0].name, "title"); assert_eq!(p.segments[0].index, None); assert!(p.is_simple()); assert_eq!(p.as_simple(), Some("title")); + Ok(()) } #[test] - fn test_indexed_field() { - let p = parse_field_path("alternatives[0]").unwrap(); + fn test_indexed_field() -> Result<(), Box> { + let p = parse_field_path("alternatives[0]")?; assert_eq!(p.segments.len(), 1); assert_eq!(p.segments[0].name, "alternatives"); assert_eq!(p.segments[0].index, Some(0)); assert!(!p.is_simple()); assert!(p.has_terminal_index()); + Ok(()) } #[test] - fn test_dotted_path() { - let p = parse_field_path("alt[0].pros").unwrap(); + fn test_dotted_path() -> Result<(), Box> { + let p = parse_field_path("alt[0].pros")?; assert_eq!(p.segments.len(), 2); assert_eq!(p.segments[0].name, "alternatives"); assert_eq!(p.segments[0].index, Some(0)); @@ -235,23 +237,26 @@ mod tests { assert_eq!(p.segments[1].index, None); assert!(!p.is_simple()); assert!(!p.has_terminal_index()); + Ok(()) } #[test] - fn test_dotted_path_with_terminal_index() { - let p = parse_field_path("alt[0].pros[1]").unwrap(); + fn test_dotted_path_with_terminal_index() -> Result<(), Box> { + let p = parse_field_path("alt[0].pros[1]")?; assert_eq!(p.segments.len(), 2); assert_eq!(p.segments[0].name, "alternatives"); assert_eq!(p.segments[0].index, Some(0)); assert_eq!(p.segments[1].name, "pros"); assert_eq!(p.segments[1].index, Some(1)); assert!(p.has_terminal_index()); + Ok(()) } #[test] - fn test_negative_index() { - let p = parse_field_path("alt[-1]").unwrap(); + fn test_negative_index() -> Result<(), Box> { + let p = parse_field_path("alt[-1]")?; assert_eq!(p.segments[0].index, Some(-1)); + Ok(()) } // ========================================================================= @@ -259,41 +264,47 @@ mod tests { // ========================================================================= #[test] - fn test_alias_alt() { - let p = parse_field_path("alt[0]").unwrap(); + fn test_alias_alt() -> Result<(), Box> { + let p = parse_field_path("alt[0]")?; assert_eq!(p.segments[0].name, "alternatives"); + Ok(()) } #[test] - fn test_raw_parse_keeps_alias_token() { - let p = parse_raw_field_path("alt[0]").unwrap(); + fn test_raw_parse_keeps_alias_token() -> Result<(), Box> { + let p = parse_raw_field_path("alt[0]")?; assert_eq!(p.segments[0].name, "alt"); + Ok(()) } #[test] - fn test_alias_ac() { - let p = parse_field_path("ac[0]").unwrap(); + fn test_alias_ac() -> Result<(), Box> { + let p = parse_field_path("ac[0]")?; assert_eq!(p.segments[0].name, "acceptance_criteria"); + Ok(()) } #[test] - fn test_alias_pro_con() { - let p = parse_field_path("alt[0].pro[0]").unwrap(); + fn test_alias_pro_con() -> Result<(), Box> { + let p = parse_field_path("alt[0].pro[0]")?; assert_eq!(p.segments[1].name, "pros"); - let p = parse_field_path("alt[0].con[0]").unwrap(); + let p = parse_field_path("alt[0].con[0]")?; assert_eq!(p.segments[1].name, "cons"); + Ok(()) } #[test] - fn test_alias_reason() { - let p = parse_field_path("alt[0].reason").unwrap(); + fn test_alias_reason() -> Result<(), Box> { + let p = parse_field_path("alt[0].reason")?; assert_eq!(p.segments[1].name, "rejection_reason"); + Ok(()) } #[test] - fn test_alias_desc() { - let p = parse_field_path("desc").unwrap(); + fn test_alias_desc() -> Result<(), Box> { + let p = parse_field_path("desc")?; assert_eq!(p.segments[0].name, "description"); + Ok(()) } // ========================================================================= @@ -301,58 +312,52 @@ mod tests { // ========================================================================= #[test] - fn test_collapse_content_decision() { - let p = parse_field_path("content.decision") - .unwrap() - .collapse_legacy_prefixes(); + fn test_collapse_content_decision() -> Result<(), Box> { + let p = parse_field_path("content.decision")?.collapse_legacy_prefixes(); assert!(p.is_simple()); assert_eq!(p.as_simple(), Some("decision")); + Ok(()) } #[test] - fn test_collapse_govctl_status() { - let p = parse_field_path("govctl.status") - .unwrap() - .collapse_legacy_prefixes(); + fn test_collapse_govctl_status() -> Result<(), Box> { + let p = parse_field_path("govctl.status")?.collapse_legacy_prefixes(); assert!(p.is_simple()); assert_eq!(p.as_simple(), Some("status")); + Ok(()) } #[test] - fn test_no_collapse_when_indexed() { + fn test_no_collapse_when_indexed() -> Result<(), Box> { // content[0].decision should NOT collapse — content has index - let p = parse_field_path("content[0].decision") - .unwrap() - .collapse_legacy_prefixes(); + let p = parse_field_path("content[0].decision")?.collapse_legacy_prefixes(); assert_eq!(p.segments.len(), 2); + Ok(()) } #[test] - fn test_no_collapse_non_legacy_prefix() { - let p = parse_field_path("alt[0].pros") - .unwrap() - .collapse_legacy_prefixes(); + fn test_no_collapse_non_legacy_prefix() -> Result<(), Box> { + let p = parse_field_path("alt[0].pros")?.collapse_legacy_prefixes(); assert_eq!(p.segments.len(), 2); + Ok(()) } #[test] - fn test_no_collapse_unknown_legacy_field() { - let p = parse_field_path("content.unknown") - .unwrap() - .collapse_legacy_prefixes(); + fn test_no_collapse_unknown_legacy_field() -> Result<(), Box> { + let p = parse_field_path("content.unknown")?.collapse_legacy_prefixes(); assert_eq!(p.segments.len(), 2); assert_eq!(p.segments[0].name, "content"); + Ok(()) } #[test] - fn test_collapse_legacy_prefix_for_deeper_path() { - let p = parse_field_path("content.alternatives[0].pros") - .unwrap() - .collapse_legacy_prefixes(); + fn test_collapse_legacy_prefix_for_deeper_path() -> Result<(), Box> { + let p = parse_field_path("content.alternatives[0].pros")?.collapse_legacy_prefixes(); assert_eq!(p.segments.len(), 2); assert_eq!(p.segments[0].name, "alternatives"); assert_eq!(p.segments[0].index, Some(0)); assert_eq!(p.segments[1].name, "pros"); + Ok(()) } // ========================================================================= @@ -401,19 +406,22 @@ mod tests { // ========================================================================= #[test] - fn test_resolve_index_zero() { - assert_eq!(resolve_index(0, 3).unwrap(), 0); + fn test_resolve_index_zero() -> Result<(), Box> { + assert_eq!(resolve_index(0, 3)?, 0); + Ok(()) } #[test] - fn test_resolve_index_positive() { - assert_eq!(resolve_index(2, 5).unwrap(), 2); + fn test_resolve_index_positive() -> Result<(), Box> { + assert_eq!(resolve_index(2, 5)?, 2); + Ok(()) } #[test] - fn test_resolve_index_negative() { - assert_eq!(resolve_index(-1, 3).unwrap(), 2); - assert_eq!(resolve_index(-3, 3).unwrap(), 0); + fn test_resolve_index_negative() -> Result<(), Box> { + assert_eq!(resolve_index(-1, 3)?, 2); + assert_eq!(resolve_index(-3, 3)?, 0); + Ok(()) } #[test] diff --git a/src/cmd/edit/rules.rs b/src/cmd/edit/rules.rs index 95b8065..7b904a3 100644 --- a/src/cmd/edit/rules.rs +++ b/src/cmd/edit/rules.rs @@ -192,10 +192,11 @@ mod tests { } #[test] - fn test_nested_rule_lookup() { - let rule = nested_root_rule("adr", "alternatives").expect("rule should exist"); + fn test_nested_rule_lookup() -> Result<(), Box> { + let rule = nested_root_rule("adr", "alternatives").ok_or("rule should exist")?; assert_eq!(rule.node.kind, NestedNodeKind::List); assert_eq!(EDIT_RULES_VERSION, 2); + Ok(()) } #[test] @@ -215,11 +216,12 @@ mod tests { } #[test] - fn test_nested_object_root_lookup() { - let rule = nested_root_rule("guard", "check").expect("rule should exist"); + fn test_nested_object_root_lookup() -> Result<(), Box> { + let rule = nested_root_rule("guard", "check").ok_or("rule should exist")?; assert_eq!(rule.node.kind, NestedNodeKind::Object); - let child = nested_field_rule("guard", "check", "timeout_secs").expect("child exists"); + let child = nested_field_rule("guard", "check", "timeout_secs").ok_or("child exists")?; assert_eq!(child.node.kind, NestedNodeKind::Scalar); + Ok(()) } #[test] diff --git a/src/cmd/edit/runtime.rs b/src/cmd/edit/runtime.rs index e83b457..5518e5c 100644 --- a/src/cmd/edit/runtime.rs +++ b/src/cmd/edit/runtime.rs @@ -624,7 +624,13 @@ fn ensure_value_path_mut<'a>( }, ); } - cur = obj.get_mut(*key).expect("inserted above"); + cur = obj.get_mut(*key).ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0817PathTypeMismatch, + format!("Cannot resolve field path '{}'", path.join(".")), + id, + ) + })?; } Ok(cur) } @@ -653,7 +659,13 @@ fn ensure_array_path_mut<'a>( }, ); } - cur = obj.get_mut(*key).expect("inserted above"); + cur = obj.get_mut(*key).ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0817PathTypeMismatch, + format!("Cannot resolve field path '{}'", path.join(".")), + id, + ) + })?; } Ok(cur) } @@ -1202,7 +1214,13 @@ fn ensure_node_path_mut<'a>( }, ); } - cur = obj.get_mut(*key).expect("inserted above"); + cur = obj.get_mut(*key).ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0817PathTypeMismatch, + format!("Cannot resolve field path '{}'", path.join(".")), + id, + ) + })?; } Ok(cur) } @@ -1362,14 +1380,13 @@ mod tests { use super::*; use serde_json::json; - fn path(input: &str) -> FieldPath { - path::parse_field_path(input) - .expect("valid path") - .collapse_legacy_prefixes() + fn path(input: &str) -> Result> { + Ok(path::parse_field_path(input)?.collapse_legacy_prefixes()) } #[test] - fn test_add_nested_object_list_value_deduplicates_by_text() { + fn test_add_nested_object_list_value_deduplicates_by_text() + -> Result<(), Box> { let mut doc = json!({ "content": { "alternatives": [ @@ -1381,27 +1398,29 @@ mod tests { add_nested_list_value( ArtifactType::Adr, &mut doc, - &path("alternatives"), + &path("alternatives")?, "Option A", "ADR-0001", - ) - .unwrap(); + )?; add_nested_list_value( ArtifactType::Adr, &mut doc, - &path("alternatives"), + &path("alternatives")?, "Option B", "ADR-0001", - ) - .unwrap(); + )?; - let alternatives = doc["content"]["alternatives"].as_array().unwrap(); + let alternatives = doc["content"]["alternatives"] + .as_array() + .ok_or("expected array")?; assert_eq!(alternatives.len(), 2); assert_eq!(alternatives[1]["text"], "Option B"); + Ok(()) } #[test] - fn test_set_nested_field_rejects_list_path_without_index() { + fn test_set_nested_field_rejects_list_path_without_index() + -> Result<(), Box> { let mut doc = json!({ "content": { "alternatives": [ @@ -1410,21 +1429,25 @@ mod tests { } }); - let err = set_nested_field( + let result = set_nested_field( ArtifactType::Adr, &mut doc, - &path("alternatives[0].pros"), + &path("alternatives[0].pros")?, "oops", "ADR-0001", - ) - .expect_err("list path should reject set"); - - let diag = err.downcast_ref::().expect("diagnostic"); + ); + assert!(result.is_err()); + let err = result.err().ok_or("expected Err")?; + let diag = err + .downcast_ref::() + .ok_or("expected Diagnostic")?; assert_eq!(diag.code, DiagnosticCode::E0817PathTypeMismatch); + Ok(()) } #[test] - fn test_get_nested_field_renders_object_item_with_scalar_lists() { + fn test_get_nested_field_renders_object_item_with_scalar_lists() + -> Result<(), Box> { let doc = json!({ "content": { "alternatives": [ @@ -1442,14 +1465,14 @@ mod tests { let rendered = get_nested_field( ArtifactType::Adr, &doc, - &path("alternatives[0]"), + &path("alternatives[0]")?, "ADR-0001", - ) - .unwrap(); + )?; assert!(rendered.contains("text: Option A")); assert!(rendered.contains("status: accepted")); assert!(rendered.contains("pros: Readable, Simple")); assert!(rendered.contains("cons: More maintenance")); + Ok(()) } } diff --git a/src/cmd/lifecycle.rs b/src/cmd/lifecycle.rs index 99fb288..4fc9747 100644 --- a/src/cmd/lifecycle.rs +++ b/src/cmd/lifecycle.rs @@ -89,7 +89,16 @@ fn fill_pending_clause_versions( version: &str, op: WriteOp, ) -> anyhow::Result<()> { - let clauses_dir = rfc_path.parent().unwrap().join("clauses"); + let clauses_dir = rfc_path + .parent() + .ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "RFC path has no parent directory", + rfc_path.display().to_string(), + ) + })? + .join("clauses"); if !clauses_dir.exists() { return Ok(()); } diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 95ff078..1b5d45f 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -11,6 +11,7 @@ pub mod migrate; pub mod move_; pub mod new; pub mod render; +pub mod self_update; pub mod status; pub mod tag; pub mod verify; diff --git a/src/cmd/self_update.rs b/src/cmd/self_update.rs new file mode 100644 index 0000000..8aabb19 --- /dev/null +++ b/src/cmd/self_update.rs @@ -0,0 +1,194 @@ +//! Self-update command: download and replace the govctl binary from GitHub Releases. +//! +//! Implements [[RFC-0002:C-SELF-UPDATE]]. + +use std::io::IsTerminal; + +use crate::diagnostic::{Diagnostic, DiagnosticCode}; +use crate::ui; + +const REPO_OWNER: &str = "govctl-org"; +const REPO_NAME: &str = "govctl"; + +/// Result of comparing the current version against the latest available. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum VersionCheck { + /// Current version is up to date (latest <= current). + UpToDate, + /// A newer version is available. + UpdateAvailable { current: String, latest: String }, +} + +/// Compare two semver version strings. Returns whether an update is available. +pub(crate) fn compare_versions(current: &str, latest_raw: &str) -> anyhow::Result { + let latest = latest_raw.trim_start_matches('v'); + + let current_semver = semver::Version::parse(current).map_err(|e| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + format!("failed to parse current version '{current}': {e}"), + "", + ) + })?; + let latest_semver = semver::Version::parse(latest).map_err(|e| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + format!("failed to parse latest version '{latest}': {e}"), + "", + ) + })?; + + if latest_semver <= current_semver { + Ok(VersionCheck::UpToDate) + } else { + Ok(VersionCheck::UpdateAvailable { + current: current.to_string(), + latest: latest.to_string(), + }) + } +} + +/// Check for the latest version and optionally update the binary. +pub fn self_update(check_only: bool) -> anyhow::Result> { + let current = env!("CARGO_PKG_VERSION"); + + if check_only { + check_version(current) + } else { + perform_update(current) + } +} + +fn check_version(current: &str) -> anyhow::Result> { + let releases = self_update::backends::github::ReleaseList::configure() + .repo_owner(REPO_OWNER) + .repo_name(REPO_NAME) + .build()? + .fetch()?; + + let latest = releases.first().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0901IoError, + "no releases found on GitHub", + "", + ) + })?; + + match compare_versions(current, &latest.version)? { + VersionCheck::UpToDate => { + ui::success(format!("govctl v{current} is up to date")); + Ok(vec![]) + } + VersionCheck::UpdateAvailable { + current: cur, + latest: lat, + } => { + ui::info(format!("govctl v{cur} -> v{lat} available")); + // Per [[RFC-0002:C-SELF-UPDATE]]: --check MUST exit 1 if a newer version is available. + // Return an error-level diagnostic so main.rs produces ExitCode::FAILURE. + Ok(vec![Diagnostic::new( + DiagnosticCode::E0901IoError, + format!("update available: v{cur} -> v{lat}"), + String::new(), + )]) + } + } +} + +fn perform_update(current: &str) -> anyhow::Result> { + let show_progress = std::io::stdout().is_terminal(); + + let status = self_update::backends::github::Update::configure() + .repo_owner(REPO_OWNER) + .repo_name(REPO_NAME) + .bin_name("govctl") + .show_download_progress(show_progress) + .current_version(current) + .build()? + .update()?; + + let new_version = status.version(); + + if new_version == current { + ui::success(format!("govctl v{current} is already up to date")); + } else { + ui::success(format!("govctl updated: v{current} -> v{new_version}")); + } + + Ok(vec![]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_same_version_is_up_to_date() -> Result<(), Box> { + assert_eq!(compare_versions("0.8.3", "0.8.3")?, VersionCheck::UpToDate); + Ok(()) + } + + #[test] + fn test_newer_available() -> Result<(), Box> { + assert_eq!( + compare_versions("0.8.2", "0.8.3")?, + VersionCheck::UpdateAvailable { + current: "0.8.2".into(), + latest: "0.8.3".into(), + } + ); + Ok(()) + } + + #[test] + fn test_current_newer_than_latest_is_up_to_date() -> Result<(), Box> { + // Dev build ahead of latest release + assert_eq!(compare_versions("0.9.0", "0.8.3")?, VersionCheck::UpToDate); + Ok(()) + } + + #[test] + fn test_strips_v_prefix() -> Result<(), Box> { + assert_eq!(compare_versions("0.8.3", "v0.8.3")?, VersionCheck::UpToDate); + assert_eq!( + compare_versions("0.8.2", "v0.9.0")?, + VersionCheck::UpdateAvailable { + current: "0.8.2".into(), + latest: "0.9.0".into(), + } + ); + Ok(()) + } + + #[test] + fn test_major_version_update() -> Result<(), Box> { + assert_eq!( + compare_versions("0.8.3", "1.0.0")?, + VersionCheck::UpdateAvailable { + current: "0.8.3".into(), + latest: "1.0.0".into(), + } + ); + Ok(()) + } + + #[test] + fn test_prerelease_not_newer_than_release() -> Result<(), Box> { + // 1.0.0-alpha < 1.0.0 per semver, so if current is 1.0.0 and latest is 1.0.0-alpha + assert_eq!( + compare_versions("1.0.0", "1.0.0-alpha")?, + VersionCheck::UpToDate + ); + Ok(()) + } + + #[test] + fn test_invalid_current_version_errors() { + assert!(compare_versions("not-a-version", "0.8.3").is_err()); + } + + #[test] + fn test_invalid_latest_version_errors() { + assert!(compare_versions("0.8.3", "not-a-version").is_err()); + } +} diff --git a/src/cmd/tag.rs b/src/cmd/tag.rs index dd119c1..77e9fb9 100644 --- a/src/cmd/tag.rs +++ b/src/cmd/tag.rs @@ -15,11 +15,23 @@ use serde::Serialize; use std::sync::LazyLock; /// Tag format regex: `^[a-z][a-z0-9-]*$` — [[RFC-0002:C-RESOURCES]] -pub static TAG_RE: LazyLock = - LazyLock::new(|| Regex::new(r"^[a-z][a-z0-9-]*$").expect("valid regex")); +static TAG_RE_RESULT: LazyLock> = + LazyLock::new(|| Regex::new(r"^[a-z][a-z0-9-]*$")); + +/// Return a reference to the compiled tag format regex. +pub fn tag_re() -> Result<&'static Regex, regex::Error> { + TAG_RE_RESULT.as_ref().map_err(|e| e.clone()) +} fn validate_tag_format(tag: &str) -> Result<()> { - if !TAG_RE.is_match(tag) { + let re = tag_re().map_err(|e| { + Diagnostic::new( + DiagnosticCode::E0806InvalidPattern, + format!("Failed to compile tag regex: {e}"), + "", + ) + })?; + if !re.is_match(tag) { return Err(Diagnostic::new( DiagnosticCode::E1101TagInvalidFormat, format!( @@ -110,7 +122,7 @@ fn set_allowed_tags(table: &mut toml::Table, tags: Vec) -> Result<()> { fn count_tag_usage(config: &Config, tag: &str) -> Result { let mut count = 0; - let rfcs = load_rfcs(config).map_err(|e| anyhow::anyhow!("{e:?}"))?; + let rfcs = load_rfcs(config).map_err(Diagnostic::from)?; for rfc_index in &rfcs { if rfc_index.rfc.tags.iter().any(|t| t == tag) { count += 1; @@ -122,21 +134,21 @@ fn count_tag_usage(config: &Config, tag: &str) -> Result { } } - let adrs = load_adrs(config).map_err(|e| anyhow::anyhow!("{e:?}"))?; + let adrs = load_adrs(config)?; for adr in &adrs { if adr.spec.govctl.tags.iter().any(|t| t == tag) { count += 1; } } - let items = load_work_items(config).map_err(|e| anyhow::anyhow!("{e:?}"))?; + let items = load_work_items(config)?; for item in &items { if item.spec.govctl.tags.iter().any(|t| t == tag) { count += 1; } } - let guard_result = load_guards_with_warnings(config).map_err(|e| anyhow::anyhow!("{e:?}"))?; + let guard_result = load_guards_with_warnings(config)?; for guard in &guard_result.items { if guard.spec.govctl.tags.iter().any(|t| t == tag) { count += 1; diff --git a/src/command_router.rs b/src/command_router.rs index 0659621..de19dd8 100644 --- a/src/command_router.rs +++ b/src/command_router.rs @@ -78,6 +78,9 @@ pub enum BuiltinOp { Completions { shell: clap_complete::Shell, }, + SelfUpdate { + check: bool, + }, #[cfg(feature = "tui")] Tui, ReleaseCut { @@ -209,6 +212,7 @@ impl CommandPlan { | Op::Builtin(BuiltinOp::Verify { .. }) | Op::Builtin(BuiltinOp::Describe { .. }) | Op::Builtin(BuiltinOp::Completions { .. }) + | Op::Builtin(BuiltinOp::SelfUpdate { .. }) | Op::Builtin(BuiltinOp::TagList { .. }) | Op::Get | Op::List { .. } @@ -505,6 +509,7 @@ fn execute_builtin( cmd::verify::verify(config, guard_ids, work.as_deref()) } BuiltinOp::Describe { context, output: _ } => cmd::describe::describe(config, *context), + BuiltinOp::SelfUpdate { check } => cmd::self_update::self_update(*check), BuiltinOp::Completions { shell } => { use crate::Cli; use clap::CommandFactory; @@ -865,6 +870,9 @@ impl CommandPlan { Commands::Completions { shell } => Ok(global(Op::Builtin(BuiltinOp::Completions { shell: *shell, }))), + Commands::SelfUpdate { check } => { + Ok(global(Op::Builtin(BuiltinOp::SelfUpdate { check: *check }))) + } #[cfg(feature = "tui")] Commands::Tui => Ok(global(Op::Builtin(BuiltinOp::Tui))), Commands::Rfc { command } => command.to_plan(), @@ -901,8 +909,9 @@ mod tests { use clap::{Parser, error::ErrorKind}; #[test] - fn test_owned_edit_action_requires_exactly_one_action() { - let err = owned_edit_action(&EditActionArgs { + fn test_owned_edit_action_requires_exactly_one_action() -> Result<(), Box> + { + let result = owned_edit_action(&EditActionArgs { set: None, add: None, remove: None, @@ -912,15 +921,19 @@ mod tests { exact: false, regex: false, all: false, - }) - .expect_err("missing action should fail"); - - let diag = err.downcast_ref::().expect("diagnostic"); + }); + assert!(result.is_err(), "missing action should fail"); + let err = result.err().ok_or("expected Err")?; + let diag = err + .downcast_ref::() + .ok_or("expected Diagnostic")?; assert_eq!(diag.code, DiagnosticCode::E0801MissingRequiredArg); + Ok(()) } #[test] - fn test_from_clause_command_uses_canonical_edit_when_path_is_present() { + fn test_from_clause_command_uses_canonical_edit_when_path_is_present() + -> Result<(), Box> { let cmd = ClauseCommand::Edit { id: "RFC-0001:C-TEST".to_string(), path: Some("text".to_string()), @@ -937,7 +950,7 @@ mod tests { text_file: None, }; - let plan = cmd.to_plan().expect("canonical edit"); + let plan = cmd.to_plan()?; assert!(matches!( plan.scope, Scope::Target { @@ -951,14 +964,16 @@ mod tests { assert_eq!(value.as_ref(), Some(&Some("Updated".to_string()))); assert!(!stdin); } - other => panic!("expected set action, got {other:?}"), + other => return Err(format!("expected set action, got {other:?}").into()), }, - other => panic!("expected field edit, got {other:?}"), + other => return Err(format!("expected field edit, got {other:?}").into()), } + Ok(()) } #[test] - fn test_from_clause_command_requires_path_for_canonical_flags() { + fn test_from_clause_command_requires_path_for_canonical_flags() + -> Result<(), Box> { let cmd = ClauseCommand::Edit { id: "RFC-0001:C-TEST".to_string(), path: None, @@ -975,13 +990,19 @@ mod tests { text_file: None, }; - let err = cmd.to_plan().expect_err("missing path"); - let diag = err.downcast_ref::().expect("diagnostic"); + let result = cmd.to_plan(); + assert!(result.is_err(), "missing path should fail"); + let err = result.err().ok_or("expected Err")?; + let diag = err + .downcast_ref::() + .ok_or("expected Diagnostic")?; assert_eq!(diag.code, DiagnosticCode::E0801MissingRequiredArg); + Ok(()) } #[test] - fn test_from_clause_command_uses_legacy_edit_without_canonical_flags() { + fn test_from_clause_command_uses_legacy_edit_without_canonical_flags() + -> Result<(), Box> { let cmd = ClauseCommand::Edit { id: "RFC-0001:C-TEST".to_string(), path: None, @@ -998,7 +1019,7 @@ mod tests { text_file: None, }; - let plan = cmd.to_plan().expect("legacy edit"); + let plan = cmd.to_plan()?; assert!(matches!( plan.scope, Scope::Artifact { @@ -1016,12 +1037,14 @@ mod tests { assert!(text_file.is_none()); assert!(stdin); } - other => panic!("expected legacy clause edit, got {other:?}"), + other => return Err(format!("expected legacy clause edit, got {other:?}").into()), } + Ok(()) } #[test] - fn test_owned_edit_action_builds_tick_match_options() { + fn test_owned_edit_action_builds_tick_match_options() -> Result<(), Box> + { let action = owned_edit_action(&EditActionArgs { set: None, add: None, @@ -1032,8 +1055,7 @@ mod tests { exact: true, regex: false, all: false, - }) - .expect("tick action"); + })?; match action { OwnedEditAction::Tick { match_opts, status } => { @@ -1041,13 +1063,15 @@ mod tests { assert_eq!(match_opts.at, Some(2)); assert!(match_opts.exact); } - other => panic!("expected tick action, got {other:?}"), + other => return Err(format!("expected tick action, got {other:?}").into()), } + Ok(()) } #[test] - fn test_owned_edit_action_rejects_tick_all_combination() { - let err = owned_edit_action(&EditActionArgs { + fn test_owned_edit_action_rejects_tick_all_combination() + -> Result<(), Box> { + let result = owned_edit_action(&EditActionArgs { set: None, add: None, remove: None, @@ -1057,16 +1081,19 @@ mod tests { exact: false, regex: false, all: true, - }) - .expect_err("tick with --all should fail"); - - let diag = err.downcast_ref::().expect("diagnostic"); + }); + assert!(result.is_err(), "tick with --all should fail"); + let err = result.err().ok_or("expected Err")?; + let diag = err + .downcast_ref::() + .ok_or("expected Diagnostic")?; assert_eq!(diag.code, DiagnosticCode::E0802ConflictingArgs); + Ok(()) } #[test] - fn test_owned_edit_action_rejects_multiple_actions() { - let err = owned_edit_action(&EditActionArgs { + fn test_owned_edit_action_rejects_multiple_actions() -> Result<(), Box> { + let result = owned_edit_action(&EditActionArgs { set: Some(Some("x".to_string())), add: Some(Some("y".to_string())), remove: None, @@ -1076,15 +1103,19 @@ mod tests { exact: false, regex: false, all: false, - }) - .expect_err("multiple actions should fail"); - - let diag = err.downcast_ref::().expect("diagnostic"); + }); + assert!(result.is_err(), "multiple actions should fail"); + let err = result.err().ok_or("expected Err")?; + let diag = err + .downcast_ref::() + .ok_or("expected Diagnostic")?; assert_eq!(diag.code, DiagnosticCode::E0802ConflictingArgs); + Ok(()) } #[test] - fn test_owned_edit_action_preserves_explicit_empty_strings() { + fn test_owned_edit_action_preserves_explicit_empty_strings() + -> Result<(), Box> { let set = owned_edit_action(&EditActionArgs { set: Some(Some(String::new())), add: None, @@ -1095,14 +1126,13 @@ mod tests { exact: false, regex: false, all: false, - }) - .expect("set action"); + })?; match set { OwnedEditAction::Set { value, stdin } => { assert_eq!(value.as_ref(), Some(&Some(String::new()))); assert!(!stdin); } - other => panic!("expected set action, got {other:?}"), + other => return Err(format!("expected set action, got {other:?}").into()), } let add = owned_edit_action(&EditActionArgs { @@ -1115,14 +1145,13 @@ mod tests { exact: false, regex: false, all: false, - }) - .expect("add action"); + })?; match add { OwnedEditAction::Add { value, stdin } => { assert_eq!(value.as_ref(), Some(&Some(String::new()))); assert!(!stdin); } - other => panic!("expected add action, got {other:?}"), + other => return Err(format!("expected add action, got {other:?}").into()), } let remove = owned_edit_action(&EditActionArgs { @@ -1135,18 +1164,18 @@ mod tests { exact: false, regex: false, all: false, - }) - .expect("remove action"); + })?; match remove { OwnedEditAction::Remove { match_opts } => { assert_eq!(match_opts.pattern.as_deref(), Some("")); } - other => panic!("expected remove action, got {other:?}"), + other => return Err(format!("expected remove action, got {other:?}").into()), } + Ok(()) } #[test] - fn test_edit_plans_are_mutating() { + fn test_edit_plans_are_mutating() -> Result<(), Box> { let plan = crate::RfcCommand::Edit(crate::CommonEditArgs { id: "RFC-0001".to_string(), path: "title".to_string(), @@ -1162,8 +1191,7 @@ mod tests { all: false, }, }) - .to_plan() - .expect("rfc edit"); + .to_plan()?; assert!(matches!(plan.scope, Scope::Target { .. })); assert!(matches!(plan.op, Op::Edit(EditOp::Field { .. }))); assert_eq!(plan.lock_disposition(), LockDisposition::GovRootExclusive); @@ -1183,33 +1211,33 @@ mod tests { text: None, text_file: None, } - .to_plan() - .expect("legacy edit"); + .to_plan()?; assert!(matches!(plan.op, Op::Edit(EditOp::ClauseLegacy { .. }))); assert_eq!(plan.lock_disposition(), LockDisposition::GovRootExclusive); + Ok(()) } #[test] - fn test_read_plans_are_lock_free() { + fn test_read_plans_are_lock_free() -> Result<(), Box> { let status = global(Op::Builtin(BuiltinOp::Status)); assert_eq!(status.lock_disposition(), LockDisposition::None); - let plan = plan_get("RFC-0001", Some("title")).expect("get"); + let plan = plan_get("RFC-0001", Some("title"))?; assert!(matches!(plan.scope, Scope::Target { .. })); assert!(matches!(plan.op, Op::Get)); assert_eq!(plan.lock_disposition(), LockDisposition::None); + Ok(()) } #[test] - fn test_lock_disposition_is_lock_free_for_inspect_commands() { + fn test_lock_disposition_is_lock_free_for_inspect_commands() + -> Result<(), Box> { assert_eq!( global(Op::Builtin(BuiltinOp::Status)).lock_disposition(), LockDisposition::None ); assert_eq!( - plan_get("RFC-0001", Some("title")) - .expect("get") - .lock_disposition(), + plan_get("RFC-0001", Some("title"))?.lock_disposition(), LockDisposition::None ); assert_eq!( @@ -1221,10 +1249,12 @@ mod tests { .lock_disposition(), LockDisposition::None ); + Ok(()) } #[test] - fn test_lock_disposition_requires_lock_for_mutating_commands() { + fn test_lock_disposition_requires_lock_for_mutating_commands() + -> Result<(), Box> { assert_eq!( global(Op::Builtin(BuiltinOp::Init { force: false })).lock_disposition(), LockDisposition::GovRootExclusive @@ -1235,8 +1265,7 @@ mod tests { "acceptance_criteria[0]", tick_action(OwnedMatchOptions::default(), TickStatus::Done), EditExtras::default(), - ) - .expect("work edit") + )? .lock_disposition(), LockDisposition::GovRootExclusive ); @@ -1252,16 +1281,48 @@ mod tests { .lock_disposition(), LockDisposition::GovRootExclusive ); + Ok(()) } #[test] - fn test_target_resolves_get_and_edit_to_same_field_target() { + fn test_self_update_routes_to_builtin_op() -> Result<(), Box> { + let check_plan = CommandPlan::from_parsed(&Commands::SelfUpdate { check: true }, false)?; + assert!(matches!(check_plan.scope, Scope::Global)); + assert!(matches!( + check_plan.op, + Op::Builtin(BuiltinOp::SelfUpdate { check: true }) + )); + + let update_plan = CommandPlan::from_parsed(&Commands::SelfUpdate { check: false }, false)?; + assert!(matches!(update_plan.scope, Scope::Global)); + assert!(matches!( + update_plan.op, + Op::Builtin(BuiltinOp::SelfUpdate { check: false }) + )); + Ok(()) + } + + #[test] + fn test_self_update_is_lock_free() { + // Self-update replaces the binary, not governance files — no gov-root lock needed. + assert_eq!( + global(Op::Builtin(BuiltinOp::SelfUpdate { check: true })).lock_disposition(), + LockDisposition::None + ); + assert_eq!( + global(Op::Builtin(BuiltinOp::SelfUpdate { check: false })).lock_disposition(), + LockDisposition::None + ); + } + + #[test] + fn test_target_resolves_get_and_edit_to_same_field_target() + -> Result<(), Box> { let get = crate::AdrCommand::Get(crate::CommonGetArgs { id: "ADR-0038".to_string(), field: Some("alternatives[1].status".to_string()), }) - .to_plan() - .expect("get routed"); + .to_plan()?; let edit = crate::AdrCommand::Edit(crate::AdrEditArgs { common: crate::CommonEditArgs { id: "ADR-0038".to_string(), @@ -1282,8 +1343,7 @@ mod tests { con: vec![], reject_reason: None, }) - .to_plan() - .expect("edit routed"); + .to_plan()?; match ((&get.op, &get.scope), (&edit.op, &edit.scope)) { ( @@ -1308,13 +1368,15 @@ mod tests { assert_eq!(get_id, edit_id); assert_eq!(get_target, edit_target); } - other => panic!("expected field targets, got {other:?}"), + other => return Err(format!("expected field targets, got {other:?}").into()), } + Ok(()) } #[test] - fn test_owned_edit_action_rejects_selector_flags_for_set() { - let err = owned_edit_action(&EditActionArgs { + fn test_owned_edit_action_rejects_selector_flags_for_set() + -> Result<(), Box> { + let result = owned_edit_action(&EditActionArgs { set: Some(Some("x".to_string())), add: None, remove: None, @@ -1324,16 +1386,19 @@ mod tests { exact: false, regex: false, all: false, - }) - .expect_err("set with --at should fail"); - - let diag = err.downcast_ref::().expect("diagnostic"); + }); + assert!(result.is_err(), "set with --at should fail"); + let err = result.err().ok_or("expected Err")?; + let diag = err + .downcast_ref::() + .ok_or("expected Diagnostic")?; assert_eq!(diag.code, DiagnosticCode::E0802ConflictingArgs); + Ok(()) } #[test] - fn test_owned_edit_action_rejects_stdin_for_remove() { - let err = owned_edit_action(&EditActionArgs { + fn test_owned_edit_action_rejects_stdin_for_remove() -> Result<(), Box> { + let result = owned_edit_action(&EditActionArgs { set: None, add: None, remove: Some(None), @@ -1343,15 +1408,19 @@ mod tests { exact: false, regex: false, all: false, - }) - .expect_err("remove with --stdin should fail"); - - let diag = err.downcast_ref::().expect("diagnostic"); + }); + assert!(result.is_err(), "remove with --stdin should fail"); + let err = result.err().ok_or("expected Err")?; + let diag = err + .downcast_ref::() + .ok_or("expected Diagnostic")?; assert_eq!(diag.code, DiagnosticCode::E0802ConflictingArgs); + Ok(()) } #[test] - fn test_from_clause_command_rejects_mixed_canonical_and_legacy_edit_flags() { + fn test_from_clause_command_rejects_mixed_canonical_and_legacy_edit_flags() + -> Result<(), Box> { let cmd = ClauseCommand::Edit { id: "RFC-0001:C-TEST".to_string(), path: Some("text".to_string()), @@ -1368,13 +1437,18 @@ mod tests { text_file: None, }; - let err = cmd.to_plan().expect_err("mixed modes should fail"); - let diag = err.downcast_ref::().expect("diagnostic"); + let result = cmd.to_plan(); + assert!(result.is_err(), "mixed modes should fail"); + let err = result.err().ok_or("expected Err")?; + let diag = err + .downcast_ref::() + .ok_or("expected Diagnostic")?; assert_eq!(diag.code, DiagnosticCode::E0802ConflictingArgs); + Ok(()) } #[test] - fn test_work_tick_defaults_status_to_done() { + fn test_work_tick_defaults_status_to_done() -> Result<(), Box> { let cli = crate::Cli::parse_from([ "govctl", "work", @@ -1388,14 +1462,15 @@ mod tests { crate::Commands::Work { command: crate::WorkCommand::Tick(crate::WorkTickArgs { status, .. }), } => assert!(matches!(status, WorkTickStatus::Done)), - _ => panic!("expected work tick command"), + _ => return Err("expected work tick command".into()), } + Ok(()) } #[test] fn test_rfc_get_help_restores_resource_specific_examples() { let err = match crate::Cli::try_parse_from(["govctl", "rfc", "get", "--help"]) { - Ok(_) => panic!("help should exit"), + Ok(_) => unreachable!("help should exit"), Err(err) => err, }; assert_eq!(err.kind(), ErrorKind::DisplayHelp); @@ -1410,7 +1485,7 @@ mod tests { #[test] fn test_work_get_help_restores_resource_specific_examples() { let err = match crate::Cli::try_parse_from(["govctl", "work", "get", "--help"]) { - Ok(_) => panic!("help should exit"), + Ok(_) => unreachable!("help should exit"), Err(err) => err, }; assert_eq!(err.kind(), ErrorKind::DisplayHelp); diff --git a/src/render.rs b/src/render.rs index 2442f58..704669b 100644 --- a/src/render.rs +++ b/src/render.rs @@ -709,7 +709,7 @@ mod tests { // Tests for render_adr with new Alternative fields per [[ADR-0027]] #[test] - fn test_render_adr_alternatives_with_pros_cons() { + fn test_render_adr_alternatives_with_pros_cons() -> Result<(), Box> { let adr = AdrEntry { spec: AdrSpec { govctl: AdrMeta { @@ -738,14 +738,16 @@ mod tests { path: std::path::PathBuf::new(), }; - let result = render_adr(&adr).unwrap(); + let result = render_adr(&adr)?; assert!(result.contains("### Option A")); assert!(result.contains("- **Pros:** Fast, Cheap")); assert!(result.contains("- **Cons:** Less reliable")); + Ok(()) } #[test] - fn test_render_adr_alternatives_rejected_with_reason() { + fn test_render_adr_alternatives_rejected_with_reason() -> Result<(), Box> + { let adr = AdrEntry { spec: AdrSpec { govctl: AdrMeta { @@ -774,15 +776,16 @@ mod tests { path: std::path::PathBuf::new(), }; - let result = render_adr(&adr).unwrap(); + let result = render_adr(&adr)?; assert!(result.contains("### Option B (rejected)")); assert!(result.contains("- **Rejected because:** Budget constraints")); + Ok(()) } // Tests for render_work_item with journal field per [[ADR-0026]] #[test] - fn test_render_work_item_journal() { + fn test_render_work_item_journal() -> Result<(), Box> { let item = WorkItemEntry { spec: WorkItemSpec { govctl: WorkItemMeta { @@ -811,14 +814,15 @@ mod tests { path: std::path::PathBuf::new(), }; - let result = render_work_item(&item).unwrap(); + let result = render_work_item(&item)?; assert!(result.contains("## Journal")); assert!(result.contains("### 2026-02-22")); assert!(result.contains("Started implementation")); + Ok(()) } #[test] - fn test_render_work_item_journal_with_scope() { + fn test_render_work_item_journal_with_scope() -> Result<(), Box> { let item = WorkItemEntry { spec: WorkItemSpec { govctl: WorkItemMeta { @@ -854,10 +858,11 @@ mod tests { path: std::path::PathBuf::new(), }; - let result = render_work_item(&item).unwrap(); + let result = render_work_item(&item)?; assert!(result.contains("### 2026-02-22 · API")); assert!(result.contains("Created endpoint")); assert!(result.contains("### 2026-02-23 · Testing")); assert!(result.contains("Added unit tests")); + Ok(()) } } diff --git a/src/signature.rs b/src/signature.rs index fc21bfa..912d7ab 100644 --- a/src/signature.rs +++ b/src/signature.rs @@ -206,19 +206,20 @@ mod tests { use super::*; #[test] - fn test_canonicalize_sorts_keys() { - let json: Value = - serde_json::from_str(r#"{"z": 1, "a": 2, "m": 3}"#).expect("test JSON should parse"); + fn test_canonicalize_sorts_keys() -> Result<(), Box> { + let json: Value = serde_json::from_str(r#"{"z": 1, "a": 2, "m": 3}"#)?; let canonical = canonicalize_json(&json); assert_eq!(canonical, r#"{"a":2,"m":3,"z":1}"#); + Ok(()) } #[test] - fn test_canonicalize_nested_objects() { - let json: Value = serde_json::from_str(r#"{"outer": {"z": 1, "a": 2}, "inner": {"b": 3}}"#) - .expect("test JSON should parse"); + fn test_canonicalize_nested_objects() -> Result<(), Box> { + let json: Value = + serde_json::from_str(r#"{"outer": {"z": 1, "a": 2}, "inner": {"b": 3}}"#)?; let canonical = canonicalize_json(&json); assert_eq!(canonical, r#"{"inner":{"b":3},"outer":{"a":2,"z":1}}"#); + Ok(()) } #[test] diff --git a/src/terminal_md.rs b/src/terminal_md.rs index d6837f3..bb5e5b7 100644 --- a/src/terminal_md.rs +++ b/src/terminal_md.rs @@ -8,24 +8,41 @@ use crate::ui::stdout_supports_color; use regex::Regex; use std::sync::LazyLock; -static HTML_COMMENT: LazyLock = - LazyLock::new(|| Regex::new(r"").expect("valid regex")); +static HTML_COMMENT: LazyLock> = + LazyLock::new(|| Regex::new(r"")); -static HTML_ANCHOR: LazyLock = - LazyLock::new(|| Regex::new(r#"\s*"#).expect("valid regex")); +static HTML_ANCHOR: LazyLock> = + LazyLock::new(|| Regex::new(r#"\s*"#)); -static HTML_DEL: LazyLock = LazyLock::new(|| Regex::new(r"").expect("valid regex")); +static HTML_DEL: LazyLock> = LazyLock::new(|| Regex::new(r"")); -static MD_LINK: LazyLock = - LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\([^)]+\.md(?:#[^)]*)?\)").expect("valid regex")); +static MD_LINK: LazyLock> = + LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\([^)]+\.md(?:#[^)]*)?\)")); /// Strip HTML artifacts that are meaningful in rendered .md files /// but visual noise in a terminal. pub fn strip_for_terminal(md: &str) -> String { - let s = HTML_COMMENT.replace_all(md, ""); - let s = HTML_ANCHOR.replace_all(&s, ""); - let s = HTML_DEL.replace_all(&s, ""); - let s = MD_LINK.replace_all(&s, "$1"); + let html_comment = match HTML_COMMENT.as_ref() { + Ok(r) => r, + Err(_) => return md.to_string(), + }; + let html_anchor = match HTML_ANCHOR.as_ref() { + Ok(r) => r, + Err(_) => return md.to_string(), + }; + let html_del = match HTML_DEL.as_ref() { + Ok(r) => r, + Err(_) => return md.to_string(), + }; + let md_link = match MD_LINK.as_ref() { + Ok(r) => r, + Err(_) => return md.to_string(), + }; + + let s = html_comment.replace_all(md, ""); + let s = html_anchor.replace_all(&s, ""); + let s = html_del.replace_all(&s, ""); + let s = md_link.replace_all(&s, "$1"); let mut out = String::with_capacity(s.len()); for line in s.lines() { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 594e425..4d85c17 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -73,7 +73,8 @@ mod tests { use super::*; #[test] - fn test_project_load_error_prefers_error_diagnostic() { + fn test_project_load_error_prefers_error_diagnostic() -> Result<(), Box> + { let err = project_load_error( vec![ Diagnostic::new(DiagnosticCode::W0109WorkNoActive, "warning", "warn"), @@ -82,13 +83,17 @@ mod tests { std::path::Path::new("gov"), ); - let diag = err.downcast_ref::().expect("diagnostic"); + let diag = err + .downcast_ref::() + .ok_or("expected Diagnostic")?; assert_eq!(diag.code, DiagnosticCode::E0302AdrNotFound); assert_eq!(diag.message, "missing adr"); + Ok(()) } #[test] - fn test_project_load_error_uses_warning_when_no_errors_exist() { + fn test_project_load_error_uses_warning_when_no_errors_exist() + -> Result<(), Box> { let err = project_load_error( vec![Diagnostic::new( DiagnosticCode::W0109WorkNoActive, @@ -98,18 +103,24 @@ mod tests { std::path::Path::new("gov"), ); - let diag = err.downcast_ref::().expect("diagnostic"); + let diag = err + .downcast_ref::() + .ok_or("expected Diagnostic")?; assert_eq!(diag.code, DiagnosticCode::W0109WorkNoActive); assert_eq!(diag.message, "warning only"); + Ok(()) } #[test] - fn test_project_load_error_falls_back_when_empty() { + fn test_project_load_error_falls_back_when_empty() -> Result<(), Box> { let err = project_load_error(vec![], std::path::Path::new("gov")); - let diag = err.downcast_ref::().expect("diagnostic"); + let diag = err + .downcast_ref::() + .ok_or("expected Diagnostic")?; assert_eq!(diag.code, DiagnosticCode::E0501ConfigInvalid); assert_eq!(diag.message, "Failed to load project"); assert_eq!(diag.file, "gov"); + Ok(()) } } diff --git a/src/validate.rs b/src/validate.rs index 2c7bb44..fbaffcd 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -910,7 +910,18 @@ fn validate_artifact_tags(index: &ProjectIndex, config: &Config, result: &mut Va let mut check_tags = |tags: &[String], artifact_id: &str, path_display: &str| { for tag in tags { // Validate format - if !crate::cmd::tag::TAG_RE.is_match(tag) { + let tag_re = match crate::cmd::tag::tag_re() { + Ok(r) => r, + Err(e) => { + result.diagnostics.push(Diagnostic::new( + DiagnosticCode::E0806InvalidPattern, + format!("Failed to compile tag regex: {e}"), + path_display, + )); + continue; + } + }; + if !tag_re.is_match(tag) { result.diagnostics.push(Diagnostic::new( DiagnosticCode::E1101TagInvalidFormat, format!( diff --git a/src/write.rs b/src/write.rs index d75a6f4..da2bf10 100644 --- a/src/write.rs +++ b/src/write.rs @@ -483,68 +483,76 @@ mod tests { use super::*; #[test] - fn test_parse_changelog_no_prefix() { - let result = parse_changelog_change("Added new feature").unwrap(); + fn test_parse_changelog_no_prefix() -> Result<(), Box> { + let result = parse_changelog_change("Added new feature")?; assert_eq!(result.category, ChangelogCategory::Added); assert_eq!(result.message, "Added new feature"); assert!(!result.explicit, "no prefix means not explicit"); + Ok(()) } #[test] - fn test_parse_changelog_fix_prefix() { - let result = parse_changelog_change("fix: memory leak in parser").unwrap(); + fn test_parse_changelog_fix_prefix() -> Result<(), Box> { + let result = parse_changelog_change("fix: memory leak in parser")?; assert_eq!(result.category, ChangelogCategory::Fixed); assert_eq!(result.message, "memory leak in parser"); assert!(result.explicit, "prefix means explicit"); + Ok(()) } #[test] - fn test_parse_changelog_security_prefix() { - let result = parse_changelog_change("security: patched CVE-2026-1234").unwrap(); + fn test_parse_changelog_security_prefix() -> Result<(), Box> { + let result = parse_changelog_change("security: patched CVE-2026-1234")?; assert_eq!(result.category, ChangelogCategory::Security); assert_eq!(result.message, "patched CVE-2026-1234"); + Ok(()) } #[test] - fn test_parse_changelog_changed_prefix() { - let result = parse_changelog_change("changed: API response format").unwrap(); + fn test_parse_changelog_changed_prefix() -> Result<(), Box> { + let result = parse_changelog_change("changed: API response format")?; assert_eq!(result.category, ChangelogCategory::Changed); assert_eq!(result.message, "API response format"); + Ok(()) } #[test] - fn test_parse_changelog_deprecated_prefix() { - let result = parse_changelog_change("deprecated: old API endpoint").unwrap(); + fn test_parse_changelog_deprecated_prefix() -> Result<(), Box> { + let result = parse_changelog_change("deprecated: old API endpoint")?; assert_eq!(result.category, ChangelogCategory::Deprecated); assert_eq!(result.message, "old API endpoint"); + Ok(()) } #[test] - fn test_parse_changelog_removed_prefix() { - let result = parse_changelog_change("removed: legacy feature").unwrap(); + fn test_parse_changelog_removed_prefix() -> Result<(), Box> { + let result = parse_changelog_change("removed: legacy feature")?; assert_eq!(result.category, ChangelogCategory::Removed); assert_eq!(result.message, "legacy feature"); + Ok(()) } #[test] - fn test_parse_changelog_add_prefix() { - let result = parse_changelog_change("add: new CLI flag").unwrap(); + fn test_parse_changelog_add_prefix() -> Result<(), Box> { + let result = parse_changelog_change("add: new CLI flag")?; assert_eq!(result.category, ChangelogCategory::Added); assert_eq!(result.message, "new CLI flag"); + Ok(()) } #[test] - fn test_parse_changelog_case_insensitive() { - let result = parse_changelog_change("FIX: uppercase prefix").unwrap(); + fn test_parse_changelog_case_insensitive() -> Result<(), Box> { + let result = parse_changelog_change("FIX: uppercase prefix")?; assert_eq!(result.category, ChangelogCategory::Fixed); assert_eq!(result.message, "uppercase prefix"); + Ok(()) } #[test] fn test_parse_changelog_invalid_prefix() { let result = parse_changelog_change("invalid: some message"); assert!(result.is_err()); - let err = result.unwrap_err().to_string(); + let err = result.err().map(|e| e.to_string()).unwrap_or_default(); assert!(err.contains("Unknown changelog prefix")); assert!(err.contains("Valid prefixes")); } @@ -553,59 +561,63 @@ mod tests { fn test_parse_changelog_empty_message_after_prefix() { let result = parse_changelog_change("fix:"); assert!(result.is_err()); - let err = result.unwrap_err().to_string(); + let err = result.err().map(|e| e.to_string()).unwrap_or_default(); assert!(err.contains("Empty message after prefix")); } #[test] - fn test_parse_changelog_colon_in_message_no_prefix() { + fn test_parse_changelog_colon_in_message_no_prefix() -> Result<(), Box> { // "Multi word prefix: message" should not be treated as a prefix - let result = parse_changelog_change("Updated module: fixed edge case").unwrap(); + let result = parse_changelog_change("Updated module: fixed edge case")?; assert_eq!(result.category, ChangelogCategory::Added); assert_eq!(result.message, "Updated module: fixed edge case"); assert!( !result.explicit, "multi-word before colon means not explicit" ); + Ok(()) } #[test] - fn test_parse_changelog_url_in_message() { + fn test_parse_changelog_url_in_message() -> Result<(), Box> { // URLs contain colons but shouldn't trigger prefix parsing - let result = parse_changelog_change("See https://example.com for details").unwrap(); + let result = parse_changelog_change("See https://example.com for details")?; assert_eq!(result.category, ChangelogCategory::Added); assert_eq!(result.message, "See https://example.com for details"); assert!(!result.explicit, "URL colon means not explicit"); + Ok(()) } #[test] - fn test_parse_changelog_conventional_commit_aliases() { + fn test_parse_changelog_conventional_commit_aliases() -> Result<(), Box> + { // feat → Added - let r = parse_changelog_change("feat: new CLI flag").unwrap(); + let r = parse_changelog_change("feat: new CLI flag")?; assert_eq!(r.category, ChangelogCategory::Added); // refactor → Changed - let r = parse_changelog_change("refactor: extract module").unwrap(); + let r = parse_changelog_change("refactor: extract module")?; assert_eq!(r.category, ChangelogCategory::Changed); // perf → Changed - let r = parse_changelog_change("perf: optimize hot path").unwrap(); + let r = parse_changelog_change("perf: optimize hot path")?; assert_eq!(r.category, ChangelogCategory::Changed); // test → Chore - let r = parse_changelog_change("test: add snapshot tests").unwrap(); + let r = parse_changelog_change("test: add snapshot tests")?; assert_eq!(r.category, ChangelogCategory::Chore); // docs → Chore - let r = parse_changelog_change("docs: update README").unwrap(); + let r = parse_changelog_change("docs: update README")?; assert_eq!(r.category, ChangelogCategory::Chore); // ci → Chore - let r = parse_changelog_change("ci: fix pipeline").unwrap(); + let r = parse_changelog_change("ci: fix pipeline")?; assert_eq!(r.category, ChangelogCategory::Chore); // build → Chore - let r = parse_changelog_change("build: update dependencies").unwrap(); + let r = parse_changelog_change("build: update dependencies")?; assert_eq!(r.category, ChangelogCategory::Chore); + Ok(()) } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 98b3d5a..82fc880 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -6,6 +6,8 @@ use std::path::Path; use std::process::Command; use tempfile::TempDir; +pub type TestResult = Result<(), Box>; + /// Get today's date in YYYY-MM-DD format (same as govctl uses) pub fn today() -> String { chrono::Local::now().format("%Y-%m-%d").to_string() @@ -16,7 +18,7 @@ pub fn today() -> String { /// - Replace today's date with `` /// - Replace work item IDs (WI-YYYY-MM-DD-NNN) with WI--NNN /// - Replace ADR IDs with date component normalized -pub fn normalize_output(output: &str, dir: &Path, date: &str) -> String { +pub fn normalize_output(output: &str, dir: &Path, date: &str) -> Result { let canonical = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); let canonical_str = canonical.display().to_string(); let dir_str = dir.display().to_string(); @@ -25,19 +27,19 @@ pub fn normalize_output(output: &str, dir: &Path, date: &str) -> String { normalized = normalized.replace(date, ""); // Replace work item IDs - let wi_pattern = regex::Regex::new(r"WI-\d{4}-\d{2}-\d{2}-(\d{3})").unwrap(); + let wi_pattern = regex::Regex::new(r"WI-\d{4}-\d{2}-\d{2}-(\d{3})")?; normalized = wi_pattern .replace_all(&normalized, "WI--$1") .to_string(); // Replace ADR filenames with dates - let adr_file_pattern = regex::Regex::new(r"ADR-(\d{4})-").unwrap(); + let adr_file_pattern = regex::Regex::new(r"ADR-(\d{4})-")?; normalized = adr_file_pattern .replace_all(&normalized, "ADR-XXXX-") .to_string(); // Replace signature hashes (date-dependent due to embedded dates in specs) - let sig_pattern = regex::Regex::new(r"sha256:[0-9a-f]{64}").unwrap(); + let sig_pattern = regex::Regex::new(r"sha256:[0-9a-f]{64}")?; normalized = sig_pattern .replace_all(&normalized, "sha256:") .to_string(); @@ -47,11 +49,11 @@ pub fn normalize_output(output: &str, dir: &Path, date: &str) -> String { let version = env!("CARGO_PKG_VERSION"); normalized = normalized.replace(&format!("\"{version}\""), "\"\""); - normalized + Ok(normalized) } /// Run govctl commands in a directory and capture output. -pub fn run_commands(dir: &Path, commands: &[&[&str]]) -> String { +pub fn run_commands(dir: &Path, commands: &[&[&str]]) -> Result { let mut output = String::new(); for args in commands { @@ -62,8 +64,7 @@ pub fn run_commands(dir: &Path, commands: &[&[&str]]) -> String { .current_dir(dir) .env("NO_COLOR", "1") .env("GOVCTL_DEFAULT_OWNER", "@test-user") - .output() - .expect("failed to run govctl"); + .output()?; let stdout = String::from_utf8_lossy(&result.stdout); let stderr = String::from_utf8_lossy(&result.stderr); @@ -84,11 +85,14 @@ pub fn run_commands(dir: &Path, commands: &[&[&str]]) -> String { output.push_str(&format!("exit: {}\n\n", result.status.code().unwrap_or(-1))); } - output + Ok(output) } /// Run commands with dynamic String arguments (for work item IDs with dates) -pub fn run_dynamic_commands(dir: &Path, commands: &[Vec]) -> String { +pub fn run_dynamic_commands( + dir: &Path, + commands: &[Vec], +) -> Result { let mut output = String::new(); for args in commands { @@ -100,8 +104,7 @@ pub fn run_dynamic_commands(dir: &Path, commands: &[Vec]) -> String { .current_dir(dir) .env("NO_COLOR", "1") .env("GOVCTL_DEFAULT_OWNER", "@test-user") - .output() - .expect("failed to run govctl"); + .output()?; let stdout = String::from_utf8_lossy(&result.stdout); let stderr = String::from_utf8_lossy(&result.stderr); @@ -122,15 +125,15 @@ pub fn run_dynamic_commands(dir: &Path, commands: &[Vec]) -> String { output.push_str(&format!("exit: {}\n\n", result.status.code().unwrap_or(-1))); } - output + Ok(output) } /// Initialize a govctl project in a temp directory. /// /// If `schema_version` is provided, overrides the config schema version /// (used by migration tests to simulate older repositories). -pub fn init_project_at(schema_version: Option) -> TempDir { - let temp_dir = TempDir::new().expect("failed to create temp dir"); +pub fn init_project_at(schema_version: Option) -> Result> { + let temp_dir = TempDir::new()?; let mut cmd = Command::new(env!("CARGO_BIN_EXE_govctl")); cmd.args(["init"]) .current_dir(temp_dir.path()) @@ -141,15 +144,15 @@ pub fn init_project_at(schema_version: Option) -> TempDir { cmd.env("GOVCTL_SCHEMA_VERSION", v.to_string()); } - let result = cmd.output().expect("failed to run govctl init"); + let result = cmd.output()?; assert!(result.status.success(), "govctl init failed"); - temp_dir + Ok(temp_dir) } -pub fn init_project() -> TempDir { +pub fn init_project() -> Result> { init_project_at(None) } -pub fn init_project_v1() -> TempDir { +pub fn init_project_v1() -> Result> { init_project_at(Some(1)) } diff --git a/tests/test_agent_dir.rs b/tests/test_agent_dir.rs index 2ace2e3..a558f20 100644 --- a/tests/test_agent_dir.rs +++ b/tests/test_agent_dir.rs @@ -7,10 +7,10 @@ use std::fs; /// Test: Default agent_dir is .claude #[test] -fn test_default_agent_dir() { - let temp_dir = init_project(); +fn test_default_agent_dir() -> common::TestResult { + let temp_dir = init_project()?; - let _output = run_commands(temp_dir.path(), &[&["init-skills"]]); + let _output = run_commands(temp_dir.path(), &[&["init-skills"]])?; let skill_dir = temp_dir.path().join(".claude/skills/gov/SKILL.md"); assert!( @@ -23,12 +23,13 @@ fn test_default_agent_dir() { rfc_writer.exists(), "skills/rfc-writer/SKILL.md should exist under .claude" ); + Ok(()) } /// Test: Custom agent_dir is respected #[test] -fn test_custom_agent_dir() { - let temp_dir = init_project(); +fn test_custom_agent_dir() -> common::TestResult { + let temp_dir = init_project()?; let config_path = temp_dir.path().join("gov/config.toml"); let config_content = r#"[project] @@ -38,9 +39,9 @@ name = "test-project" docs_output = "docs" agent_dir = ".custom-agent" "#; - fs::write(&config_path, config_content).unwrap(); + fs::write(&config_path, config_content)?; - let output = run_commands(temp_dir.path(), &[&["init-skills", "-f"]]); + let output = run_commands(temp_dir.path(), &[&["init-skills", "-f"]])?; eprintln!("init-skills output:\n{}", output); if let Ok(entries) = fs::read_dir(temp_dir.path()) { @@ -55,26 +56,28 @@ agent_dir = ".custom-agent" "skills/gov/SKILL.md should exist under custom agent_dir, found: {:?}", cursor_skill ); + Ok(()) } /// Test: init-skills creates all subdirs (skills, agents) #[test] -fn test_agent_dir_creates_subdirs() { - let temp_dir = init_project(); +fn test_agent_dir_creates_subdirs() -> common::TestResult { + let temp_dir = init_project()?; - run_commands(temp_dir.path(), &[&["init-skills"]]); + run_commands(temp_dir.path(), &[&["init-skills"]])?; assert!(temp_dir.path().join(".claude/skills").is_dir()); assert!(temp_dir.path().join(".claude/agents").is_dir()); assert!(!temp_dir.path().join(".claude/commands").exists()); + Ok(()) } /// Test: --format codex writes .toml agents instead of .md #[test] -fn test_codex_format_agents() { - let temp_dir = init_project(); +fn test_codex_format_agents() -> common::TestResult { + let temp_dir = init_project()?; - run_commands(temp_dir.path(), &[&["init-skills", "--format", "codex"]]); + run_commands(temp_dir.path(), &[&["init-skills", "--format", "codex"]])?; // Skills are the same format regardless assert!(temp_dir.path().join(".claude/skills/gov/SKILL.md").exists()); @@ -85,7 +88,7 @@ fn test_codex_format_agents() { toml_agent.exists(), "codex format should write .toml agents" ); - let content = fs::read_to_string(&toml_agent).unwrap(); + let content = fs::read_to_string(&toml_agent)?; assert!(content.contains("name = \"rfc-reviewer\"")); assert!(content.contains("developer_instructions")); @@ -97,14 +100,15 @@ fn test_codex_format_agents() { .exists(), "codex format should not write .md agents" ); + Ok(()) } /// Test: default format writes .md agents #[test] -fn test_claude_format_agents() { - let temp_dir = init_project(); +fn test_claude_format_agents() -> common::TestResult { + let temp_dir = init_project()?; - run_commands(temp_dir.path(), &[&["init-skills"]]); + run_commands(temp_dir.path(), &[&["init-skills"]])?; let md_agent = temp_dir.path().join(".claude/agents/rfc-reviewer.md"); assert!(md_agent.exists(), "claude format should write .md agents"); @@ -116,12 +120,13 @@ fn test_claude_format_agents() { .exists(), "claude format should not write .toml agents" ); + Ok(()) } /// Test: init does NOT create skills/agents [[ADR-0035]] #[test] -fn test_init_no_skills() { - let temp_dir = init_project(); +fn test_init_no_skills() -> common::TestResult { + let temp_dir = init_project()?; assert!( !temp_dir.path().join(".claude/skills").exists(), @@ -135,4 +140,5 @@ fn test_init_no_skills() { temp_dir.path().join("gov/schema/adr.schema.json").exists(), "init should create schema files" ); + Ok(()) } diff --git a/tests/test_changelog.rs b/tests/test_changelog.rs index d259dd5..c8a2b5a 100644 --- a/tests/test_changelog.rs +++ b/tests/test_changelog.rs @@ -12,8 +12,8 @@ use tempfile::TempDir; /// 4. Create more unreleased work items /// 5. Render changelog and test release command #[test] -fn test_changelog_release_workflow() { - let temp_dir = TempDir::new().expect("failed to create temp dir"); +fn test_changelog_release_workflow() -> common::TestResult { + let temp_dir = TempDir::new()?; let dir = temp_dir.path(); let date = today(); @@ -122,10 +122,10 @@ fn test_changelog_release_workflow() { ], ]; - let setup_output = run_dynamic_commands(dir, &setup_commands); + let setup_output = run_dynamic_commands(dir, &setup_commands)?; insta::assert_snapshot!( "changelog_setup", - normalize_output(&setup_output, dir, &date) + normalize_output(&setup_output, dir, &date)? ); // Phase 2: Create unreleased work items for v0.2.0 @@ -236,10 +236,10 @@ fn test_changelog_release_workflow() { ], ]; - let unreleased_output = run_dynamic_commands(dir, &unreleased_commands); + let unreleased_output = run_dynamic_commands(dir, &unreleased_commands)?; insta::assert_snapshot!( "changelog_unreleased", - normalize_output(&unreleased_output, dir, &date) + normalize_output(&unreleased_output, dir, &date)? ); // Phase 3: Test changelog rendering and release preview @@ -250,21 +250,22 @@ fn test_changelog_release_workflow() { &["render", "changelog", "--dry-run"], &["release", "0.2.0", "--dry-run"], ], - ); + )?; insta::assert_snapshot!( "changelog_render", - normalize_output(&changelog_output, dir, &date) + normalize_output(&changelog_output, dir, &date)? ); // Phase 4: Test error cases let error_output = run_commands( dir, &[&["release", "invalid-version"], &["release", "0.1.0"]], - ); + )?; insta::assert_snapshot!( "changelog_errors", - normalize_output(&error_output, dir, &date) + normalize_output(&error_output, dir, &date)? ); + Ok(()) } /// Integration test: changelog preservation across the full adoption lifecycle. @@ -280,8 +281,8 @@ fn test_changelog_release_workflow() { /// and re-rendered. Verifies manual edits, hand-written versions, and /// unexpanded inline refs are all preserved. #[test] -fn test_changelog_preservation_lifecycle() { - let temp_dir = TempDir::new().expect("failed to create temp dir"); +fn test_changelog_preservation_lifecycle() -> common::TestResult { + let temp_dir = TempDir::new()?; let dir = temp_dir.path(); let date = today(); @@ -290,7 +291,7 @@ fn test_changelog_preservation_lifecycle() { // ── Phase 1: Brownfield adoption ────────────────────────────────── - let _ = run_commands(dir, &[&["init"]]); + let _ = run_commands(dir, &[&["init"]])?; // Legacy CHANGELOG: no [Unreleased], no govctl artifacts, mixed v-prefix let legacy_changelog = "\ @@ -327,8 +328,7 @@ All notable changes to this project will be documented in this file. - Initial public release - REST API with OpenAPI spec "; - std::fs::write(dir.join("CHANGELOG.md"), legacy_changelog) - .expect("failed to write legacy CHANGELOG.md"); + std::fs::write(dir.join("CHANGELOG.md"), legacy_changelog)?; // First govctl work item — includes an inline ref let phase1_commands: Vec> = vec![ @@ -367,11 +367,10 @@ All notable changes to this project will be documented in this file. "2026-03-01".to_string(), ], ]; - let _ = run_dynamic_commands(dir, &phase1_commands); + let _ = run_dynamic_commands(dir, &phase1_commands)?; - let _ = run_commands(dir, &[&["render", "changelog"]]); - let rendered = std::fs::read_to_string(dir.join("CHANGELOG.md")) - .expect("failed to read CHANGELOG.md after phase 1"); + let _ = run_commands(dir, &[&["render", "changelog"]])?; + let rendered = std::fs::read_to_string(dir.join("CHANGELOG.md"))?; // New govctl release appears assert!(rendered.contains("## [1.0.0]"), "1.0.0 should be present"); @@ -418,7 +417,7 @@ All notable changes to this project will be documented in this file. insta::assert_snapshot!( "changelog_lifecycle_phase1", - normalize_output(&rendered, dir, &date) + normalize_output(&rendered, dir, &date)? ); // ── Phase 2: Manual edits + second release ─────────────────────── @@ -485,8 +484,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 "#, wi1 = wi1, ); - std::fs::write(dir.join("CHANGELOG.md"), &phase2_changelog) - .expect("failed to write edited CHANGELOG.md"); + std::fs::write(dir.join("CHANGELOG.md"), &phase2_changelog)?; // Second govctl release let phase2_commands: Vec> = vec![ @@ -525,11 +523,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 "2026-03-14".to_string(), ], ]; - let _ = run_dynamic_commands(dir, &phase2_commands); + let _ = run_dynamic_commands(dir, &phase2_commands)?; - let _ = run_commands(dir, &[&["render", "changelog"]]); - let rendered = std::fs::read_to_string(dir.join("CHANGELOG.md")) - .expect("failed to read CHANGELOG.md after phase 2"); + let _ = run_commands(dir, &[&["render", "changelog"]])?; + let rendered = std::fs::read_to_string(dir.join("CHANGELOG.md"))?; // New release appears assert!(rendered.contains("## [2.0.0]"), "2.0.0 should be present"); @@ -599,6 +596,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 insta::assert_snapshot!( "changelog_lifecycle_phase2", - normalize_output(&rendered, dir, &date) + normalize_output(&rendered, dir, &date)? ); + Ok(()) } diff --git a/tests/test_delete.rs b/tests/test_delete.rs index f4018e1..5d1033b 100644 --- a/tests/test_delete.rs +++ b/tests/test_delete.rs @@ -2,18 +2,20 @@ mod common; -use common::{init_project, normalize_output, run_commands, run_dynamic_commands, today}; +use common::{ + TestResult, init_project, normalize_output, run_commands, run_dynamic_commands, today, +}; use std::fs; /// Test: Delete clause - safeguard prevents deleting from normative RFC #[test] -fn test_delete_clause_safeguard_normative() { - let temp_dir = init_project(); +fn test_delete_clause_safeguard_normative() -> TestResult { + let temp_dir = init_project()?; let date = today(); // Create normative RFC with clause let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.toml"), @@ -38,8 +40,7 @@ version = "1.0.0" date = "2026-01-01" added = ["Initial release"] "#, - ) - .unwrap(); + )?; fs::write( rfc_dir.join("clauses/C-LOCKED.toml"), @@ -56,26 +57,27 @@ since = "1.0.0" [content] text = "This clause cannot be deleted - RFC is normative." "#, - ) - .unwrap(); + )?; // Try to delete the clause (should fail) let output = run_commands( temp_dir.path(), &[&["clause", "delete", "RFC-0001:C-LOCKED", "-f"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + + Ok(()) } /// Test: Delete clause - successful deletion from draft RFC #[test] -fn test_delete_clause_success_draft() { - let temp_dir = init_project(); +fn test_delete_clause_success_draft() -> TestResult { + let temp_dir = init_project()?; let date = today(); // Create draft RFC with two clauses let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.toml"), @@ -100,8 +102,7 @@ version = "0.1.0" date = "2026-01-01" notes = "Initial draft" "#, - ) - .unwrap(); + )?; fs::write( rfc_dir.join("clauses/C-KEEP.toml"), @@ -118,8 +119,7 @@ since = "0.1.0" [content] text = "This clause will remain." "#, - ) - .unwrap(); + )?; fs::write( rfc_dir.join("clauses/C-DELETE.toml"), @@ -136,8 +136,7 @@ since = "0.1.0" [content] text = "This clause will be deleted." "#, - ) - .unwrap(); + )?; // Delete the clause with force flag, then verify let output = run_commands( @@ -147,14 +146,16 @@ text = "This clause will be deleted." &["clause", "list", "RFC-0001"], &["check"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + + Ok(()) } /// Test: Delete work item - safeguard prevents deleting active work item #[test] -fn test_delete_work_safeguard_active() { - let temp_dir = init_project(); +fn test_delete_work_safeguard_active() -> TestResult { + let temp_dir = init_project()?; let date = today(); let wi1 = format!("WI-{}-001", date); @@ -181,14 +182,16 @@ fn test_delete_work_safeguard_active() { ], ]; - let output = run_dynamic_commands(temp_dir.path(), &commands); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_dynamic_commands(temp_dir.path(), &commands)?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + + Ok(()) } /// Test: Delete work item - safeguard prevents deleting done work item #[test] -fn test_delete_work_safeguard_done() { - let temp_dir = init_project(); +fn test_delete_work_safeguard_done() -> TestResult { + let temp_dir = init_project()?; let date = today(); let wi1 = format!("WI-{}-001", date); @@ -230,14 +233,16 @@ fn test_delete_work_safeguard_done() { ], ]; - let output = run_dynamic_commands(temp_dir.path(), &commands); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_dynamic_commands(temp_dir.path(), &commands)?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + + Ok(()) } /// Test: Delete work item - successful deletion of queued work item #[test] -fn test_delete_work_success_queue() { - let temp_dir = init_project(); +fn test_delete_work_success_queue() -> TestResult { + let temp_dir = init_project()?; let date = today(); let wi1 = format!("WI-{}-001", date); @@ -264,14 +269,16 @@ fn test_delete_work_success_queue() { vec!["check".to_string()], ]; - let output = run_dynamic_commands(temp_dir.path(), &commands); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_dynamic_commands(temp_dir.path(), &commands)?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + + Ok(()) } /// Test: Delete work item - safeguard prevents deletion when referenced #[test] -fn test_delete_work_safeguard_referenced() { - let temp_dir = init_project(); +fn test_delete_work_safeguard_referenced() -> TestResult { + let temp_dir = init_project()?; let date = today(); let wi1 = format!("WI-{}-001", date); let wi2 = format!("WI-{}-002", date); @@ -297,7 +304,7 @@ fn test_delete_work_safeguard_referenced() { ], ]; - let _ = run_dynamic_commands(temp_dir.path(), &setup_commands); + let _ = run_dynamic_commands(temp_dir.path(), &setup_commands)?; // Try to delete wi1 (should fail because wi2 references it) let delete_commands: Vec> = vec![vec![ @@ -307,6 +314,8 @@ fn test_delete_work_safeguard_referenced() { "-f".to_string(), ]]; - let output = run_dynamic_commands(temp_dir.path(), &delete_commands); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_dynamic_commands(temp_dir.path(), &delete_commands)?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + + Ok(()) } diff --git a/tests/test_describe.rs b/tests/test_describe.rs index c9af556..d2d9605 100644 --- a/tests/test_describe.rs +++ b/tests/test_describe.rs @@ -5,52 +5,56 @@ mod common; use common::{init_project, normalize_output, run_commands, today}; #[test] -fn test_describe_basic() { +fn test_describe_basic() -> common::TestResult { // describe without a project should output static metadata - let temp_dir = tempfile::TempDir::new().expect("failed to create temp dir"); + let temp_dir = tempfile::TempDir::new()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["describe"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["describe"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_describe_in_initialized_project() { +fn test_describe_in_initialized_project() -> common::TestResult { // describe in an initialized project (without --context) should still output static metadata - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["describe"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["describe"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_describe_with_context_empty_project() { +fn test_describe_with_context_empty_project() -> common::TestResult { // describe --context in an empty initialized project should show empty state - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["describe", "--context"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["describe", "--context"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_describe_with_context_draft_rfc() { +fn test_describe_with_context_draft_rfc() -> common::TestResult { // describe --context with a draft RFC should suggest finalizing - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( temp_dir.path(), &[&["rfc", "new", "Test RFC"], &["describe", "--context"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_describe_with_context_normative_spec_phase_rfc() { +fn test_describe_with_context_normative_spec_phase_rfc() -> common::TestResult { // describe --context with a normative RFC in spec phase should suggest advancing to impl - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -60,14 +64,15 @@ fn test_describe_with_context_normative_spec_phase_rfc() { &["rfc", "finalize", "RFC-0001", "normative"], &["describe", "--context"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_describe_with_context_normative_impl_phase_rfc() { +fn test_describe_with_context_normative_impl_phase_rfc() -> common::TestResult { // describe --context with a normative RFC in impl phase should suggest advancing to test - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -78,14 +83,15 @@ fn test_describe_with_context_normative_impl_phase_rfc() { &["rfc", "advance", "RFC-0001", "impl"], &["describe", "--context"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_describe_with_context_normative_test_phase_rfc() { +fn test_describe_with_context_normative_test_phase_rfc() -> common::TestResult { // describe --context with a normative RFC in test phase should suggest advancing to stable - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -97,27 +103,29 @@ fn test_describe_with_context_normative_test_phase_rfc() { &["rfc", "advance", "RFC-0001", "test"], &["describe", "--context"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_describe_with_context_proposed_adr() { +fn test_describe_with_context_proposed_adr() -> common::TestResult { // describe --context with a proposed ADR should suggest accepting - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( temp_dir.path(), &[&["adr", "new", "Test Decision"], &["describe", "--context"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_describe_with_context_active_work_item() { +fn test_describe_with_context_active_work_item() -> common::TestResult { // describe --context with an active work item should show it - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -126,14 +134,15 @@ fn test_describe_with_context_active_work_item() { &["work", "new", "Test task", "--active"], &["describe", "--context"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_describe_with_context_queued_work_items() { +fn test_describe_with_context_queued_work_items() -> common::TestResult { // describe --context with queued work items but no active should suggest activating one - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -143,6 +152,7 @@ fn test_describe_with_context_queued_work_items() { &["work", "new", "Task two"], &["describe", "--context"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } diff --git a/tests/test_display_paths.rs b/tests/test_display_paths.rs index edec165..a08e752 100644 --- a/tests/test_display_paths.rs +++ b/tests/test_display_paths.rs @@ -10,13 +10,13 @@ use std::fs; /// Test: RFC render shows relative output path #[test] -fn test_render_rfc_display_path() { - let temp_dir = init_project(); +fn test_render_rfc_display_path() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); // Create draft RFC with clause let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.toml"), @@ -38,8 +38,7 @@ version = "0.1.0" date = "2026-01-01" notes = "Initial draft" "#, - ) - .unwrap(); + )?; fs::write( rfc_dir.join("clauses/C-TEST.toml"), @@ -52,25 +51,25 @@ status = "active" [content] text = "Test clause content." "#, - ) - .unwrap(); + )?; let output = run_commands( temp_dir.path(), &[&["rfc", "render", "RFC-0001", "--dry-run"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: ADR render shows relative output path #[test] -fn test_render_adr_display_path() { - let temp_dir = init_project(); +fn test_render_adr_display_path() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); // Create ADR let adr_dir = temp_dir.path().join("gov/adr"); - fs::create_dir_all(&adr_dir).unwrap(); + fs::create_dir_all(&adr_dir)?; fs::write( adr_dir.join("ADR-0001-test-decision.toml"), @@ -88,25 +87,25 @@ decision = "Test decision" alternatives = [] consequences = "Test consequences" "#, - ) - .unwrap(); + )?; let output = run_commands( temp_dir.path(), &[&["adr", "render", "ADR-0001", "--dry-run"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: Work item render shows relative output path #[test] -fn test_render_work_display_path() { - let temp_dir = init_project(); +fn test_render_work_display_path() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); // Create work item let work_dir = temp_dir.path().join("gov/work"); - fs::create_dir_all(&work_dir).unwrap(); + fs::create_dir_all(&work_dir)?; let work_filename = format!("{}-test-work.toml", date); fs::write( @@ -128,52 +127,55 @@ notes = [] "#, date, date, date ), - ) - .unwrap(); + )?; let output = run_commands( temp_dir.path(), &[&["work", "render", &format!("WI-{}-001", date), "--dry-run"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_render_rfc_missing_returns_scope_context() { - let temp_dir = init_project(); +fn test_render_rfc_missing_returns_scope_context() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["rfc", "render", "RFC-9999"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["rfc", "render", "RFC-9999"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_render_adr_missing_returns_scope_context() { - let temp_dir = init_project(); +fn test_render_adr_missing_returns_scope_context() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["adr", "render", "ADR-9999"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["adr", "render", "ADR-9999"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_render_work_missing_returns_scope_context() { - let temp_dir = init_project(); +fn test_render_work_missing_returns_scope_context() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["work", "render", "WI-9999-01-01-001"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["work", "render", "WI-9999-01-01-001"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: Delete work item dry-run shows relative path #[test] -fn test_delete_work_dry_run_display_path() { - let temp_dir = init_project(); +fn test_delete_work_dry_run_display_path() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); // Create a queued work item let work_dir = temp_dir.path().join("gov/work"); - fs::create_dir_all(&work_dir).unwrap(); + fs::create_dir_all(&work_dir)?; let work_filename = format!("{}-test-work.toml", date); fs::write( @@ -194,25 +196,25 @@ notes = [] "#, date, date ), - ) - .unwrap(); + )?; let output = run_commands( temp_dir.path(), &[&["work", "delete", &format!("WI-{}-001", date), "--dry-run"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: Delete clause dry-run shows relative path #[test] -fn test_delete_clause_dry_run_display_path() { - let temp_dir = init_project(); +fn test_delete_clause_dry_run_display_path() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); // Create draft RFC with clause (draft status allows deletion) let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.toml"), @@ -237,8 +239,7 @@ version = "0.1.0" date = "2026-01-01" notes = "Initial draft" "#, - ) - .unwrap(); + )?; fs::write( rfc_dir.join("clauses/C-TO-DELETE.toml"), @@ -254,25 +255,25 @@ status = "active" [content] text = "This clause will be deleted." "#, - ) - .unwrap(); + )?; let output = run_commands( temp_dir.path(), &[&["clause", "delete", "RFC-0001:C-TO-DELETE", "--dry-run"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: RFC set dry-run shows relative path #[test] -fn test_rfc_set_dry_run_display_path() { - let temp_dir = init_project(); +fn test_rfc_set_dry_run_display_path() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); // Create draft RFC let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.toml"), r#"#:schema ../../schema/rfc.schema.json @@ -296,8 +297,7 @@ version = "0.1.0" date = "2026-01-01" notes = "Initial draft" "#, - ) - .unwrap(); + )?; let output = run_commands( temp_dir.path(), @@ -309,19 +309,20 @@ notes = "Initial draft" "Updated Title", "--dry-run", ]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: RFC bump dry-run shows relative path #[test] -fn test_rfc_bump_dry_run_display_path() { - let temp_dir = init_project(); +fn test_rfc_bump_dry_run_display_path() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); // Create draft RFC let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.toml"), r#"#:schema ../../schema/rfc.schema.json @@ -345,8 +346,7 @@ version = "0.1.0" date = "2026-01-01" notes = "Initial draft" "#, - ) - .unwrap(); + )?; let output = run_commands( temp_dir.path(), @@ -358,27 +358,30 @@ notes = "Initial draft" "fix: test change", "--dry-run", ]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: RFC new dry-run shows relative paths #[test] -fn test_rfc_new_dry_run_display_path() { - let temp_dir = init_project(); +fn test_rfc_new_dry_run_display_path() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["rfc", "new", "New RFC", "--dry-run"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["rfc", "new", "New RFC", "--dry-run"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: work new dry-run shows relative paths #[test] -fn test_work_new_dry_run_display_path() { - let temp_dir = init_project(); +fn test_work_new_dry_run_display_path() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( temp_dir.path(), &[&["work", "new", "New Work", "--dry-run"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } diff --git a/tests/test_edit.rs b/tests/test_edit.rs index 1c55b31..cf78ba6 100644 --- a/tests/test_edit.rs +++ b/tests/test_edit.rs @@ -9,8 +9,8 @@ use common::{init_project, normalize_output, run_commands, today}; // ============================================================================ #[test] -fn test_rfc_set_title() { - let temp_dir = init_project(); +fn test_rfc_set_title() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -20,13 +20,14 @@ fn test_rfc_set_title() { &["rfc", "set", "RFC-0001", "title", "New Title"], &["rfc", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_rfc_get_field() { - let temp_dir = init_project(); +fn test_rfc_get_field() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -37,13 +38,14 @@ fn test_rfc_get_field() { &["rfc", "get", "RFC-0001", "status"], &["rfc", "get", "RFC-0001", "phase"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_rfc_add_owner() { - let temp_dir = init_project(); +fn test_rfc_add_owner() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -53,13 +55,14 @@ fn test_rfc_add_owner() { &["rfc", "add", "RFC-0001", "owners", "@newowner"], &["rfc", "get", "RFC-0001", "owners"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_rfc_remove_owner() { - let temp_dir = init_project(); +fn test_rfc_remove_owner() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -71,13 +74,14 @@ fn test_rfc_remove_owner() { &["rfc", "remove", "RFC-0001", "owners", "@owner1"], &["rfc", "get", "RFC-0001", "owners"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_rfc_remove_owner_by_index_canonical() { - let temp_dir = init_project(); +fn test_rfc_remove_owner_by_index_canonical() -> common::TestResult { + let temp_dir = init_project()?; let output = run_commands( temp_dir.path(), @@ -88,7 +92,7 @@ fn test_rfc_remove_owner_by_index_canonical() { &["rfc", "edit", "RFC-0001", "owners[1]", "--remove"], &["rfc", "get", "RFC-0001", "owners"], ], - ); + )?; assert!( output.contains("Removed '@owner1' from RFC-0001.owners"), @@ -100,11 +104,12 @@ fn test_rfc_remove_owner_by_index_canonical() { "output: {}", output ); + Ok(()) } #[test] -fn test_rfc_add_ref() { - let temp_dir = init_project(); +fn test_rfc_add_ref() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -114,13 +119,14 @@ fn test_rfc_add_ref() { &["rfc", "add", "RFC-0001", "refs", "ADR-0001"], &["rfc", "get", "RFC-0001", "refs"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_rfc_edit_set_title_canonical() { - let temp_dir = init_project(); +fn test_rfc_edit_set_title_canonical() -> common::TestResult { + let temp_dir = init_project()?; let output = run_commands( temp_dir.path(), @@ -136,7 +142,7 @@ fn test_rfc_edit_set_title_canonical() { ], &["rfc", "get", "RFC-0001", "title"], ], - ); + )?; assert!( output.contains("Set RFC-0001.title = Canonical Title"), @@ -148,11 +154,12 @@ fn test_rfc_edit_set_title_canonical() { "output: {}", output ); + Ok(()) } #[test] -fn test_rfc_edit_set_owner_by_index_canonical() { - let temp_dir = init_project(); +fn test_rfc_edit_set_owner_by_index_canonical() -> common::TestResult { + let temp_dir = init_project()?; let output = run_commands( temp_dir.path(), @@ -170,7 +177,7 @@ fn test_rfc_edit_set_owner_by_index_canonical() { ], &["rfc", "get", "RFC-0001", "owners"], ], - ); + )?; assert!( output.contains("Set RFC-0001.owners[1] = @replacement"), @@ -182,11 +189,12 @@ fn test_rfc_edit_set_owner_by_index_canonical() { "output: {}", output ); + Ok(()) } #[test] -fn test_rfc_set_nonexistent_field() { - let temp_dir = init_project(); +fn test_rfc_set_nonexistent_field() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -195,13 +203,14 @@ fn test_rfc_set_nonexistent_field() { &["rfc", "new", "Test RFC"], &["rfc", "set", "RFC-0001", "nonexistent", "value"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_rfc_set_version_rejected() { - let temp_dir = init_project(); +fn test_rfc_set_version_rejected() -> common::TestResult { + let temp_dir = init_project()?; let output = run_commands( temp_dir.path(), @@ -209,18 +218,19 @@ fn test_rfc_set_version_rejected() { &["rfc", "new", "Test RFC"], &["rfc", "set", "RFC-0001", "version", "0.2.0"], ], - ); + )?; assert!(output.contains("error[E0804]"), "output: {}", output); assert!( output.contains("Use `govctl rfc bump`"), "output: {}", output ); + Ok(()) } #[test] -fn test_rfc_set_status_rejected() { - let temp_dir = init_project(); +fn test_rfc_set_status_rejected() -> common::TestResult { + let temp_dir = init_project()?; let output = run_commands( temp_dir.path(), @@ -228,18 +238,20 @@ fn test_rfc_set_status_rejected() { &["rfc", "new", "Test RFC"], &["rfc", "set", "RFC-0001", "status", "normative"], ], - ); + )?; assert!(output.contains("error[E0804]"), "output: {}", output); assert!(output.contains("govctl rfc finalize"), "output: {}", output); + Ok(()) } #[test] -fn test_rfc_get_nonexistent() { - let temp_dir = init_project(); +fn test_rfc_get_nonexistent() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["rfc", "get", "RFC-9999", "title"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["rfc", "get", "RFC-9999", "title"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } // ============================================================================ @@ -247,8 +259,8 @@ fn test_rfc_get_nonexistent() { // ============================================================================ #[test] -fn test_clause_set_text() { - let temp_dir = init_project(); +fn test_clause_set_text() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -274,13 +286,14 @@ fn test_clause_set_text() { ], &["clause", "show", "RFC-0001:C-TEST"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_clause_edit_text_canonical() { - let temp_dir = init_project(); +fn test_clause_edit_text_canonical() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -307,13 +320,14 @@ fn test_clause_edit_text_canonical() { ], &["clause", "show", "RFC-0001:C-TEST"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_clause_set_title() { - let temp_dir = init_project(); +fn test_clause_set_title() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -333,13 +347,14 @@ fn test_clause_set_title() { &["clause", "set", "RFC-0001:C-TEST", "title", "New Title"], &["clause", "show", "RFC-0001:C-TEST"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_clause_edit_title_canonical() { - let temp_dir = init_project(); +fn test_clause_edit_title_canonical() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -366,13 +381,14 @@ fn test_clause_edit_title_canonical() { ], &["clause", "show", "RFC-0001:C-TEST"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_clause_remove_anchor_by_index_canonical() { - let temp_dir = init_project(); +fn test_clause_remove_anchor_by_index_canonical() -> common::TestResult { + let temp_dir = init_project()?; let output = run_commands( temp_dir.path(), @@ -413,7 +429,7 @@ fn test_clause_remove_anchor_by_index_canonical() { ], &["clause", "show", "RFC-0001:C-TEST", "-o", "json"], ], - ); + )?; assert!( output.contains("Removed 'anchor-one' from RFC-0001:C-TEST.anchors"), @@ -426,11 +442,12 @@ fn test_clause_remove_anchor_by_index_canonical() { "output: {}", output ); + Ok(()) } #[test] -fn test_clause_get_field() { - let temp_dir = init_project(); +fn test_clause_get_field() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -451,13 +468,14 @@ fn test_clause_get_field() { &["clause", "get", "RFC-0001:C-TEST", "kind"], &["clause", "get", "RFC-0001:C-TEST", "status"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_clause_set_since_rejected() { - let temp_dir = init_project(); +fn test_clause_set_since_rejected() -> common::TestResult { + let temp_dir = init_project()?; let output = run_commands( temp_dir.path(), @@ -475,18 +493,19 @@ fn test_clause_set_since_rejected() { ], &["clause", "set", "RFC-0001:C-TEST", "since", "0.1.0"], ], - ); + )?; assert!(output.contains("error[E0804]"), "output: {}", output); assert!( output.contains("Clause 'since' is derived from RFC versioning"), "output: {}", output ); + Ok(()) } #[test] -fn test_clause_set_text_sugar() { - let temp_dir = init_project(); +fn test_clause_set_text_sugar() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -506,13 +525,14 @@ fn test_clause_set_text_sugar() { &["clause", "set", "RFC-0001:C-TEST", "text", "new text"], &["clause", "show", "RFC-0001:C-TEST"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_clause_set_status_rejected() { - let temp_dir = init_project(); +fn test_clause_set_status_rejected() -> common::TestResult { + let temp_dir = init_project()?; let output = run_commands( temp_dir.path(), @@ -530,18 +550,19 @@ fn test_clause_set_status_rejected() { ], &["clause", "set", "RFC-0001:C-TEST", "status", "deprecated"], ], - ); + )?; assert!(output.contains("error[E0804]"), "output: {}", output); assert!( output.contains("govctl clause deprecate"), "output: {}", output ); + Ok(()) } #[test] -fn test_clause_edit_nonexistent() { - let temp_dir = init_project(); +fn test_clause_edit_nonexistent() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -550,8 +571,9 @@ fn test_clause_edit_nonexistent() { &["rfc", "new", "Test RFC"], &["clause", "edit", "RFC-0001:C-NONEXISTENT", "--text", "Text"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } // ============================================================================ @@ -559,8 +581,8 @@ fn test_clause_edit_nonexistent() { // ============================================================================ #[test] -fn test_adr_get_field() { - let temp_dir = init_project(); +fn test_adr_get_field() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -570,13 +592,14 @@ fn test_adr_get_field() { &["adr", "get", "ADR-0001", "title"], &["adr", "get", "ADR-0001", "status"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_set_title() { - let temp_dir = init_project(); +fn test_adr_set_title() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -586,13 +609,14 @@ fn test_adr_set_title() { &["adr", "set", "ADR-0001", "title", "New Title"], &["adr", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_set_status_rejected() { - let temp_dir = init_project(); +fn test_adr_set_status_rejected() -> common::TestResult { + let temp_dir = init_project()?; let output = run_commands( temp_dir.path(), @@ -600,14 +624,15 @@ fn test_adr_set_status_rejected() { &["adr", "new", "Test Decision"], &["adr", "set", "ADR-0001", "status", "accepted"], ], - ); + )?; assert!(output.contains("error[E0804]"), "output: {}", output); assert!(output.contains("govctl adr accept"), "output: {}", output); + Ok(()) } #[test] -fn test_adr_set_alternative_status_field_rejected() { - let temp_dir = init_project(); +fn test_adr_set_alternative_status_field_rejected() -> common::TestResult { + let temp_dir = init_project()?; let output = run_commands( temp_dir.path(), @@ -622,14 +647,15 @@ fn test_adr_set_alternative_status_field_rejected() { "accepted", ], ], - ); + )?; assert!(output.contains("error[E0804]"), "output: {}", output); assert!(output.contains("tick-owned"), "output: {}", output); + Ok(()) } #[test] -fn test_adr_add_ref() { - let temp_dir = init_project(); +fn test_adr_add_ref() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -639,13 +665,14 @@ fn test_adr_add_ref() { &["adr", "add", "ADR-0001", "refs", "RFC-0001"], &["adr", "get", "ADR-0001", "refs"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_edit_add_nested_path_canonical() { - let temp_dir = init_project(); +fn test_adr_edit_add_nested_path_canonical() -> common::TestResult { + let temp_dir = init_project()?; let output = run_commands( temp_dir.path(), @@ -669,7 +696,7 @@ fn test_adr_edit_add_nested_path_canonical() { ], &["adr", "get", "ADR-0001", "alternatives[0].pros"], ], - ); + )?; assert!( output.contains("Added 'Option A' to ADR-0001.alternatives"), @@ -686,11 +713,12 @@ fn test_adr_edit_add_nested_path_canonical() { "output: {}", output ); + Ok(()) } #[test] -fn test_adr_set_context() { - let temp_dir = init_project(); +fn test_adr_set_context() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -706,13 +734,14 @@ fn test_adr_set_context() { ], &["adr", "show", "ADR-0001"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_set_decision() { - let temp_dir = init_project(); +fn test_adr_set_decision() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -722,13 +751,14 @@ fn test_adr_set_decision() { &["adr", "set", "ADR-0001", "decision", "We decided to do X"], &["adr", "show", "ADR-0001"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_set_consequences() { - let temp_dir = init_project(); +fn test_adr_set_consequences() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -744,17 +774,19 @@ fn test_adr_set_consequences() { ], &["adr", "show", "ADR-0001"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_get_nonexistent() { - let temp_dir = init_project(); +fn test_adr_get_nonexistent() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["adr", "get", "ADR-9999", "title"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["adr", "get", "ADR-9999", "title"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } // ============================================================================ @@ -762,8 +794,8 @@ fn test_adr_get_nonexistent() { // ============================================================================ #[test] -fn test_work_get_field() { - let temp_dir = init_project(); +fn test_work_get_field() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -773,13 +805,14 @@ fn test_work_get_field() { &["work", "get", &format!("WI-{}-001", date), "title"], &["work", "get", &format!("WI-{}-001", date), "status"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_work_set_title() { - let temp_dir = init_project(); +fn test_work_set_title() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -795,13 +828,14 @@ fn test_work_set_title() { ], &["work", "list", "all"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_work_set_status_rejected() { - let temp_dir = init_project(); +fn test_work_set_status_rejected() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let work_id = format!("WI-{}-001", date); @@ -811,14 +845,15 @@ fn test_work_set_status_rejected() { &["work", "new", "Test Task"], &["work", "set", &work_id, "status", "active"], ], - ); + )?; assert!(output.contains("error[E0804]"), "output: {}", output); assert!(output.contains("govctl work move"), "output: {}", output); + Ok(()) } #[test] -fn test_work_set_acceptance_criteria_status_rejected() { - let temp_dir = init_project(); +fn test_work_set_acceptance_criteria_status_rejected() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let work_id = format!("WI-{}-001", date); @@ -841,14 +876,15 @@ fn test_work_set_acceptance_criteria_status_rejected() { "done", ], ], - ); + )?; assert!(output.contains("error[E0804]"), "output: {}", output); assert!(output.contains("govctl work tick"), "output: {}", output); + Ok(()) } #[test] -fn test_work_add_acceptance_criteria() { - let temp_dir = init_project(); +fn test_work_add_acceptance_criteria() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -871,13 +907,14 @@ fn test_work_add_acceptance_criteria() { ], &["work", "show", &format!("WI-{}-001", date)], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_work_tick_acceptance_criteria() { - let temp_dir = init_project(); +fn test_work_tick_acceptance_criteria() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -909,13 +946,14 @@ fn test_work_tick_acceptance_criteria() { ], &["work", "show", &format!("WI-{}-001", date)], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_work_edit_tick_indexed_path_canonical() { - let temp_dir = init_project(); +fn test_work_edit_tick_indexed_path_canonical() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let wi_id = format!("WI-{}-001", date); @@ -944,7 +982,7 @@ fn test_work_edit_tick_indexed_path_canonical() { vec!["work".to_string(), "show".to_string(), wi_id], ]; - let output = common::run_dynamic_commands(temp_dir.path(), &commands); + let output = common::run_dynamic_commands(temp_dir.path(), &commands)?; assert!( output.contains("Added 'add: Criterion 1' to WI-"), @@ -957,11 +995,12 @@ fn test_work_edit_tick_indexed_path_canonical() { output ); assert!(output.contains("- ✓ Criterion 1"), "output: {}", output); + Ok(()) } #[test] -fn test_work_tick_cancel_acceptance_criteria() { - let temp_dir = init_project(); +fn test_work_tick_cancel_acceptance_criteria() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -986,13 +1025,14 @@ fn test_work_tick_cancel_acceptance_criteria() { ], &["work", "show", &format!("WI-{}-001", date)], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_work_add_journal() { - let temp_dir = init_project(); +fn test_work_add_journal() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1015,13 +1055,14 @@ fn test_work_add_journal() { ], &["work", "show", &format!("WI-{}-001", date)], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_work_add_ref() { - let temp_dir = init_project(); +fn test_work_add_ref() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1037,13 +1078,14 @@ fn test_work_add_ref() { ], &["work", "get", &format!("WI-{}-001", date), "refs"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_work_remove_acceptance_criteria() { - let temp_dir = init_project(); +fn test_work_remove_acceptance_criteria() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1073,20 +1115,22 @@ fn test_work_remove_acceptance_criteria() { ], &["work", "show", &format!("WI-{}-001", date)], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_work_get_nonexistent() { - let temp_dir = init_project(); +fn test_work_get_nonexistent() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( temp_dir.path(), &[&["work", "get", "WI-9999-99-999", "title"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } // ============================================================================ @@ -1094,9 +1138,9 @@ fn test_work_get_nonexistent() { // ============================================================================ #[test] -fn test_field_alias_ac() { +fn test_field_alias_ac() -> common::TestResult { // 'ac' should resolve to 'acceptance_criteria' - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1112,14 +1156,15 @@ fn test_field_alias_ac() { ], &["work", "get", &format!("WI-{}-001", date), "ac"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_field_alias_desc() { +fn test_field_alias_desc() -> common::TestResult { // 'desc' should resolve to 'description' - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1135,14 +1180,15 @@ fn test_field_alias_desc() { ], &["work", "get", &format!("WI-{}-001", date), "desc"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_field_alias_desc_under_legacy_prefix() { +fn test_field_alias_desc_under_legacy_prefix() -> common::TestResult { // content.desc should resolve to description on work items - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1158,14 +1204,15 @@ fn test_field_alias_desc_under_legacy_prefix() { ], &["work", "get", &format!("WI-{}-001", date), "description"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_field_alias_desc_not_global_on_adr() { +fn test_field_alias_desc_not_global_on_adr() -> common::TestResult { // desc is not a valid ADR root field alias and should not be rewritten globally - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1174,13 +1221,14 @@ fn test_field_alias_desc_not_global_on_adr() { &["adr", "new", "Alias Scope"], &["adr", "set", "ADR-0001", "desc", "nope"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_tick_rejects_nested_path() { - let temp_dir = init_project(); +fn test_tick_rejects_nested_path() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1204,8 +1252,9 @@ fn test_tick_rejects_nested_path() { "done", ], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } // ============================================================================ @@ -1213,8 +1262,8 @@ fn test_tick_rejects_nested_path() { // ============================================================================ #[test] -fn test_adr_get_nested_path() { - let temp_dir = init_project(); +fn test_adr_get_nested_path() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1240,13 +1289,14 @@ fn test_adr_get_nested_path() { &["adr", "get", "ADR-0001", "alt[0].cons"], &["adr", "get", "ADR-0001", "alternatives[0]"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_set_nested_path() { - let temp_dir = init_project(); +fn test_adr_set_nested_path() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1277,13 +1327,14 @@ fn test_adr_set_nested_path() { ], &["adr", "get", "ADR-0001", "alt[0].rejection_reason"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_add_nested_path() { - let temp_dir = init_project(); +fn test_adr_add_nested_path() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1304,13 +1355,14 @@ fn test_adr_add_nested_path() { &["adr", "add", "ADR-0001", "alt[0].cons", "Slow"], &["adr", "get", "ADR-0001", "alt[0].cons"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_nested_path_rejects_extra_segments() { - let temp_dir = init_project(); +fn test_adr_nested_path_rejects_extra_segments() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1328,13 +1380,14 @@ fn test_adr_nested_path_rejects_extra_segments() { ], &["adr", "get", "ADR-0001", "alt[0].pros[0].oops"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_add_nested_path_rejects_indexed_terminal() { - let temp_dir = init_project(); +fn test_adr_add_nested_path_rejects_indexed_terminal() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1352,13 +1405,14 @@ fn test_adr_add_nested_path_rejects_indexed_terminal() { ], &["adr", "add", "ADR-0001", "alt[0].pros[999]", "Ignored"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_get_nested_scalar_rejects_index() { - let temp_dir = init_project(); +fn test_adr_get_nested_scalar_rejects_index() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1376,13 +1430,14 @@ fn test_adr_get_nested_scalar_rejects_index() { ], &["adr", "get", "ADR-0001", "alt[0].text[0]"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_remove_nested_path() { - let temp_dir = init_project(); +fn test_adr_remove_nested_path() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1412,13 +1467,14 @@ fn test_adr_remove_nested_path() { &["adr", "remove", "ADR-0001", "alt[0]"], &["adr", "get", "ADR-0001", "alternatives"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_work_get_nested_scalar_rejects_index() { - let temp_dir = init_project(); +fn test_work_get_nested_scalar_rejects_index() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let wi_id = format!("WI-{}-001", date); @@ -1429,13 +1485,14 @@ fn test_work_get_nested_scalar_rejects_index() { &["work", "add", &wi_id, "journal", "Did something"], &["work", "get", &wi_id, "journal[0].content[0]"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_remove_nested_path_requires_selector() { - let temp_dir = init_project(); +fn test_adr_remove_nested_path_requires_selector() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1453,13 +1510,14 @@ fn test_adr_remove_nested_path_requires_selector() { ], &["adr", "remove", "ADR-0001", "alt[0].cons"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_edit_tick_updates_alternative_root() { - let temp_dir = init_project(); +fn test_adr_edit_tick_updates_alternative_root() -> common::TestResult { + let temp_dir = init_project()?; let output = run_commands( temp_dir.path(), @@ -1478,18 +1536,19 @@ fn test_adr_edit_tick_updates_alternative_root() { ], &["adr", "get", "ADR-0001", "alternatives"], ], - ); + )?; assert!( output.contains("Marked 'Option A' as accepted"), "output: {}", output ); assert!(output.contains("[accepted] Option A"), "output: {}", output); + Ok(()) } #[test] -fn test_adr_edit_tick_updates_indexed_alternative_item() { - let temp_dir = init_project(); +fn test_adr_edit_tick_updates_indexed_alternative_item() -> common::TestResult { + let temp_dir = init_project()?; let output = run_commands( temp_dir.path(), @@ -1499,7 +1558,7 @@ fn test_adr_edit_tick_updates_indexed_alternative_item() { &["adr", "edit", "ADR-0001", "alt[0]", "--tick", "accepted"], &["adr", "get", "ADR-0001", "alternatives[0].status"], ], - ); + )?; assert!( output.contains("Marked 'Option A' as accepted"), "output: {}", @@ -1510,11 +1569,12 @@ fn test_adr_edit_tick_updates_indexed_alternative_item() { "output: {}", output ); + Ok(()) } #[test] -fn test_adr_edit_tick_rejects_work_item_status_names() { - let temp_dir = init_project(); +fn test_adr_edit_tick_rejects_work_item_status_names() -> common::TestResult { + let temp_dir = init_project()?; let output = run_commands( temp_dir.path(), @@ -1532,18 +1592,19 @@ fn test_adr_edit_tick_rejects_work_item_status_names() { "0", ], ], - ); + )?; assert!(output.contains("error[E0820]"), "output: {}", output); assert!( output.contains("ADR tick status must be one of: accepted, considered, rejected"), "output: {}", output ); + Ok(()) } #[test] -fn test_remove_indexed_path_conflict() { - let temp_dir = init_project(); +fn test_remove_indexed_path_conflict() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1569,13 +1630,14 @@ fn test_remove_indexed_path_conflict() { "Bad", ], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_path_backward_compat() { - let temp_dir = init_project(); +fn test_path_backward_compat() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -1594,6 +1656,7 @@ fn test_path_backward_compat() { &["adr", "set", "ADR-0001", "govctl.title", "Compat Title"], &["adr", "get", "ADR-0001", "govctl.title"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } diff --git a/tests/test_errors.rs b/tests/test_errors.rs index f253493..c4d4da4 100644 --- a/tests/test_errors.rs +++ b/tests/test_errors.rs @@ -7,11 +7,11 @@ use std::fs; /// Test: RFC files fail check when they contain unknown fields rejected by schema #[test] -fn test_invalid_rfc_schema_check() { - let temp_dir = init_project(); +fn test_invalid_rfc_schema_check() -> common::TestResult { + let temp_dir = init_project()?; let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.json"), @@ -26,21 +26,21 @@ fn test_invalid_rfc_schema_check() { "sections": [], "unexpected": true }"#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); + let output = run_commands(temp_dir.path(), &[&["check"]])?; assert!(output.contains("error[E0101]"), "output: {}", output); assert!(output.contains("rfc.schema.json"), "output: {}", output); + Ok(()) } /// Test: Clause files fail check when they contain unknown fields rejected by schema #[test] -fn test_invalid_clause_schema_check() { - let temp_dir = init_project(); +fn test_invalid_clause_schema_check() -> common::TestResult { + let temp_dir = init_project()?; let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.json"), @@ -54,8 +54,7 @@ fn test_invalid_clause_schema_check() { "created": "2026-01-01", "sections": [{"title": "Test", "clauses": ["clauses/C-TEST.json"]}] }"#, - ) - .unwrap(); + )?; fs::write( rfc_dir.join("clauses/C-TEST.json"), @@ -66,23 +65,23 @@ fn test_invalid_clause_schema_check() { "text": "Clause text", "unexpected": "should fail schema validation" }"#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); + let output = run_commands(temp_dir.path(), &[&["check"]])?; assert!(output.contains("error[E0201]"), "output: {}", output); assert!(output.contains("clause.schema.json"), "output: {}", output); + Ok(()) } /// Test: Clause claims superseded_by a non-existent clause #[test] -fn test_broken_superseded_check() { - let temp_dir = init_project(); +fn test_broken_superseded_check() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); // Create RFC with broken supersession let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.json"), @@ -108,8 +107,7 @@ fn test_broken_superseded_check() { } ] }"#, - ) - .unwrap(); + )?; // C-OLD claims to be superseded by C-NONEXISTENT (which doesn't exist) fs::write( @@ -123,8 +121,7 @@ fn test_broken_superseded_check() { "superseded_by": "C-NONEXISTENT", "since": "1.0.0" }"#, - ) - .unwrap(); + )?; fs::write( rfc_dir.join("clauses/C-NEW.json"), @@ -136,22 +133,22 @@ fn test_broken_superseded_check() { "text": "This is the new clause.", "since": "1.0.0" }"#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: RFC has invalid status/phase combination (draft + stable) #[test] -fn test_invalid_transition_check() { - let temp_dir = init_project(); +fn test_invalid_transition_check() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); // Create RFC with invalid state let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.json"), @@ -177,8 +174,7 @@ fn test_invalid_transition_check() { } ] }"#, - ) - .unwrap(); + )?; fs::write( rfc_dir.join("clauses/C-TEST.json"), @@ -190,11 +186,11 @@ fn test_invalid_transition_check() { "text": "A test clause in an invalid RFC.", "since": "0.1.0" }"#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } // ============================================================================= @@ -203,12 +199,12 @@ fn test_invalid_transition_check() { /// Test: Valid RFC TOML in [govctl] wire format passes check #[test] -fn test_valid_rfc_toml_wire_format() { - let temp_dir = init_project(); +fn test_valid_rfc_toml_wire_format() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.toml"), @@ -227,21 +223,21 @@ created = "2026-01-01" [[sections]] title = "Summary" "#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: Valid clause TOML in [govctl]+[content] wire format passes check #[test] -fn test_valid_clause_toml_wire_format() { - let temp_dir = init_project(); +fn test_valid_clause_toml_wire_format() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.toml"), @@ -261,8 +257,7 @@ created = "2026-01-01" title = "Spec" clauses = ["clauses/C-TEST.toml"] "#, - ) - .unwrap(); + )?; fs::write( rfc_dir.join("clauses/C-TEST.toml"), @@ -279,21 +274,21 @@ since = "0.1.0" [content] text = "Clause body text." "#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: RFC TOML in wire format rejects unknown fields in [govctl] #[test] -fn test_invalid_rfc_toml_wire_unknown_field() { - let temp_dir = init_project(); +fn test_invalid_rfc_toml_wire_unknown_field() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.toml"), @@ -311,21 +306,21 @@ unexpected = "extra field" [[sections]] title = "Summary" "#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: Clause TOML in wire format rejects unknown fields in [content] #[test] -fn test_invalid_clause_toml_wire_unknown_field() { - let temp_dir = init_project(); +fn test_invalid_clause_toml_wire_unknown_field() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.toml"), @@ -343,8 +338,7 @@ created = "2026-01-01" title = "Spec" clauses = ["clauses/C-BAD.toml"] "#, - ) - .unwrap(); + )?; fs::write( rfc_dir.join("clauses/C-BAD.toml"), @@ -358,21 +352,21 @@ kind = "normative" text = "Body." unexpected = "extra field" "#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: RFC TOML in wire format rejects missing required field (owners) #[test] -fn test_invalid_rfc_toml_wire_missing_required() { - let temp_dir = init_project(); +fn test_invalid_rfc_toml_wire_missing_required() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.toml"), @@ -388,21 +382,21 @@ created = "2026-01-01" [[sections]] title = "Summary" "#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: Clause TOML in wire format rejects missing [content].text #[test] -fn test_invalid_clause_toml_wire_missing_text() { - let temp_dir = init_project(); +fn test_invalid_clause_toml_wire_missing_text() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.toml"), @@ -420,8 +414,7 @@ created = "2026-01-01" title = "Spec" clauses = ["clauses/C-NOTEXT.toml"] "#, - ) - .unwrap(); + )?; fs::write( rfc_dir.join("clauses/C-NOTEXT.toml"), @@ -433,21 +426,21 @@ kind = "normative" [content] "#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: Legacy flat RFC TOML is still accepted via normalization #[test] -fn test_legacy_flat_rfc_toml_accepted() { - let temp_dir = init_project(); +fn test_legacy_flat_rfc_toml_accepted() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.toml"), @@ -462,21 +455,21 @@ created = "2026-01-01" [[sections]] title = "Summary" "#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: Legacy flat clause TOML is still accepted via normalization #[test] -fn test_legacy_flat_clause_toml_accepted() { - let temp_dir = init_project(); +fn test_legacy_flat_clause_toml_accepted() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.toml"), @@ -492,8 +485,7 @@ created = "2026-01-01" title = "Spec" clauses = ["clauses/C-FLAT.toml"] "#, - ) - .unwrap(); + )?; fs::write( rfc_dir.join("clauses/C-FLAT.toml"), @@ -503,17 +495,17 @@ kind = "normative" status = "active" text = "Legacy flat format body." "#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } /// Test: ADR files fail check when they contain unknown fields rejected by schema #[test] -fn test_invalid_adr_schema_check() { - let temp_dir = init_project(); +fn test_invalid_adr_schema_check() -> common::TestResult { + let temp_dir = init_project()?; fs::write( temp_dir.path().join("gov/adr/ADR-0001-invalid.toml"), @@ -530,18 +522,18 @@ decision = "Decision" consequences = "Consequences" unexpected = "should fail schema validation" "#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); + let output = run_commands(temp_dir.path(), &[&["check"]])?; assert!(output.contains("error[E0301]"), "output: {}", output); assert!(output.contains("adr.schema.json"), "output: {}", output); + Ok(()) } /// Test: Work item files fail check when they contain unknown fields rejected by schema #[test] -fn test_invalid_work_schema_check() { - let temp_dir = init_project(); +fn test_invalid_work_schema_check() -> common::TestResult { + let temp_dir = init_project()?; fs::write( temp_dir.path().join("gov/work/2026-01-01-invalid.toml"), @@ -556,18 +548,18 @@ created = "2026-01-01" description = "Work description" unexpected = "should fail schema validation" "#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); + let output = run_commands(temp_dir.path(), &[&["check"]])?; assert!(output.contains("error[E0401]"), "output: {}", output); assert!(output.contains("work.schema.json"), "output: {}", output); + Ok(()) } /// Test: Release files fail check when they contain unknown fields rejected by schema #[test] -fn test_invalid_release_schema_check() { - let temp_dir = init_project(); +fn test_invalid_release_schema_check() -> common::TestResult { + let temp_dir = init_project()?; fs::write( temp_dir.path().join("gov/releases.toml"), @@ -579,18 +571,18 @@ version = "1.0.0" date = "2026-01-01" unexpected = "should fail schema validation" "#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); + let output = run_commands(temp_dir.path(), &[&["check"]])?; assert!(output.contains("error[E0704]"), "output: {}", output); assert!(output.contains("release.schema.json"), "output: {}", output); + Ok(()) } /// Test: Verification guard files fail check when they contain unknown fields rejected by schema #[test] -fn test_invalid_guard_schema_check() { - let temp_dir = init_project(); +fn test_invalid_guard_schema_check() -> common::TestResult { + let temp_dir = init_project()?; fs::write( temp_dir.path().join("gov/guard/check.toml"), @@ -603,10 +595,10 @@ title = "Invalid Guard" command = "true" unexpected = "should fail schema validation" "#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); + let output = run_commands(temp_dir.path(), &[&["check"]])?; assert!(output.contains("error[E1001]"), "output: {}", output); assert!(output.contains("guard.schema.json"), "output: {}", output); + Ok(()) } diff --git a/tests/test_guard.rs b/tests/test_guard.rs index 7e202c8..eb65789 100644 --- a/tests/test_guard.rs +++ b/tests/test_guard.rs @@ -7,10 +7,10 @@ use std::fs; use std::path::Path; #[test] -fn test_guard_new_scaffolds_file() { - let temp_dir = init_project(); +fn test_guard_new_scaffolds_file() -> common::TestResult { + let temp_dir = init_project()?; - let output = run_commands(temp_dir.path(), &[&["guard", "new", "clippy lint"]]); + let output = run_commands(temp_dir.path(), &[&["guard", "new", "clippy lint"]])?; assert!(output.contains("exit: 0"), "output: {}", output); assert!(output.contains("GUARD-CLIPPY-LINT"), "output: {}", output); assert!( @@ -22,67 +22,72 @@ fn test_guard_new_scaffolds_file() { let guard_path = temp_dir.path().join("gov/guard/clippy-lint.toml"); assert!(guard_path.exists(), "guard file should be created"); - let content = fs::read_to_string(&guard_path).unwrap(); + let content = fs::read_to_string(&guard_path)?; assert!(content.contains("GUARD-CLIPPY-LINT")); assert!(content.contains("clippy lint")); assert!(content.contains("[check]")); + Ok(()) } #[test] -fn test_guard_new_duplicate_rejected() { - let temp_dir = init_project(); - write_guard(temp_dir.path(), "GUARD-ECHO", "true"); +fn test_guard_new_duplicate_rejected() -> common::TestResult { + let temp_dir = init_project()?; + write_guard(temp_dir.path(), "GUARD-ECHO", "true")?; - let output = run_commands(temp_dir.path(), &[&["guard", "new", "echo"]]); + let output = run_commands(temp_dir.path(), &[&["guard", "new", "echo"]])?; assert!(output.contains("exit: 1"), "output: {}", output); assert!( output.contains("already exists"), "should reject duplicate: {}", output ); + Ok(()) } #[test] -fn test_guard_new_invalid_title_rejected_with_code() { - let temp_dir = init_project(); +fn test_guard_new_invalid_title_rejected_with_code() -> common::TestResult { + let temp_dir = init_project()?; - let output = run_commands(temp_dir.path(), &[&["guard", "new", "123 !!!"]]); + let output = run_commands(temp_dir.path(), &[&["guard", "new", "123 !!!"]])?; assert!(output.contains("exit: 1"), "output: {}", output); assert!(output.contains("error[E1006]"), "output: {}", output); + Ok(()) } #[test] -fn test_guard_list() { - let temp_dir = init_project(); - write_guard(temp_dir.path(), "GUARD-ALPHA", "true"); - write_guard(temp_dir.path(), "GUARD-BETA", "echo ok"); +fn test_guard_list() -> common::TestResult { + let temp_dir = init_project()?; + write_guard(temp_dir.path(), "GUARD-ALPHA", "true")?; + write_guard(temp_dir.path(), "GUARD-BETA", "echo ok")?; - let output = run_commands(temp_dir.path(), &[&["guard", "list"]]); + let output = run_commands(temp_dir.path(), &[&["guard", "list"]])?; assert!(output.contains("exit: 0"), "output: {}", output); assert!(output.contains("GUARD-ALPHA"), "output: {}", output); assert!(output.contains("GUARD-BETA"), "output: {}", output); + Ok(()) } #[test] -fn test_guard_show() { - let temp_dir = init_project(); - write_guard(temp_dir.path(), "GUARD-ECHO", "echo hello"); +fn test_guard_show() -> common::TestResult { + let temp_dir = init_project()?; + write_guard(temp_dir.path(), "GUARD-ECHO", "echo hello")?; - let output = run_commands(temp_dir.path(), &[&["guard", "show", "GUARD-ECHO"]]); + let output = run_commands(temp_dir.path(), &[&["guard", "show", "GUARD-ECHO"]])?; assert!(output.contains("exit: 0"), "output: {}", output); assert!(output.contains("GUARD-ECHO"), "output: {}", output); assert!(output.contains("echo hello"), "output: {}", output); + Ok(()) } #[test] -fn test_guard_show_json_output() { - let temp_dir = init_project(); - write_guard(temp_dir.path(), "GUARD-ECHO", "echo hello"); +fn test_guard_show_json_output() -> common::TestResult { + let temp_dir = init_project()?; + write_guard(temp_dir.path(), "GUARD-ECHO", "echo hello")?; let output = run_commands( temp_dir.path(), &[&["guard", "show", "GUARD-ECHO", "-o", "json"]], - ); + )?; assert!( output.contains("\"id\": \"GUARD-ECHO\""), "output: {}", @@ -94,21 +99,23 @@ fn test_guard_show_json_output() { output ); assert!(output.contains("exit: 0"), "output: {}", output); + Ok(()) } #[test] -fn test_guard_show_missing_returns_coded_error() { - let temp_dir = init_project(); +fn test_guard_show_missing_returns_coded_error() -> common::TestResult { + let temp_dir = init_project()?; - let output = run_commands(temp_dir.path(), &[&["guard", "show", "GUARD-MISSING"]]); + let output = run_commands(temp_dir.path(), &[&["guard", "show", "GUARD-MISSING"]])?; assert!(output.contains("exit: 1"), "output: {}", output); assert!(output.contains("error[E1002]"), "output: {}", output); + Ok(()) } #[test] -fn test_guard_get_field() { - let temp_dir = init_project(); - write_guard(temp_dir.path(), "GUARD-ECHO", "echo hello"); +fn test_guard_get_field() -> common::TestResult { + let temp_dir = init_project()?; + write_guard(temp_dir.path(), "GUARD-ECHO", "echo hello")?; let output = run_commands( temp_dir.path(), @@ -116,15 +123,16 @@ fn test_guard_get_field() { &["guard", "get", "GUARD-ECHO", "command"], &["guard", "get", "GUARD-ECHO", "title"], ], - ); + )?; assert!(output.contains("echo hello"), "output: {}", output); assert!(output.contains("GUARD-ECHO"), "output: {}", output); + Ok(()) } #[test] -fn test_guard_set_command() { - let temp_dir = init_project(); - write_guard(temp_dir.path(), "GUARD-ECHO", "echo old"); +fn test_guard_set_command() -> common::TestResult { + let temp_dir = init_project()?; + write_guard(temp_dir.path(), "GUARD-ECHO", "echo old")?; let output = run_commands( temp_dir.path(), @@ -132,30 +140,32 @@ fn test_guard_set_command() { &["guard", "set", "GUARD-ECHO", "command", "echo new"], &["guard", "get", "GUARD-ECHO", "command"], ], - ); + )?; assert!(output.contains("echo new"), "output: {}", output); + Ok(()) } #[test] -fn test_guard_delete_unreferenced() { - let temp_dir = init_project(); - write_guard(temp_dir.path(), "GUARD-TEMP", "true"); +fn test_guard_delete_unreferenced() -> common::TestResult { + let temp_dir = init_project()?; + write_guard(temp_dir.path(), "GUARD-TEMP", "true")?; let guard_path = temp_dir.path().join("gov/guard/guard-temp.toml"); assert!(guard_path.exists()); - let output = run_commands(temp_dir.path(), &[&["guard", "delete", "GUARD-TEMP"]]); + let output = run_commands(temp_dir.path(), &[&["guard", "delete", "GUARD-TEMP"]])?; assert!(output.contains("exit: 0"), "output: {}", output); assert!(!guard_path.exists(), "guard file should be deleted"); + Ok(()) } #[test] -fn test_guard_delete_blocked_by_config() { - let temp_dir = init_project(); - write_guard(temp_dir.path(), "GUARD-IMPORTANT", "true"); - append_verification_config(temp_dir.path(), true, &["GUARD-IMPORTANT"]); +fn test_guard_delete_blocked_by_config() -> common::TestResult { + let temp_dir = init_project()?; + write_guard(temp_dir.path(), "GUARD-IMPORTANT", "true")?; + append_verification_config(temp_dir.path(), true, &["GUARD-IMPORTANT"])?; - let output = run_commands(temp_dir.path(), &[&["guard", "delete", "GUARD-IMPORTANT"]]); + let output = run_commands(temp_dir.path(), &[&["guard", "delete", "GUARD-IMPORTANT"]])?; assert!(output.contains("exit: 1"), "output: {}", output); assert!( output.contains("still referenced"), @@ -167,54 +177,58 @@ fn test_guard_delete_blocked_by_config() { "should mention config: {}", output ); + Ok(()) } #[test] -fn test_guard_delete_blocked_by_work_item() { - let temp_dir = init_project(); - write_guard(temp_dir.path(), "GUARD-REQUIRED", "true"); - write_work_item_with_guard(temp_dir.path(), "GUARD-REQUIRED"); +fn test_guard_delete_blocked_by_work_item() -> common::TestResult { + let temp_dir = init_project()?; + write_guard(temp_dir.path(), "GUARD-REQUIRED", "true")?; + write_work_item_with_guard(temp_dir.path(), "GUARD-REQUIRED")?; - let output = run_commands(temp_dir.path(), &[&["guard", "delete", "GUARD-REQUIRED"]]); + let output = run_commands(temp_dir.path(), &[&["guard", "delete", "GUARD-REQUIRED"]])?; assert!(output.contains("exit: 1"), "output: {}", output); assert!( output.contains("still referenced"), "should block delete: {}", output ); + Ok(()) } #[test] -fn test_guard_delete_force_does_not_bypass_reference_checks() { - let temp_dir = init_project(); - write_guard(temp_dir.path(), "GUARD-FORCED", "true"); - append_verification_config(temp_dir.path(), true, &["GUARD-FORCED"]); +fn test_guard_delete_force_does_not_bypass_reference_checks() -> common::TestResult { + let temp_dir = init_project()?; + write_guard(temp_dir.path(), "GUARD-FORCED", "true")?; + append_verification_config(temp_dir.path(), true, &["GUARD-FORCED"])?; let output = run_commands( temp_dir.path(), &[&["guard", "delete", "GUARD-FORCED", "--force"]], - ); + )?; assert!(output.contains("exit: 1"), "output: {}", output); assert!( output.contains("still referenced"), "force should not bypass reference checks: {}", output ); + Ok(()) } #[test] -fn test_guard_delete_missing_returns_coded_error() { - let temp_dir = init_project(); +fn test_guard_delete_missing_returns_coded_error() -> common::TestResult { + let temp_dir = init_project()?; - let output = run_commands(temp_dir.path(), &[&["guard", "delete", "GUARD-MISSING"]]); + let output = run_commands(temp_dir.path(), &[&["guard", "delete", "GUARD-MISSING"]])?; assert!(output.contains("exit: 1"), "output: {}", output); assert!(output.contains("error[E1002]"), "output: {}", output); + Ok(()) } #[test] -fn test_guard_set_timeout_secs() { - let temp_dir = init_project(); - write_guard(temp_dir.path(), "GUARD-ECHO", "true"); +fn test_guard_set_timeout_secs() -> common::TestResult { + let temp_dir = init_project()?; + write_guard(temp_dir.path(), "GUARD-ECHO", "true")?; let output = run_commands( temp_dir.path(), @@ -222,14 +236,15 @@ fn test_guard_set_timeout_secs() { &["guard", "set", "GUARD-ECHO", "timeout_secs", "30"], &["guard", "get", "GUARD-ECHO", "timeout_secs"], ], - ); + )?; assert!(output.contains("30"), "output: {}", output); + Ok(()) } #[test] -fn test_guard_nested_object_root_edit_paths() { - let temp_dir = init_project(); - write_guard(temp_dir.path(), "GUARD-ECHO", "echo old"); +fn test_guard_nested_object_root_edit_paths() -> common::TestResult { + let temp_dir = init_project()?; + write_guard(temp_dir.path(), "GUARD-ECHO", "echo old")?; let output = run_commands( temp_dir.path(), @@ -252,7 +267,7 @@ fn test_guard_nested_object_root_edit_paths() { &["guard", "get", "GUARD-ECHO", "command"], &["guard", "get", "GUARD-ECHO", "timeout_secs"], ], - ); + )?; assert!( output.contains("Set GUARD-ECHO.check.command = echo nested legacy"), @@ -274,23 +289,33 @@ fn test_guard_nested_object_root_edit_paths() { "output: {}", output ); + Ok(()) } // --- Helpers --- -fn write_guard(dir: &Path, guard_id: &str, command: &str) { +fn write_guard( + dir: &Path, + guard_id: &str, + command: &str, +) -> Result<(), Box> { let path = dir .join("gov/guard") .join(format!("{}.toml", guard_id.to_lowercase())); let content = format!( "#:schema ../schema/guard.schema.json\n\n[govctl]\nid = \"{guard_id}\"\ntitle = \"{guard_id}\"\n\n[check]\ncommand = \"{command}\"\n" ); - fs::write(path, content).expect("guard file should be writable"); + fs::write(path, content)?; + Ok(()) } -fn append_verification_config(dir: &Path, enabled: bool, guard_ids: &[&str]) { +fn append_verification_config( + dir: &Path, + enabled: bool, + guard_ids: &[&str], +) -> Result<(), Box> { let config_path = dir.join("gov/config.toml"); - let existing = fs::read_to_string(&config_path).expect("config should exist"); + let existing = fs::read_to_string(&config_path)?; let default_guards = guard_ids .iter() .map(|id| format!("\"{id}\"")) @@ -299,13 +324,18 @@ fn append_verification_config(dir: &Path, enabled: bool, guard_ids: &[&str]) { let appended = format!( "{existing}\n[verification]\nenabled = {enabled}\ndefault_guards = [{default_guards}]\n" ); - fs::write(config_path, appended).expect("config should be writable"); + fs::write(config_path, appended)?; + Ok(()) } -fn write_work_item_with_guard(dir: &Path, guard_id: &str) { +fn write_work_item_with_guard( + dir: &Path, + guard_id: &str, +) -> Result<(), Box> { let path = dir.join("gov/work/2026-01-01-guarded-item.toml"); let content = format!( "#:schema ../schema/work.schema.json\n\n[govctl]\nid = \"WI-2026-01-01-001\"\ntitle = \"Guarded Item\"\nstatus = \"active\"\ncreated = \"2026-01-01\"\nstarted = \"2026-01-01\"\n\n[content]\ndescription = \"Guarded work item\"\n\n[[content.acceptance_criteria]]\ntext = \"done\"\nstatus = \"done\"\ncategory = \"chore\"\n\n[verification]\nrequired_guards = [\"{guard_id}\"]\n" ); - fs::write(path, content).expect("work item should be writable"); + fs::write(path, content)?; + Ok(()) } diff --git a/tests/test_happy_path.rs b/tests/test_happy_path.rs index e0257c9..2639b57 100644 --- a/tests/test_happy_path.rs +++ b/tests/test_happy_path.rs @@ -7,12 +7,12 @@ use std::fs; use std::path::Path; /// Create a minimal valid project with RFC, clause, ADR, and work item -fn setup_minimal_valid(dir: &Path, date: &str) { +fn setup_minimal_valid(dir: &Path, date: &str) -> common::TestResult { let wi1 = format!("WI-{}-001", date); // Create RFC with clause let rfc_dir = dir.join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.json"), @@ -38,8 +38,7 @@ fn setup_minimal_valid(dir: &Path, date: &str) { } ] }"#, - ) - .unwrap(); + )?; fs::write( rfc_dir.join("clauses/C-EXAMPLE.json"), @@ -51,8 +50,7 @@ fn setup_minimal_valid(dir: &Path, date: &str) { "text": "This is an example clause for testing.", "since": "1.0.0" }"#, - ) - .unwrap(); + )?; // Create ADR fs::write( @@ -70,8 +68,7 @@ context = "We need to test ADR functionality." decision = "We will create a test ADR." consequences = "Tests will pass." "#, - ) - .unwrap(); + )?; // Create work item via commands let commands: Vec> = vec![ @@ -90,74 +87,81 @@ consequences = "Tests will pass." ], ]; - let _ = run_dynamic_commands(dir, &commands); + let _ = run_dynamic_commands(dir, &commands)?; + Ok(()) } #[test] -fn test_minimal_valid_check() { - let temp_dir = init_project(); +fn test_minimal_valid_check() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); - setup_minimal_valid(temp_dir.path(), &date); + setup_minimal_valid(temp_dir.path(), &date)?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_minimal_valid_list_rfc() { - let temp_dir = init_project(); +fn test_minimal_valid_list_rfc() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); - setup_minimal_valid(temp_dir.path(), &date); + setup_minimal_valid(temp_dir.path(), &date)?; - let output = run_commands(temp_dir.path(), &[&["rfc", "list"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["rfc", "list"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_minimal_valid_list_clause() { - let temp_dir = init_project(); +fn test_minimal_valid_list_clause() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); - setup_minimal_valid(temp_dir.path(), &date); + setup_minimal_valid(temp_dir.path(), &date)?; - let output = run_commands(temp_dir.path(), &[&["clause", "list"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["clause", "list"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_minimal_valid_list_adr() { - let temp_dir = init_project(); +fn test_minimal_valid_list_adr() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); - setup_minimal_valid(temp_dir.path(), &date); + setup_minimal_valid(temp_dir.path(), &date)?; - let output = run_commands(temp_dir.path(), &[&["adr", "list"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["adr", "list"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_minimal_valid_list_work() { - let temp_dir = init_project(); +fn test_minimal_valid_list_work() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); - setup_minimal_valid(temp_dir.path(), &date); + setup_minimal_valid(temp_dir.path(), &date)?; - let output = run_commands(temp_dir.path(), &[&["work", "list"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["work", "list"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_minimal_valid_status() { - let temp_dir = init_project(); +fn test_minimal_valid_status() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); - setup_minimal_valid(temp_dir.path(), &date); + setup_minimal_valid(temp_dir.path(), &date)?; - let output = run_commands(temp_dir.path(), &[&["status"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["status"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_minimal_valid_full_workflow() { - let temp_dir = init_project(); +fn test_minimal_valid_full_workflow() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); - setup_minimal_valid(temp_dir.path(), &date); + setup_minimal_valid(temp_dir.path(), &date)?; let output = run_commands( temp_dir.path(), @@ -169,6 +173,7 @@ fn test_minimal_valid_full_workflow() { &["work", "list"], &["status"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } diff --git a/tests/test_help.rs b/tests/test_help.rs index 1856150..515be5c 100644 --- a/tests/test_help.rs +++ b/tests/test_help.rs @@ -5,100 +5,111 @@ mod common; use common::{normalize_output, run_commands, today}; #[test] -fn test_rfc_get_help() { - let temp_dir = tempfile::TempDir::new().expect("failed to create temp dir"); +fn test_rfc_get_help() -> common::TestResult { + let temp_dir = tempfile::TempDir::new()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["rfc", "get", "--help"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["rfc", "get", "--help"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_rfc_root_help() { - let temp_dir = tempfile::TempDir::new().expect("failed to create temp dir"); +fn test_rfc_root_help() -> common::TestResult { + let temp_dir = tempfile::TempDir::new()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["rfc", "--help"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["rfc", "--help"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_get_help() { - let temp_dir = tempfile::TempDir::new().expect("failed to create temp dir"); +fn test_adr_get_help() -> common::TestResult { + let temp_dir = tempfile::TempDir::new()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["adr", "get", "--help"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["adr", "get", "--help"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_root_help() { - let temp_dir = tempfile::TempDir::new().expect("failed to create temp dir"); +fn test_adr_root_help() -> common::TestResult { + let temp_dir = tempfile::TempDir::new()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["adr", "--help"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["adr", "--help"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_work_get_help() { - let temp_dir = tempfile::TempDir::new().expect("failed to create temp dir"); +fn test_work_get_help() -> common::TestResult { + let temp_dir = tempfile::TempDir::new()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["work", "get", "--help"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["work", "get", "--help"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_work_root_help() { - let temp_dir = tempfile::TempDir::new().expect("failed to create temp dir"); +fn test_work_root_help() -> common::TestResult { + let temp_dir = tempfile::TempDir::new()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["work", "--help"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["work", "--help"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_clause_root_help() { - let temp_dir = tempfile::TempDir::new().expect("failed to create temp dir"); +fn test_clause_root_help() -> common::TestResult { + let temp_dir = tempfile::TempDir::new()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["clause", "--help"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["clause", "--help"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_clause_edit_help() { - let temp_dir = tempfile::TempDir::new().expect("failed to create temp dir"); +fn test_clause_edit_help() -> common::TestResult { + let temp_dir = tempfile::TempDir::new()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["clause", "edit", "--help"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["clause", "edit", "--help"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_guard_root_help() { - let temp_dir = tempfile::TempDir::new().expect("failed to create temp dir"); +fn test_guard_root_help() -> common::TestResult { + let temp_dir = tempfile::TempDir::new()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["guard", "--help"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["guard", "--help"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_adr_tick_help() { - let temp_dir = tempfile::TempDir::new().expect("failed to create temp dir"); +fn test_adr_tick_help() -> common::TestResult { + let temp_dir = tempfile::TempDir::new()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["adr", "tick", "--help"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["adr", "tick", "--help"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_work_tick_help() { - let temp_dir = tempfile::TempDir::new().expect("failed to create temp dir"); +fn test_work_tick_help() -> common::TestResult { + let temp_dir = tempfile::TempDir::new()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["work", "tick", "--help"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["work", "tick", "--help"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } diff --git a/tests/test_init.rs b/tests/test_init.rs index 07afda5..7ace806 100644 --- a/tests/test_init.rs +++ b/tests/test_init.rs @@ -8,30 +8,31 @@ use tempfile::TempDir; /// Test: init creates .gitignore with .govctl.lock if not exists #[test] -fn test_init_creates_gitignore() { - let temp_dir = TempDir::new().expect("failed to create temp dir"); +fn test_init_creates_gitignore() -> common::TestResult { + let temp_dir = TempDir::new()?; // Run init - let output = run_commands(temp_dir.path(), &[&["init"]]); + let output = run_commands(temp_dir.path(), &[&["init"]])?; assert!(output.contains("Project initialized")); // .gitignore should exist and contain .govctl.lock let gitignore_path = temp_dir.path().join(".gitignore"); assert!(gitignore_path.exists(), ".gitignore should be created"); - let content = fs::read_to_string(&gitignore_path).unwrap(); + let content = fs::read_to_string(&gitignore_path)?; assert!( content.contains(".govctl.lock"), ".gitignore should contain .govctl.lock" ); + Ok(()) } /// Test: init installs bundled artifact JSON Schemas #[test] -fn test_init_creates_artifact_schema_files() { - let temp_dir = TempDir::new().expect("failed to create temp dir"); +fn test_init_creates_artifact_schema_files() -> common::TestResult { + let temp_dir = TempDir::new()?; - let output = run_commands(temp_dir.path(), &[&["init"]]); + let output = run_commands(temp_dir.path(), &[&["init"]])?; assert!(output.contains("Project initialized")); for filename in [ @@ -53,23 +54,24 @@ fn test_init_creates_artifact_schema_files() { temp_dir.path().join("gov/guard").exists(), "guard directory should exist after init" ); + Ok(()) } /// Test: init appends .govctl.lock to existing .gitignore #[test] -fn test_init_appends_to_existing_gitignore() { - let temp_dir = TempDir::new().expect("failed to create temp dir"); +fn test_init_appends_to_existing_gitignore() -> common::TestResult { + let temp_dir = TempDir::new()?; // Create existing .gitignore let gitignore_path = temp_dir.path().join(".gitignore"); - fs::write(&gitignore_path, "# Existing content\ntarget/\n").unwrap(); + fs::write(&gitignore_path, "# Existing content\ntarget/\n")?; // Run init - let output = run_commands(temp_dir.path(), &[&["init"]]); + let output = run_commands(temp_dir.path(), &[&["init"]])?; assert!(output.contains("Project initialized")); // .gitignore should still exist with both old and new content - let content = fs::read_to_string(&gitignore_path).unwrap(); + let content = fs::read_to_string(&gitignore_path)?; assert!( content.contains("target/"), ".gitignore should retain existing content" @@ -78,37 +80,39 @@ fn test_init_appends_to_existing_gitignore() { content.contains(".govctl.lock"), ".gitignore should have .govctl.lock appended" ); + Ok(()) } /// Test: init doesn't duplicate .govctl.lock if already present #[test] -fn test_init_no_duplicate_gitignore_entry() { - let temp_dir = TempDir::new().expect("failed to create temp dir"); +fn test_init_no_duplicate_gitignore_entry() -> common::TestResult { + let temp_dir = TempDir::new()?; // Create .gitignore with .govctl.lock already present let gitignore_path = temp_dir.path().join(".gitignore"); - fs::write(&gitignore_path, ".govctl.lock\n").unwrap(); + fs::write(&gitignore_path, ".govctl.lock\n")?; // Run init - let output = run_commands(temp_dir.path(), &[&["init"]]); + let output = run_commands(temp_dir.path(), &[&["init"]])?; assert!(output.contains("Project initialized")); // Should not have duplicate entry - let content = fs::read_to_string(&gitignore_path).unwrap(); + let content = fs::read_to_string(&gitignore_path)?; let count = content.matches(".govctl.lock").count(); assert_eq!( count, 1, ".gitignore should not have duplicate .govctl.lock entries" ); + Ok(()) } /// Test: custom docs_output is respected for render #[test] -fn test_init_custom_docs_output() { - let temp_dir = TempDir::new().expect("failed to create temp dir"); +fn test_init_custom_docs_output() -> common::TestResult { + let temp_dir = TempDir::new()?; // Run init first - run_commands(temp_dir.path(), &[&["init"]]); + run_commands(temp_dir.path(), &[&["init"]])?; // Update gov/config.toml with custom docs_output let config_path = temp_dir.path().join("gov/config.toml"); @@ -118,27 +122,28 @@ name = "test-project" [paths] docs_output = "documentation" "#; - fs::write(&config_path, config_content).unwrap(); + fs::write(&config_path, config_content)?; // Create an RFC - let output = run_commands(temp_dir.path(), &[&["rfc", "new", "Test RFC"]]); + let output = run_commands(temp_dir.path(), &[&["rfc", "new", "Test RFC"]])?; assert!(output.contains("Created RFC")); // Render the first RFC (RFC-0001 by default on fresh init) - let output = run_commands(temp_dir.path(), &[&["rfc", "render", "RFC-0001"]]); + let output = run_commands(temp_dir.path(), &[&["rfc", "render", "RFC-0001"]])?; eprintln!("render output: {}", output); // Rendered output should be under documentation/rfc/ let docs_dir = temp_dir.path().join("documentation/rfc"); assert!(docs_dir.exists(), "docs should be under documentation/rfc/"); + Ok(()) } /// Test: custom docs_output with ADR render #[test] -fn test_init_custom_paths_combined() { - let temp_dir = TempDir::new().expect("failed to create temp dir"); +fn test_init_custom_paths_combined() -> common::TestResult { + let temp_dir = TempDir::new()?; - run_commands(temp_dir.path(), &[&["init"]]); + run_commands(temp_dir.path(), &[&["init"]])?; let config_path = temp_dir.path().join("gov/config.toml"); let config_content = r#"[project] @@ -147,18 +152,19 @@ name = "test-project" [paths] docs_output = "output/docs" "#; - fs::write(&config_path, config_content).unwrap(); + fs::write(&config_path, config_content)?; - let output = run_commands(temp_dir.path(), &[&["adr", "new", "Test ADR"]]); + let output = run_commands(temp_dir.path(), &[&["adr", "new", "Test ADR"]])?; assert!(output.contains("Created ADR"), "output: {}", output); let adr_dir = temp_dir.path().join("gov/adr"); assert!( - adr_dir.exists() && adr_dir.read_dir().unwrap().count() > 0, + adr_dir.exists() && adr_dir.read_dir()?.count() > 0, "ADR should be under gov/adr/" ); - let _output = run_commands(temp_dir.path(), &[&["adr", "render", "ADR-0001"]]); + let _output = run_commands(temp_dir.path(), &[&["adr", "render", "ADR-0001"]])?; let docs_dir = temp_dir.path().join("output/docs/adr"); assert!(docs_dir.exists(), "docs should be under output/docs/adr/"); + Ok(()) } diff --git a/tests/test_lifecycle.rs b/tests/test_lifecycle.rs index e5d107e..70415fe 100644 --- a/tests/test_lifecycle.rs +++ b/tests/test_lifecycle.rs @@ -12,8 +12,8 @@ use std::fs; // ============================================================================ #[test] -fn test_finalize_draft_to_normative() { - let temp_dir = init_project(); +fn test_finalize_draft_to_normative() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -24,13 +24,14 @@ fn test_finalize_draft_to_normative() { &["rfc", "finalize", "RFC-0001", "normative"], &["rfc", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_finalize_draft_to_deprecated() { - let temp_dir = init_project(); +fn test_finalize_draft_to_deprecated() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -40,13 +41,14 @@ fn test_finalize_draft_to_deprecated() { &["rfc", "finalize", "RFC-0001", "deprecated"], &["rfc", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_finalize_normative_to_deprecated() { - let temp_dir = init_project(); +fn test_finalize_normative_to_deprecated() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -57,13 +59,14 @@ fn test_finalize_normative_to_deprecated() { &["rfc", "deprecate", "RFC-0001"], &["rfc", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_finalize_already_normative_fails() { - let temp_dir = init_project(); +fn test_finalize_already_normative_fails() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -73,29 +76,31 @@ fn test_finalize_already_normative_fails() { &["rfc", "finalize", "RFC-0001", "normative"], &["rfc", "finalize", "RFC-0001", "normative"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_finalize_nonexistent_rfc() { - let temp_dir = init_project(); +fn test_finalize_nonexistent_rfc() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( temp_dir.path(), &[&["rfc", "finalize", "RFC-9999", "normative"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_finalize_legacy_json_rfc_requires_migrate() { - let temp_dir = init_project(); +fn test_finalize_legacy_json_rfc_requires_migrate() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(&rfc_dir).unwrap(); + fs::create_dir_all(&rfc_dir)?; fs::write( rfc_dir.join("rfc.json"), r#"{ @@ -109,14 +114,14 @@ fn test_finalize_legacy_json_rfc_requires_migrate() { "sections": [], "changelog": [{ "version": "0.1.0", "date": "2026-01-01", "notes": "Initial draft" }] }"#, - ) - .unwrap(); + )?; let output = run_commands( temp_dir.path(), &[&["rfc", "finalize", "RFC-0001", "normative"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } // ============================================================================ @@ -124,8 +129,8 @@ fn test_finalize_legacy_json_rfc_requires_migrate() { // ============================================================================ #[test] -fn test_advance_spec_to_impl() { - let temp_dir = init_project(); +fn test_advance_spec_to_impl() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -136,13 +141,14 @@ fn test_advance_spec_to_impl() { &["rfc", "advance", "RFC-0001", "impl"], &["rfc", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_advance_impl_to_test() { - let temp_dir = init_project(); +fn test_advance_impl_to_test() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -154,13 +160,14 @@ fn test_advance_impl_to_test() { &["rfc", "advance", "RFC-0001", "test"], &["rfc", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_advance_test_to_stable() { - let temp_dir = init_project(); +fn test_advance_test_to_stable() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -173,14 +180,15 @@ fn test_advance_test_to_stable() { &["rfc", "advance", "RFC-0001", "stable"], &["rfc", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_advance_draft_to_impl_fails() { +fn test_advance_draft_to_impl_fails() -> common::TestResult { // Cannot advance draft RFC to impl phase - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -189,14 +197,15 @@ fn test_advance_draft_to_impl_fails() { &["rfc", "new", "Test RFC"], &["rfc", "advance", "RFC-0001", "impl"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_advance_skip_phase_fails() { +fn test_advance_skip_phase_fails() -> common::TestResult { // Cannot skip phases (e.g., spec -> test) - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -206,14 +215,15 @@ fn test_advance_skip_phase_fails() { &["rfc", "finalize", "RFC-0001", "normative"], &["rfc", "advance", "RFC-0001", "test"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_advance_backwards_fails() { +fn test_advance_backwards_fails() -> common::TestResult { // Cannot go backwards (e.g., impl -> spec) - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -224,22 +234,24 @@ fn test_advance_backwards_fails() { &["rfc", "advance", "RFC-0001", "impl"], &["rfc", "advance", "RFC-0001", "spec"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_advance_nonexistent_rfc() { - let temp_dir = init_project(); +fn test_advance_nonexistent_rfc() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["rfc", "advance", "RFC-9999", "impl"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["rfc", "advance", "RFC-9999", "impl"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_finalize_sets_updated_field() { - let temp_dir = init_project(); +fn test_finalize_sets_updated_field() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -249,13 +261,14 @@ fn test_finalize_sets_updated_field() { &["rfc", "finalize", "RFC-0001", "normative"], &["rfc", "get", "RFC-0001", "updated"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_advance_sets_updated_field() { - let temp_dir = init_project(); +fn test_advance_sets_updated_field() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -266,17 +279,18 @@ fn test_advance_sets_updated_field() { &["rfc", "advance", "RFC-0001", "impl"], &["rfc", "get", "RFC-0001", "updated"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_deprecate_legacy_json_clause_requires_migrate() { - let temp_dir = init_project(); +fn test_deprecate_legacy_json_clause_requires_migrate() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let clauses_dir = temp_dir.path().join("gov/rfc/RFC-0001/clauses"); - fs::create_dir_all(&clauses_dir).unwrap(); + fs::create_dir_all(&clauses_dir)?; fs::write( clauses_dir.join("C-TEST.json"), r#"{ @@ -286,14 +300,14 @@ fn test_deprecate_legacy_json_clause_requires_migrate() { "status": "active", "text": "Legacy clause content." }"#, - ) - .unwrap(); + )?; let output = run_commands( temp_dir.path(), &[&["clause", "deprecate", "RFC-0001:C-TEST", "--force"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } // ============================================================================ @@ -301,8 +315,8 @@ fn test_deprecate_legacy_json_clause_requires_migrate() { // ============================================================================ #[test] -fn test_bump_patch_version() { - let temp_dir = init_project(); +fn test_bump_patch_version() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -320,13 +334,14 @@ fn test_bump_patch_version() { ], &["rfc", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_bump_minor_version() { - let temp_dir = init_project(); +fn test_bump_minor_version() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -344,13 +359,14 @@ fn test_bump_minor_version() { ], &["rfc", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_bump_major_version() { - let temp_dir = init_project(); +fn test_bump_major_version() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -368,13 +384,14 @@ fn test_bump_major_version() { ], &["rfc", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_bump_requires_summary() { - let temp_dir = init_project(); +fn test_bump_requires_summary() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -384,13 +401,14 @@ fn test_bump_requires_summary() { &["rfc", "finalize", "RFC-0001", "normative"], &["rfc", "bump", "RFC-0001", "--patch"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_bump_with_change() { - let temp_dir = init_project(); +fn test_bump_with_change() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -401,20 +419,22 @@ fn test_bump_with_change() { &["rfc", "bump", "RFC-0001", "--change", "Added new clause"], &["rfc", "show", "RFC-0001"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_bump_nonexistent_rfc() { - let temp_dir = init_project(); +fn test_bump_nonexistent_rfc() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( temp_dir.path(), &[&["rfc", "bump", "RFC-9999", "--patch", "--summary", "Fix"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } // ============================================================================ @@ -422,8 +442,8 @@ fn test_bump_nonexistent_rfc() { // ============================================================================ #[test] -fn test_accept_proposed_adr() { - let temp_dir = init_project(); +fn test_accept_proposed_adr() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -434,13 +454,14 @@ fn test_accept_proposed_adr() { &["adr", "accept", "ADR-0001"], &["adr", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_reject_proposed_adr() { - let temp_dir = init_project(); +fn test_reject_proposed_adr() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -450,13 +471,14 @@ fn test_reject_proposed_adr() { &["adr", "reject", "ADR-0001"], &["adr", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_accept_already_accepted_fails() { - let temp_dir = init_project(); +fn test_accept_already_accepted_fails() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -466,13 +488,14 @@ fn test_accept_already_accepted_fails() { &["adr", "accept", "ADR-0001"], &["adr", "accept", "ADR-0001"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_accept_rejected_adr_fails() { - let temp_dir = init_project(); +fn test_accept_rejected_adr_fails() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -482,17 +505,19 @@ fn test_accept_rejected_adr_fails() { &["adr", "reject", "ADR-0001"], &["adr", "accept", "ADR-0001"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_accept_nonexistent_adr() { - let temp_dir = init_project(); +fn test_accept_nonexistent_adr() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["adr", "accept", "ADR-9999"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["adr", "accept", "ADR-9999"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } // ============================================================================ @@ -500,8 +525,8 @@ fn test_accept_nonexistent_adr() { // ============================================================================ #[test] -fn test_deprecate_normative_rfc() { - let temp_dir = init_project(); +fn test_deprecate_normative_rfc() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -512,13 +537,14 @@ fn test_deprecate_normative_rfc() { &["rfc", "deprecate", "RFC-0001", "--force"], &["rfc", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_supersede_rfc() { - let temp_dir = init_project(); +fn test_supersede_rfc() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -532,13 +558,14 @@ fn test_supersede_rfc() { &["rfc", "supersede", "RFC-0001", "--by", "RFC-0002"], &["rfc", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_supersede_nonexistent_rfc() { - let temp_dir = init_project(); +fn test_supersede_nonexistent_rfc() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -548,8 +575,9 @@ fn test_supersede_nonexistent_rfc() { &["rfc", "finalize", "RFC-0001", "normative"], &["rfc", "supersede", "RFC-9999", "--by", "RFC-0001"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } // ============================================================================ @@ -557,8 +585,8 @@ fn test_supersede_nonexistent_rfc() { // ============================================================================ #[test] -fn test_supersede_clause() { - let temp_dir = init_project(); +fn test_supersede_clause() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -595,13 +623,14 @@ fn test_supersede_clause() { ], &["clause", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_deprecate_clause_force() { - let temp_dir = init_project(); +fn test_deprecate_clause_force() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -621,13 +650,14 @@ fn test_deprecate_clause_force() { &["clause", "deprecate", "RFC-0001:C-ONE", "--force"], &["clause", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_deprecate_clause_already_deprecated_fails() { - let temp_dir = init_project(); +fn test_deprecate_clause_already_deprecated_fails() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -647,13 +677,14 @@ fn test_deprecate_clause_already_deprecated_fails() { &["clause", "deprecate", "RFC-0001:C-ONE", "--force"], &["clause", "deprecate", "RFC-0001:C-ONE", "--force"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_deprecate_clause_superseded_fails() { - let temp_dir = init_project(); +fn test_deprecate_clause_superseded_fails() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -690,6 +721,7 @@ fn test_deprecate_clause_superseded_fails() { ], &["clause", "deprecate", "RFC-0001:C-OLD", "--force"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } diff --git a/tests/test_lock.rs b/tests/test_lock.rs index 1763bf7..e89dad5 100644 --- a/tests/test_lock.rs +++ b/tests/test_lock.rs @@ -14,15 +14,15 @@ use std::time::{Duration, Instant}; /// Test: Write command creates and releases lock #[test] -fn test_write_command_creates_lock_file() { - let temp_dir = init_project(); +fn test_write_command_creates_lock_file() -> common::TestResult { + let temp_dir = init_project()?; let _date = today(); // Run a write command let output = run_commands( temp_dir.path(), &[&["work", "new", "Test work item", "--active"]], - ); + )?; // Verify command succeeded assert!(output.contains("Created work item")); @@ -33,12 +33,13 @@ fn test_write_command_creates_lock_file() { lock_path.exists(), "Lock file should exist after write command" ); + Ok(()) } /// Test: Multiple sequential write commands work (lock is released between commands) #[test] -fn test_sequential_write_commands_succeed() { - let temp_dir = init_project(); +fn test_sequential_write_commands_succeed() -> common::TestResult { + let temp_dir = init_project()?; let _date = today(); // Run multiple write commands sequentially @@ -48,17 +49,18 @@ fn test_sequential_write_commands_succeed() { &["work", "new", "First work item"], &["work", "new", "Second work item"], ], - ); + )?; // Both should succeed assert!(output.contains("Created work item")); assert!(output.contains("exit: 0")); + Ok(()) } /// Test: Lock file is created under gov root #[test] -fn test_lock_file_location() { - let temp_dir = init_project(); +fn test_lock_file_location() -> common::TestResult { + let temp_dir = init_project()?; let lock_path = temp_dir.path().join("gov/.govctl.lock"); @@ -67,18 +69,19 @@ fn test_lock_file_location() { let _ = fs::remove_file(&lock_path); // After a write command, lock file exists (even if released) - run_commands(temp_dir.path(), &[&["rfc", "new", "Test RFC"]]); + run_commands(temp_dir.path(), &[&["rfc", "new", "Test RFC"]])?; assert!( lock_path.exists(), "Lock file should be created under gov root" ); + Ok(()) } /// Test: Read-only commands don't create lock #[test] -fn test_read_commands_no_lock() { - let temp_dir = init_project(); +fn test_read_commands_no_lock() -> common::TestResult { + let temp_dir = init_project()?; let _date = today(); let lock_path = temp_dir.path().join("gov/.govctl.lock"); @@ -87,7 +90,7 @@ fn test_read_commands_no_lock() { let _ = fs::remove_file(&lock_path); // Run read-only commands - run_commands(temp_dir.path(), &[&["status"]]); + run_commands(temp_dir.path(), &[&["status"]])?; // Read commands don't create the lock file assert!( @@ -95,19 +98,20 @@ fn test_read_commands_no_lock() { "Read commands should not create lock file" ); - run_commands(temp_dir.path(), &[&["check"]]); + run_commands(temp_dir.path(), &[&["check"]])?; // Still no lock file assert!( !lock_path.exists(), "Read commands should not create lock file" ); + Ok(()) } /// Test: Lock timeout configuration is respected #[test] -fn test_lock_timeout_configurable() { - let temp_dir = init_project(); +fn test_lock_timeout_configurable() -> common::TestResult { + let temp_dir = init_project()?; // Create config with short timeout let config_path = temp_dir.path().join("gov/config.toml"); @@ -120,18 +124,19 @@ docs_output = "docs" [concurrency] lock_timeout_secs = 1 "#; - fs::write(&config_path, config_content).unwrap(); + fs::write(&config_path, config_content)?; // The timeout is now 1 second instead of default 30 // A write command should still succeed quickly - let output = run_commands(temp_dir.path(), &[&["work", "new", "Test"]]); + let output = run_commands(temp_dir.path(), &[&["work", "new", "Test"]])?; assert!(output.contains("Created work item")); + Ok(()) } /// Test: Lock is released after write command completes #[test] -fn test_lock_released_after_write() { - let temp_dir = init_project(); +fn test_lock_released_after_write() -> common::TestResult { + let temp_dir = init_project()?; let lock_path = temp_dir.path().join("gov/.govctl.lock"); @@ -139,14 +144,14 @@ fn test_lock_released_after_write() { let _ = fs::remove_file(&lock_path); // Run a write command - run_commands(temp_dir.path(), &[&["work", "new", "Test"]]); + run_commands(temp_dir.path(), &[&["work", "new", "Test"]])?; // Lock file should exist but be unlocked assert!(lock_path.exists(), "Lock file should exist"); // Another write command should succeed immediately (lock was released) let start = Instant::now(); - let output = run_commands(temp_dir.path(), &[&["work", "new", "Test2"]]); + let output = run_commands(temp_dir.path(), &[&["work", "new", "Test2"]])?; let elapsed = start.elapsed(); // Should succeed quickly (not waiting for lock) @@ -156,6 +161,7 @@ fn test_lock_released_after_write() { elapsed ); assert!(output.contains("Created work item")); + Ok(()) } /// Helper: Kill a process, waiting with try_wait polling @@ -177,7 +183,10 @@ fn kill_and_wait(mut child: std::process::Child, timeout: Duration) { } /// Create a config file with specified lock timeout -fn create_config_with_timeout(temp_dir: &std::path::Path, timeout_secs: u64) { +fn create_config_with_timeout( + temp_dir: &std::path::Path, + timeout_secs: u64, +) -> Result<(), Box> { let config_path = temp_dir.join("gov/config.toml"); let config_content = format!( r#"[project] @@ -191,7 +200,8 @@ lock_timeout_secs = {} "#, timeout_secs ); - fs::write(&config_path, config_content).unwrap(); + fs::write(&config_path, config_content)?; + Ok(()) } /// Test: Concurrent write is blocked by lock (cross-process) @@ -199,16 +209,16 @@ lock_timeout_secs = {} /// Uses govctl itself as the lock holder - spawns a write command that /// blocks waiting for user input (which never comes), holding the lock. #[test] -fn test_concurrent_write_blocked_by_lock() { - let temp_dir = init_project(); +fn test_concurrent_write_blocked_by_lock() -> common::TestResult { + let temp_dir = init_project()?; // Short timeout for the second writer - create_config_with_timeout(temp_dir.path(), 1); + create_config_with_timeout(temp_dir.path(), 1)?; // Start a work item deletion in another process // This will prompt for confirmation, holding the lock while waiting let work_dir = temp_dir.path().join("gov/work"); - fs::create_dir_all(&work_dir).unwrap(); + fs::create_dir_all(&work_dir)?; let work_file = work_dir.join("2026-01-01-test-item.toml"); fs::write( &work_file, @@ -223,8 +233,7 @@ created = "2026-01-01" description = "Test" acceptance_criteria = [] "#, - ) - .unwrap(); + )?; // Spawn a delete command (will hold lock while waiting for confirmation) let holder = Command::new(env!("CARGO_BIN_EXE_govctl")) @@ -232,8 +241,7 @@ acceptance_criteria = [] .current_dir(temp_dir.path()) .env("NO_COLOR", "1") .stdin(std::process::Stdio::piped()) - .spawn() - .expect("Failed to start lock holder"); + .spawn()?; // Wait a bit for the holder to acquire the lock thread::sleep(Duration::from_millis(500)); @@ -244,8 +252,7 @@ acceptance_criteria = [] .args(["work", "new", "Should timeout"]) .current_dir(temp_dir.path()) .env("NO_COLOR", "1") - .output() - .expect("Failed to run govctl"); + .output()?; let elapsed = start.elapsed(); // Should have timed out quickly @@ -266,19 +273,20 @@ acceptance_criteria = [] // Clean up kill_and_wait(holder, Duration::from_secs(2)); + Ok(()) } /// Test: Concurrent write succeeds after lock is released #[test] -fn test_write_succeeds_after_lock_released() { - let temp_dir = init_project(); +fn test_write_succeeds_after_lock_released() -> common::TestResult { + let temp_dir = init_project()?; // Longer timeout for this test - create_config_with_timeout(temp_dir.path(), 30); + create_config_with_timeout(temp_dir.path(), 30)?; // Create a work item to delete let work_dir = temp_dir.path().join("gov/work"); - fs::create_dir_all(&work_dir).unwrap(); + fs::create_dir_all(&work_dir)?; let work_file = work_dir.join("2026-01-01-test-item.toml"); fs::write( &work_file, @@ -293,22 +301,20 @@ created = "2026-01-01" description = "Test" acceptance_criteria = [] "#, - ) - .unwrap(); + )?; // Spawn a delete command with -f flag (no confirmation, completes immediately) let result = Command::new(env!("CARGO_BIN_EXE_govctl")) .args(["work", "delete", "WI-2026-01-01-001", "-f"]) .current_dir(temp_dir.path()) .env("NO_COLOR", "1") - .status() - .expect("Failed to run govctl"); + .status()?; assert!(result.success(), "Delete should succeed"); // Now a write should succeed immediately let start = Instant::now(); - let output = run_commands(temp_dir.path(), &[&["work", "new", "After release"]]); + let output = run_commands(temp_dir.path(), &[&["work", "new", "After release"]])?; let elapsed = start.elapsed(); assert!( @@ -317,13 +323,14 @@ acceptance_criteria = [] elapsed ); assert!(output.contains("Created work item")); + Ok(()) } #[test] -fn test_write_command_without_init_reports_missing_gov_root() { - let temp_dir = tempfile::TempDir::new().expect("temp dir"); +fn test_write_command_without_init_reports_missing_gov_root() -> common::TestResult { + let temp_dir = tempfile::TempDir::new()?; - let output = run_commands(temp_dir.path(), &[&["work", "new", "Needs init"]]); + let output = run_commands(temp_dir.path(), &[&["work", "new", "Needs init"]])?; assert!(output.contains("exit: 1"), "output: {}", output); assert!(output.contains("error[E0502]"), "output: {}", output); assert!( @@ -331,12 +338,13 @@ fn test_write_command_without_init_reports_missing_gov_root() { "output: {}", output ); + Ok(()) } #[test] -fn test_concurrent_tick_commands_persist_all_acceptance_criteria_updates() { - let temp_dir = init_project(); - create_config_with_timeout(temp_dir.path(), 30); +fn test_concurrent_tick_commands_persist_all_acceptance_criteria_updates() -> common::TestResult { + let temp_dir = init_project()?; + create_config_with_timeout(temp_dir.path(), 30)?; let today = today(); let wi_id = format!("WI-{today}-001"); @@ -344,7 +352,7 @@ fn test_concurrent_tick_commands_persist_all_acceptance_criteria_updates() { let create_output = run_commands( temp_dir.path(), &[&["work", "new", "Concurrent tick persistence", "--active"]], - ); + )?; assert!( create_output.contains(&wi_id), "expected work item id in output: {create_output}" @@ -375,7 +383,7 @@ fn test_concurrent_tick_commands_persist_all_acceptance_criteria_updates() { "test: criterion three", ], ], - ); + )?; assert!( setup_output.contains("exit: 0"), "setup output: {setup_output}" @@ -402,12 +410,14 @@ fn test_concurrent_tick_commands_persist_all_acceptance_criteria_updates() { .env("NO_COLOR", "1") .env("GOVCTL_DEFAULT_OWNER", "@test-user") .output() - .expect("failed to run concurrent tick command") })); } for handle in handles { - let output = handle.join().expect("tick thread panicked"); + let output = handle + .join() + .map_err(|_| "tick thread panicked")? + .map_err(|e| format!("failed to run concurrent tick command: {e}"))?; assert!( output.status.success(), "concurrent tick failed: stdout={} stderr={}", @@ -421,18 +431,17 @@ fn test_concurrent_tick_commands_persist_all_acceptance_criteria_updates() { .current_dir(temp_dir.path()) .env("NO_COLOR", "1") .env("GOVCTL_DEFAULT_OWNER", "@test-user") - .output() - .expect("failed to read work item json"); + .output()?; assert!( get_output.status.success(), "work show failed: stdout={} stderr={}", String::from_utf8_lossy(&get_output.stdout), String::from_utf8_lossy(&get_output.stderr) ); - let work: Value = serde_json::from_slice(&get_output.stdout).expect("valid work item json"); + let work: Value = serde_json::from_slice(&get_output.stdout)?; let criteria = work["content"]["acceptance_criteria"] .as_array() - .expect("acceptance_criteria array"); + .ok_or("acceptance_criteria array missing or not an array")?; let done_count = criteria .iter() .filter(|item| item["status"] == "done") @@ -443,4 +452,5 @@ fn test_concurrent_tick_commands_persist_all_acceptance_criteria_updates() { "expected all criteria to persist as done, got:\n{}", String::from_utf8_lossy(&get_output.stdout) ); + Ok(()) } diff --git a/tests/test_migrate.rs b/tests/test_migrate.rs index 9084cc2..fc811be 100644 --- a/tests/test_migrate.rs +++ b/tests/test_migrate.rs @@ -2,12 +2,12 @@ mod common; -use common::{init_project, init_project_v1, run_commands}; +use common::{TestResult, init_project, init_project_v1, run_commands}; use std::fs; -fn write_legacy_rfc_project(dir: &std::path::Path) { +fn write_legacy_rfc_project(dir: &std::path::Path) -> Result<(), Box> { let rfc_dir = dir.join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.json"), @@ -34,8 +34,7 @@ fn write_legacy_rfc_project(dir: &std::path::Path) { } ] }"#, - ) - .unwrap(); + )?; fs::write( rfc_dir.join("clauses/C-LEGACY.json"), @@ -47,24 +46,24 @@ fn write_legacy_rfc_project(dir: &std::path::Path) { "text": "Legacy clause text.", "since": "1.0.0" }"#, - ) - .unwrap(); + )?; + + Ok(()) } #[test] -fn test_migrate_converts_json_rfc_and_upgrades_releases() { - let temp_dir = init_project_v1(); - write_legacy_rfc_project(temp_dir.path()); +fn test_migrate_converts_json_rfc_and_upgrades_releases() -> TestResult { + let temp_dir = init_project_v1()?; + write_legacy_rfc_project(temp_dir.path())?; fs::write( temp_dir.path().join("gov/releases.toml"), r#"[[releases]] version = "1.0.0" date = "2026-01-01" "#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["migrate"], &["check"]]); + let output = run_commands(temp_dir.path(), &[&["migrate"], &["check"]])?; assert!( output.contains("file(s) written"), "should report written files: {}", @@ -78,7 +77,7 @@ date = "2026-01-01" assert!(!rfc_dir.join("rfc.json").exists()); assert!(!rfc_dir.join("clauses/C-LEGACY.json").exists()); - let releases = fs::read_to_string(temp_dir.path().join("gov/releases.toml")).unwrap(); + let releases = fs::read_to_string(temp_dir.path().join("gov/releases.toml"))?; assert!(releases.contains("#:schema")); assert!( !releases.contains("schema = 1"), @@ -86,20 +85,22 @@ date = "2026-01-01" releases ); - let config = fs::read_to_string(temp_dir.path().join("gov/config.toml")).unwrap(); + let config = fs::read_to_string(temp_dir.path().join("gov/config.toml"))?; assert!( config.contains("version = 2"), "schema version should be bumped to 2: {}", config ); + + Ok(()) } #[test] -fn test_migrate_dry_run_preserves_legacy_files() { - let temp_dir = init_project_v1(); - write_legacy_rfc_project(temp_dir.path()); +fn test_migrate_dry_run_preserves_legacy_files() -> TestResult { + let temp_dir = init_project_v1()?; + write_legacy_rfc_project(temp_dir.path())?; - let output = run_commands(temp_dir.path(), &[&["--dry-run", "migrate"]]); + let output = run_commands(temp_dir.path(), &[&["--dry-run", "migrate"]])?; assert!(output.contains("Would write: gov/rfc/RFC-0001/rfc.toml")); assert!(output.contains("Would delete: gov/rfc/RFC-0001/rfc.json")); @@ -109,17 +110,19 @@ fn test_migrate_dry_run_preserves_legacy_files() { assert!(!rfc_dir.join("rfc.toml").exists()); assert!(!rfc_dir.join("clauses/C-LEGACY.toml").exists()); - let config = fs::read_to_string(temp_dir.path().join("gov/config.toml")).unwrap(); + let config = fs::read_to_string(temp_dir.path().join("gov/config.toml"))?; assert!( config.contains("version = 1"), "dry-run should not bump version: {}", config ); + + Ok(()) } #[test] -fn test_migrate_is_noop_on_current_version() { - let temp_dir = init_project(); +fn test_migrate_is_noop_on_current_version() -> TestResult { + let temp_dir = init_project()?; // Create an RFC so the project isn't empty, then migrate to bump version run_commands( @@ -136,48 +139,52 @@ fn test_migrate_is_noop_on_current_version() { ], &["migrate"], ], - ); + )?; // Second migrate should be a noop - let output = run_commands(temp_dir.path(), &[&["migrate"]]); + let output = run_commands(temp_dir.path(), &[&["migrate"]])?; assert!( output.contains("already at schema version 2"), "output: {}", output ); + + Ok(()) } #[test] -fn test_migrate_bumps_version_even_without_file_changes() { - let temp_dir = init_project_v1(); +fn test_migrate_bumps_version_even_without_file_changes() -> TestResult { + let temp_dir = init_project_v1()?; // Create artifacts using govctl (already in new format with headers) - run_commands(temp_dir.path(), &[&["rfc", "new", "New Format RFC"]]); + run_commands(temp_dir.path(), &[&["rfc", "new", "New Format RFC"]])?; // Config says version = 1, but files are already in v2 format - let config = fs::read_to_string(temp_dir.path().join("gov/config.toml")).unwrap(); + let config = fs::read_to_string(temp_dir.path().join("gov/config.toml"))?; assert!(config.contains("version = 1")); - let output = run_commands(temp_dir.path(), &[&["migrate"]]); + let output = run_commands(temp_dir.path(), &[&["migrate"]])?; assert!( output.contains("Schema version bumped to 2"), "should bump version even with no file ops: {}", output ); - let config = fs::read_to_string(temp_dir.path().join("gov/config.toml")).unwrap(); + let config = fs::read_to_string(temp_dir.path().join("gov/config.toml"))?; assert!( config.contains("version = 2"), "config should now be version 2: {}", config ); + + Ok(()) } #[test] -fn test_migrate_failure_leaves_legacy_repo_unchanged() { - let temp_dir = init_project_v1(); +fn test_migrate_failure_leaves_legacy_repo_unchanged() -> TestResult { + let temp_dir = init_project_v1()?; let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.json"), @@ -203,19 +210,20 @@ fn test_migrate_failure_leaves_legacy_repo_unchanged() { } ] }"#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["migrate"]]); + let output = run_commands(temp_dir.path(), &[&["migrate"]])?; assert!(output.contains("error[E0202]"), "output: {}", output); assert!(rfc_dir.join("rfc.json").exists()); assert!(!rfc_dir.join("rfc.toml").exists()); - let config = fs::read_to_string(temp_dir.path().join("gov/config.toml")).unwrap(); + let config = fs::read_to_string(temp_dir.path().join("gov/config.toml"))?; assert!( config.contains("version = 1"), "version should not be bumped on failure: {}", config ); + + Ok(()) } diff --git a/tests/test_move.rs b/tests/test_move.rs index 8ceaef4..4da51f1 100644 --- a/tests/test_move.rs +++ b/tests/test_move.rs @@ -5,9 +5,9 @@ mod common; use common::{init_project, normalize_output, run_commands, run_dynamic_commands, today}; #[test] -fn test_move_queue_to_active() { +fn test_move_queue_to_active() -> common::TestResult { // Move work item from queue to active - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -18,15 +18,16 @@ fn test_move_queue_to_active() { &["work", "move", "WI--001", "active"], &["work", "list", "all"], ], - ); + )?; let output = output.replace("WI--001", &format!("WI-{}-001", date)); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_move_active_to_done_with_criteria() { +fn test_move_active_to_done_with_criteria() -> common::TestResult { // Move work item from active to done with acceptance criteria - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); // Create and set up work item @@ -45,7 +46,7 @@ fn test_move_active_to_done_with_criteria() { "add: Task done".to_string(), ], ]; - let _ = run_dynamic_commands(temp_dir.path(), &setup_commands); + let _ = run_dynamic_commands(temp_dir.path(), &setup_commands)?; let output = run_commands( temp_dir.path(), @@ -62,14 +63,15 @@ fn test_move_active_to_done_with_criteria() { &["work", "move", &format!("WI-{}-001", date), "done"], &["work", "list", "all"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_move_to_done_without_criteria_fails() { +fn test_move_to_done_without_criteria_fails() -> common::TestResult { // Cannot move to done without acceptance criteria - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -78,14 +80,15 @@ fn test_move_to_done_without_criteria_fails() { &["work", "new", "Test task", "--active"], &["work", "move", &format!("WI-{}-001", date), "done"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_move_to_done_with_pending_criteria_fails() { +fn test_move_to_done_with_pending_criteria_fails() -> common::TestResult { // Cannot move to done with pending acceptance criteria - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let setup_commands: Vec> = vec![ @@ -103,19 +106,20 @@ fn test_move_to_done_with_pending_criteria_fails() { "add: Task done".to_string(), ], ]; - let _ = run_dynamic_commands(temp_dir.path(), &setup_commands); + let _ = run_dynamic_commands(temp_dir.path(), &setup_commands)?; let output = run_commands( temp_dir.path(), &[&["work", "move", &format!("WI-{}-001", date), "done"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_move_active_to_cancelled() { +fn test_move_active_to_cancelled() -> common::TestResult { // Move work item from active to cancelled - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -125,14 +129,15 @@ fn test_move_active_to_cancelled() { &["work", "move", &format!("WI-{}-001", date), "cancelled"], &["work", "list", "all"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_move_queue_to_cancelled() { +fn test_move_queue_to_cancelled() -> common::TestResult { // Move work item from queue to cancelled (skip active) - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -142,14 +147,15 @@ fn test_move_queue_to_cancelled() { &["work", "move", &format!("WI-{}-001", date), "cancelled"], &["work", "list", "all"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_move_by_work_item_id() { +fn test_move_by_work_item_id() -> common::TestResult { // Can reference work item by ID instead of filename - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -159,27 +165,29 @@ fn test_move_by_work_item_id() { &["work", "move", &format!("WI-{}-001", date), "active"], &["work", "list", "all"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_move_nonexistent_work_item() { +fn test_move_nonexistent_work_item() -> common::TestResult { // Cannot move non-existent work item - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( temp_dir.path(), &[&["work", "move", "WI-9999-99-999", "active"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_move_sets_started_date() { +fn test_move_sets_started_date() -> common::TestResult { // Moving to active sets started date if not already set - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let output = run_commands( @@ -190,14 +198,15 @@ fn test_move_sets_started_date() { &["work", "move", &format!("WI-{}-001", date), "active"], &["work", "show", &format!("WI-{}-001", date)], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_move_sets_completed_date() { +fn test_move_sets_completed_date() -> common::TestResult { // Moving to done/cancelled sets completed date - let temp_dir = init_project(); + let temp_dir = init_project()?; let date = today(); let setup_commands: Vec> = vec![ @@ -215,7 +224,7 @@ fn test_move_sets_completed_date() { "add: Done".to_string(), ], ]; - let _ = run_dynamic_commands(temp_dir.path(), &setup_commands); + let _ = run_dynamic_commands(temp_dir.path(), &setup_commands)?; let output = run_commands( temp_dir.path(), @@ -233,6 +242,7 @@ fn test_move_sets_completed_date() { &["work", "move", &format!("WI-{}-001", date), "done"], &["work", "show", &format!("WI-{}-001", date)], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } diff --git a/tests/test_rfc_lifecycle.rs b/tests/test_rfc_lifecycle.rs index 468863b..a252e4c 100644 --- a/tests/test_rfc_lifecycle.rs +++ b/tests/test_rfc_lifecycle.rs @@ -6,8 +6,8 @@ use common::{init_project, normalize_output, run_commands, today}; /// Test: RFC amendment tracking with signature-based detection #[test] -fn test_rfc_amendment_tracking() { - let temp_dir = init_project(); +fn test_rfc_amendment_tracking() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); // Create RFC and clause @@ -33,7 +33,7 @@ fn test_rfc_amendment_tracking() { "Original text for amendment test.", ], ], - ); + )?; // Finalize and bump to set baseline signature let baseline = run_commands( @@ -50,7 +50,7 @@ fn test_rfc_amendment_tracking() { ], &["rfc", "list"], ], - ); + )?; // Edit clause to create amendment let edit = run_commands( @@ -62,10 +62,10 @@ fn test_rfc_amendment_tracking() { "--text", "AMENDED text - content changed.", ]], - ); + )?; // List should show asterisk for amended RFC - let amended = run_commands(temp_dir.path(), &[&["rfc", "list"]]); + let amended = run_commands(temp_dir.path(), &[&["rfc", "list"]])?; // Bump version to release amendment let released = run_commands( @@ -81,8 +81,9 @@ fn test_rfc_amendment_tracking() { ], &["rfc", "list"], ], - ); + )?; let combined = format!("{}{}{}{}{}", setup, baseline, edit, amended, released); - insta::assert_snapshot!(normalize_output(&combined, temp_dir.path(), &date)); + insta::assert_snapshot!(normalize_output(&combined, temp_dir.path(), &date)?); + Ok(()) } diff --git a/tests/test_scan.rs b/tests/test_scan.rs index 96b01c7..522065d 100644 --- a/tests/test_scan.rs +++ b/tests/test_scan.rs @@ -10,10 +10,10 @@ use std::fs; /// Helper to enable source scanning in a project. /// Parses config as typed TOML, inserts an active `[source_scan]` table, and writes back. -fn enable_source_scan(dir: &std::path::Path) { +fn enable_source_scan(dir: &std::path::Path) -> Result<(), Box> { let config_path = dir.join("gov/config.toml"); - let content = fs::read_to_string(&config_path).unwrap(); - let mut doc: toml::Table = toml::from_str(&content).unwrap(); + let content = fs::read_to_string(&config_path)?; + let mut doc: toml::Table = toml::from_str(&content)?; let mut scan = toml::Table::new(); scan.insert("enabled".into(), toml::Value::Boolean(true)); scan.insert( @@ -22,25 +22,27 @@ fn enable_source_scan(dir: &std::path::Path) { ); scan.insert("exclude".into(), toml::Value::Array(vec![])); doc.insert("source_scan".into(), toml::Value::Table(scan)); - fs::write(&config_path, toml::to_string_pretty(&doc).unwrap()).unwrap(); + fs::write(&config_path, toml::to_string_pretty(&doc)?)?; + Ok(()) } #[test] -fn test_scan_no_references() { +fn test_scan_no_references() -> common::TestResult { // project with no source files should scan successfully - let temp_dir = init_project(); - enable_source_scan(temp_dir.path()); + let temp_dir = init_project()?; + enable_source_scan(temp_dir.path())?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_scan_valid_rfc_reference() { +fn test_scan_valid_rfc_reference() -> common::TestResult { // source file with valid RFC reference should pass - let temp_dir = init_project(); - enable_source_scan(temp_dir.path()); + let temp_dir = init_project()?; + enable_source_scan(temp_dir.path())?; let date = today(); // Create an RFC @@ -50,25 +52,25 @@ fn test_scan_valid_rfc_reference() { &["rfc", "new", "Test RFC"], &["rfc", "finalize", "RFC-0001", "normative"], ], - ); + )?; // Create a source file with a reference to the RFC - fs::create_dir_all(temp_dir.path().join("src")).unwrap(); + fs::create_dir_all(temp_dir.path().join("src"))?; fs::write( temp_dir.path().join("src/main.rs"), "// Implements [[RFC-0001]]\nfn main() {}\n", - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_scan_valid_clause_reference() { +fn test_scan_valid_clause_reference() -> common::TestResult { // source file with valid clause reference should pass - let temp_dir = init_project(); - enable_source_scan(temp_dir.path()); + let temp_dir = init_project()?; + enable_source_scan(temp_dir.path())?; let date = today(); // Create an RFC with a clause @@ -88,44 +90,44 @@ fn test_scan_valid_clause_reference() { ], &["rfc", "finalize", "RFC-0001", "normative"], ], - ); + )?; // Create a source file with a reference to the clause - fs::create_dir_all(temp_dir.path().join("src")).unwrap(); + fs::create_dir_all(temp_dir.path().join("src"))?; fs::write( temp_dir.path().join("src/main.rs"), "// Implements [[RFC-0001:C-TEST]]\nfn main() {}\n", - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_scan_unknown_rfc_reference() { +fn test_scan_unknown_rfc_reference() -> common::TestResult { // source file with unknown RFC reference should error - let temp_dir = init_project(); - enable_source_scan(temp_dir.path()); + let temp_dir = init_project()?; + enable_source_scan(temp_dir.path())?; let date = today(); // Create a source file with a reference to non-existent RFC - fs::create_dir_all(temp_dir.path().join("src")).unwrap(); + fs::create_dir_all(temp_dir.path().join("src"))?; fs::write( temp_dir.path().join("src/main.rs"), "// Implements [[RFC-9999]]\nfn main() {}\n", - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_scan_unknown_clause_reference() { +fn test_scan_unknown_clause_reference() -> common::TestResult { // source file with unknown clause reference should error - let temp_dir = init_project(); - enable_source_scan(temp_dir.path()); + let temp_dir = init_project()?; + enable_source_scan(temp_dir.path())?; let date = today(); // Create an RFC but no clause @@ -135,25 +137,25 @@ fn test_scan_unknown_clause_reference() { &["rfc", "new", "Test RFC"], &["rfc", "finalize", "RFC-0001", "normative"], ], - ); + )?; // Create a source file with a reference to non-existent clause - fs::create_dir_all(temp_dir.path().join("src")).unwrap(); + fs::create_dir_all(temp_dir.path().join("src"))?; fs::write( temp_dir.path().join("src/main.rs"), "// Implements [[RFC-0001:C-NONEXISTENT]]\nfn main() {}\n", - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_scan_deprecated_rfc_reference() { +fn test_scan_deprecated_rfc_reference() -> common::TestResult { // source file with deprecated RFC reference should warn - let temp_dir = init_project(); - enable_source_scan(temp_dir.path()); + let temp_dir = init_project()?; + enable_source_scan(temp_dir.path())?; let date = today(); // Create and deprecate an RFC @@ -164,25 +166,25 @@ fn test_scan_deprecated_rfc_reference() { &["rfc", "finalize", "RFC-0001", "normative"], &["rfc", "deprecate", "RFC-0001", "--force"], ], - ); + )?; // Create a source file with a reference to deprecated RFC - fs::create_dir_all(temp_dir.path().join("src")).unwrap(); + fs::create_dir_all(temp_dir.path().join("src"))?; fs::write( temp_dir.path().join("src/main.rs"), "// Implements [[RFC-0001]]\nfn main() {}\n", - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_scan_valid_adr_reference() { +fn test_scan_valid_adr_reference() -> common::TestResult { // source file with valid ADR reference should pass - let temp_dir = init_project(); - enable_source_scan(temp_dir.path()); + let temp_dir = init_project()?; + enable_source_scan(temp_dir.path())?; let date = today(); // Create an ADR @@ -192,55 +194,55 @@ fn test_scan_valid_adr_reference() { &["adr", "new", "Test Decision"], &["adr", "accept", "ADR-0001"], ], - ); + )?; // Create a source file with a reference to the ADR - fs::create_dir_all(temp_dir.path().join("src")).unwrap(); + fs::create_dir_all(temp_dir.path().join("src"))?; fs::write( temp_dir.path().join("src/main.rs"), "// Follows [[ADR-0001]]\nfn main() {}\n", - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_scan_valid_work_item_reference() { +fn test_scan_valid_work_item_reference() -> common::TestResult { // source file with valid work item reference should pass - let temp_dir = init_project(); - enable_source_scan(temp_dir.path()); + let temp_dir = init_project()?; + enable_source_scan(temp_dir.path())?; let date = today(); // Create a work item - run_commands(temp_dir.path(), &[&["work", "new", "Test task"]]); + run_commands(temp_dir.path(), &[&["work", "new", "Test task"]])?; // Get the work item ID from the output - let wi_output = run_commands(temp_dir.path(), &[&["work", "list", "all"]]); - let wi_id = regex::Regex::new(r"WI-\d{4}-\d{2}-\d{2}-\d{3}") - .unwrap() + let wi_output = run_commands(temp_dir.path(), &[&["work", "list", "all"]])?; + let wi_id = regex::Regex::new(r"WI-\d{4}-\d{2}-\d{2}-\d{3}")? .find(&wi_output) - .map(|m| m.as_str().to_string()) - .expect("No work item ID found"); + .ok_or("No work item ID found")? + .as_str() + .to_string(); // Create a source file with a reference to the work item - fs::create_dir_all(temp_dir.path().join("src")).unwrap(); + fs::create_dir_all(temp_dir.path().join("src"))?; fs::write( temp_dir.path().join("src/main.rs"), format!("// Implements [[{}]]\nfn main() {{}}\n", wi_id), - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_scan_multiple_references_in_file() { +fn test_scan_multiple_references_in_file() -> common::TestResult { // source file with multiple references should check all - let temp_dir = init_project(); - enable_source_scan(temp_dir.path()); + let temp_dir = init_project()?; + enable_source_scan(temp_dir.path())?; let date = today(); // Create RFCs @@ -252,25 +254,25 @@ fn test_scan_multiple_references_in_file() { &["rfc", "new", "RFC Two"], &["rfc", "finalize", "RFC-0002", "normative"], ], - ); + )?; // Create a source file with multiple references - fs::create_dir_all(temp_dir.path().join("src")).unwrap(); + fs::create_dir_all(temp_dir.path().join("src"))?; fs::write( temp_dir.path().join("src/main.rs"), "// Implements [[RFC-0001]] and [[RFC-0002]]\nfn main() {}\n", - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_scan_mixed_valid_invalid_references() { +fn test_scan_mixed_valid_invalid_references() -> common::TestResult { // source file with mixed valid/invalid references should report errors - let temp_dir = init_project(); - enable_source_scan(temp_dir.path()); + let temp_dir = init_project()?; + enable_source_scan(temp_dir.path())?; let date = today(); // Create one RFC @@ -280,16 +282,16 @@ fn test_scan_mixed_valid_invalid_references() { &["rfc", "new", "Valid RFC"], &["rfc", "finalize", "RFC-0001", "normative"], ], - ); + )?; // Create a source file with valid and invalid references - fs::create_dir_all(temp_dir.path().join("src")).unwrap(); + fs::create_dir_all(temp_dir.path().join("src"))?; fs::write( temp_dir.path().join("src/main.rs"), "// Implements [[RFC-0001]] and [[RFC-9999]]\nfn main() {}\n", - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } diff --git a/tests/test_source_scan.rs b/tests/test_source_scan.rs index 3e7267c..4deb1a0 100644 --- a/tests/test_source_scan.rs +++ b/tests/test_source_scan.rs @@ -7,13 +7,13 @@ use std::fs; /// Test: Source code scanning detects valid and invalid [[RFC-XXX:C-XXX]] references #[test] -fn test_source_scan_detects_refs() { - let temp_dir = init_project(); +fn test_source_scan_detects_refs() -> common::TestResult { + let temp_dir = init_project()?; let date = today(); // Create RFC with a valid clause let rfc_dir = temp_dir.path().join("gov/rfc/RFC-0001"); - fs::create_dir_all(rfc_dir.join("clauses")).unwrap(); + fs::create_dir_all(rfc_dir.join("clauses"))?; fs::write( rfc_dir.join("rfc.json"), @@ -39,8 +39,7 @@ fn test_source_scan_detects_refs() { } ] }"#, - ) - .unwrap(); + )?; fs::write( rfc_dir.join("clauses/C-VALID.json"), @@ -52,12 +51,11 @@ fn test_source_scan_detects_refs() { "text": "This is a valid clause.", "since": "1.0.0" }"#, - ) - .unwrap(); + )?; // Create source file with references let src_dir = temp_dir.path().join("src"); - fs::create_dir_all(&src_dir).unwrap(); + fs::create_dir_all(&src_dir)?; fs::write( src_dir.join("example.rs"), @@ -79,9 +77,9 @@ fn main() { println!("Test file for source scanning"); } "#, - ) - .unwrap(); + )?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } diff --git a/tests/test_tags.rs b/tests/test_tags.rs index 28ca6bf..bd34ff6 100644 --- a/tests/test_tags.rs +++ b/tests/test_tags.rs @@ -4,7 +4,7 @@ mod common; -use common::{init_project, normalize_output, run_commands, today}; +use common::{TestResult, init_project, normalize_output, run_commands, today}; use std::fs; // ============================================================================ @@ -12,10 +12,10 @@ use std::fs; // ============================================================================ /// Register allowed tags in config.toml by editing the TOML table directly. -fn register_tags(dir: &std::path::Path, tags: &[&str]) { +fn register_tags(dir: &std::path::Path, tags: &[&str]) -> Result<(), Box> { let config_path = dir.join("gov/config.toml"); - let content = fs::read_to_string(&config_path).unwrap(); - let mut doc: toml::Table = toml::from_str(&content).unwrap(); + let content = fs::read_to_string(&config_path)?; + let mut doc: toml::Table = toml::from_str(&content)?; let arr: toml::value::Array = tags .iter() .map(|t| toml::Value::String(t.to_string())) @@ -23,7 +23,8 @@ fn register_tags(dir: &std::path::Path, tags: &[&str]) { let mut tags_table = toml::Table::new(); tags_table.insert("allowed".into(), toml::Value::Array(arr)); doc.insert("tags".into(), toml::Value::Table(tags_table)); - fs::write(&config_path, toml::to_string_pretty(&doc).unwrap()).unwrap(); + fs::write(&config_path, toml::to_string_pretty(&doc)?)?; + Ok(()) } // ============================================================================ @@ -31,38 +32,41 @@ fn register_tags(dir: &std::path::Path, tags: &[&str]) { // ============================================================================ #[test] -fn test_tag_new() { - let temp_dir = init_project(); +fn test_tag_new() -> TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( temp_dir.path(), &[&["tag", "new", "caching"], &["tag", "list"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_tag_new_duplicate() { - let temp_dir = init_project(); +fn test_tag_new_duplicate() -> TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( temp_dir.path(), &[&["tag", "new", "caching"], &["tag", "new", "caching"]], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_tag_new_invalid_format() { - let temp_dir = init_project(); +fn test_tag_new_invalid_format() -> TestResult { + let temp_dir = init_project()?; let date = today(); - let output = run_commands(temp_dir.path(), &[&["tag", "new", "UPPER"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["tag", "new", "UPPER"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_tag_delete() { - let temp_dir = init_project(); +fn test_tag_delete() -> TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( temp_dir.path(), @@ -71,13 +75,14 @@ fn test_tag_delete() { &["tag", "delete", "caching"], &["tag", "list"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_tag_delete_referenced() { - let temp_dir = init_project(); +fn test_tag_delete_referenced() -> TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( temp_dir.path(), @@ -87,8 +92,9 @@ fn test_tag_delete_referenced() { &["adr", "add", "ADR-0001", "tags", "caching"], &["tag", "delete", "caching"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } // ============================================================================ @@ -96,8 +102,8 @@ fn test_tag_delete_referenced() { // ============================================================================ #[test] -fn test_artifact_add_tag() { - let temp_dir = init_project(); +fn test_artifact_add_tag() -> TestResult { + let temp_dir = init_project()?; let date = today(); let output = run_commands( temp_dir.path(), @@ -107,16 +113,17 @@ fn test_artifact_add_tag() { &["adr", "add", "ADR-0001", "tags", "caching"], &["adr", "get", "ADR-0001", "tags"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_artifact_add_unregistered_tag() { - let temp_dir = init_project(); +fn test_artifact_add_unregistered_tag() -> TestResult { + let temp_dir = init_project()?; let date = today(); - register_tags(temp_dir.path(), &["registered"]); + register_tags(temp_dir.path(), &["registered"])?; let output = run_commands( temp_dir.path(), @@ -124,8 +131,9 @@ fn test_artifact_add_unregistered_tag() { &["adr", "new", "Test Decision"], &["adr", "add", "ADR-0001", "tags", "nonexistent"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } // ============================================================================ @@ -133,18 +141,17 @@ fn test_artifact_add_unregistered_tag() { // ============================================================================ #[test] -fn test_check_rejects_unknown_tag() { - let temp_dir = init_project(); +fn test_check_rejects_unknown_tag() -> TestResult { + let temp_dir = init_project()?; let date = today(); - register_tags(temp_dir.path(), &["allowed-tag"]); + register_tags(temp_dir.path(), &["allowed-tag"])?; - run_commands(temp_dir.path(), &[&["adr", "new", "Test Decision"]]); + run_commands(temp_dir.path(), &[&["adr", "new", "Test Decision"]])?; // Find the ADR file and inject an unregistered tag let adr_dir = temp_dir.path().join("gov/adr"); - let adr_path = fs::read_dir(&adr_dir) - .unwrap() + let adr_path = fs::read_dir(&adr_dir)? .filter_map(|e| e.ok()) .map(|e| e.path()) .find(|p| { @@ -153,30 +160,31 @@ fn test_check_rejects_unknown_tag() { .map(|n| n.starts_with("ADR-0001") && n.ends_with(".toml")) .unwrap_or(false) }) - .expect("ADR-0001 file not found in gov/adr"); + .ok_or("ADR-0001 file not found in gov/adr")?; - let content = fs::read_to_string(&adr_path).unwrap(); - let mut doc: toml::Table = toml::from_str(&content).unwrap(); + let content = fs::read_to_string(&adr_path)?; + let mut doc: toml::Table = toml::from_str(&content)?; let govctl = doc .get_mut("govctl") .and_then(|v| v.as_table_mut()) - .expect("[govctl] table must exist in ADR TOML"); + .ok_or("[govctl] table must exist in ADR TOML")?; govctl.insert( "tags".into(), toml::Value::Array(vec![toml::Value::String("unknown-tag".into())]), ); - fs::write(&adr_path, toml::to_string_pretty(&doc).unwrap()).unwrap(); + fs::write(&adr_path, toml::to_string_pretty(&doc)?)?; - let output = run_commands(temp_dir.path(), &[&["check"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["check"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_check_accepts_registered_tag() { - let temp_dir = init_project(); +fn test_check_accepts_registered_tag() -> TestResult { + let temp_dir = init_project()?; let date = today(); - register_tags(temp_dir.path(), &["caching"]); + register_tags(temp_dir.path(), &["caching"])?; let output = run_commands( temp_dir.path(), @@ -185,8 +193,9 @@ fn test_check_accepts_registered_tag() { &["adr", "add", "ADR-0001", "tags", "caching"], &["check"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } // ============================================================================ @@ -194,8 +203,8 @@ fn test_check_accepts_registered_tag() { // ============================================================================ #[test] -fn test_list_filter_by_tag() { - let temp_dir = init_project(); +fn test_list_filter_by_tag() -> TestResult { + let temp_dir = init_project()?; let date = today(); run_commands( @@ -206,15 +215,16 @@ fn test_list_filter_by_tag() { &["adr", "new", "Untagged Decision"], &["adr", "add", "ADR-0001", "tags", "caching"], ], - ); + )?; - let output = run_commands(temp_dir.path(), &[&["adr", "list", "--tag", "caching"]]); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + let output = run_commands(temp_dir.path(), &[&["adr", "list", "--tag", "caching"]])?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } #[test] -fn test_list_filter_multiple_tags() { - let temp_dir = init_project(); +fn test_list_filter_multiple_tags() -> TestResult { + let temp_dir = init_project()?; let date = today(); run_commands( @@ -227,7 +237,7 @@ fn test_list_filter_multiple_tags() { &["adr", "add", "ADR-0001", "tags", "caching"], &["adr", "add", "ADR-0001", "tags", "performance"], ], - ); + )?; let output = run_commands( temp_dir.path(), @@ -235,6 +245,7 @@ fn test_list_filter_multiple_tags() { &["adr", "list", "--tag", "caching,performance"], &["adr", "list", "--tag", "caching,security"], ], - ); - insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); + )?; + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)?); + Ok(()) } diff --git a/tests/test_verify.rs b/tests/test_verify.rs index 805a074..3c6e289 100644 --- a/tests/test_verify.rs +++ b/tests/test_verify.rs @@ -2,33 +2,35 @@ mod common; -use common::{init_project, run_commands}; +use common::{TestResult, init_project, run_commands}; use std::fs; use std::path::Path; #[test] -fn test_verify_runs_project_default_guard() { - let temp_dir = init_project(); +fn test_verify_runs_project_default_guard() -> TestResult { + let temp_dir = init_project()?; - append_verification_config(temp_dir.path(), true, &["GUARD-ECHO"]); - write_guard(temp_dir.path(), "GUARD-ECHO", "true", None); + append_verification_config(temp_dir.path(), true, &["GUARD-ECHO"])?; + write_guard(temp_dir.path(), "GUARD-ECHO", "true", None)?; - let output = run_commands(temp_dir.path(), &[&["verify"]]); + let output = run_commands(temp_dir.path(), &[&["verify"]])?; assert!(output.contains("PASS GUARD-ECHO"), "output: {}", output); assert!(output.contains("exit: 0"), "output: {}", output); + + Ok(()) } #[test] -fn test_work_move_done_rejects_failed_required_guard() { - let temp_dir = init_project(); +fn test_work_move_done_rejects_failed_required_guard() -> TestResult { + let temp_dir = init_project()?; - write_guard(temp_dir.path(), "GUARD-FAIL", "exit 1", None); - write_active_work_item(temp_dir.path(), "WI-2026-01-01-001", "GUARD-FAIL", None); + write_guard(temp_dir.path(), "GUARD-FAIL", "exit 1", None)?; + write_active_work_item(temp_dir.path(), "WI-2026-01-01-001", "GUARD-FAIL", None)?; let output = run_commands( temp_dir.path(), &[&["work", "move", "WI-2026-01-01-001", "done"]], - ); + )?; assert!( output.contains("Cannot mark as done: verification guard requirements failed"), "output: {}", @@ -36,40 +38,48 @@ fn test_work_move_done_rejects_failed_required_guard() { ); assert!(output.contains("error[E1004]"), "output: {}", output); assert!(output.contains("exit: 1"), "output: {}", output); + + Ok(()) } #[test] -fn test_work_move_done_allows_waived_guard() { - let temp_dir = init_project(); +fn test_work_move_done_allows_waived_guard() -> TestResult { + let temp_dir = init_project()?; - write_guard(temp_dir.path(), "GUARD-FAIL", "exit 1", None); + write_guard(temp_dir.path(), "GUARD-FAIL", "exit 1", None)?; write_active_work_item( temp_dir.path(), "WI-2026-01-01-001", "GUARD-FAIL", Some("Guard does not apply to this work item."), - ); + )?; let output = run_commands( temp_dir.path(), &[&["work", "move", "WI-2026-01-01-001", "done"]], - ); + )?; assert!(output.contains("exit: 0"), "output: {}", output); let work_file = temp_dir .path() .join("gov/work/2026-01-01-guarded-item.toml"); - let content = fs::read_to_string(&work_file).expect("work item should be readable"); + let content = fs::read_to_string(&work_file)?; assert!( content.contains("status = \"done\""), "content: {}", content ); + + Ok(()) } -fn append_verification_config(dir: &Path, enabled: bool, guard_ids: &[&str]) { +fn append_verification_config( + dir: &Path, + enabled: bool, + guard_ids: &[&str], +) -> Result<(), Box> { let config_path = dir.join("gov/config.toml"); - let existing = fs::read_to_string(&config_path).expect("config should exist"); + let existing = fs::read_to_string(&config_path)?; let default_guards = guard_ids .iter() .map(|id| format!("\"{id}\"")) @@ -78,10 +88,16 @@ fn append_verification_config(dir: &Path, enabled: bool, guard_ids: &[&str]) { let appended = format!( "{existing}\n[verification]\nenabled = {enabled}\ndefault_guards = [{default_guards}]\n" ); - fs::write(config_path, appended).expect("config should be writable"); + fs::write(config_path, appended)?; + Ok(()) } -fn write_guard(dir: &Path, guard_id: &str, command: &str, pattern: Option<&str>) { +fn write_guard( + dir: &Path, + guard_id: &str, + command: &str, + pattern: Option<&str>, +) -> Result<(), Box> { let path = dir .join("gov/guard") .join(format!("{}.toml", guard_id.to_lowercase())); @@ -91,10 +107,16 @@ fn write_guard(dir: &Path, guard_id: &str, command: &str, pattern: Option<&str>) let content = format!( "[govctl]\nschema = 1\nid = \"{guard_id}\"\ntitle = \"{guard_id}\"\n\n[check]\ncommand = \"{command}\"\n{pattern_line}" ); - fs::write(path, content).expect("guard file should be writable"); + fs::write(path, content)?; + Ok(()) } -fn write_active_work_item(dir: &Path, work_id: &str, guard_id: &str, waiver_reason: Option<&str>) { +fn write_active_work_item( + dir: &Path, + work_id: &str, + guard_id: &str, + waiver_reason: Option<&str>, +) -> Result<(), Box> { let path = dir.join("gov/work/2026-01-01-guarded-item.toml"); let waiver = waiver_reason .map(|reason| { @@ -104,5 +126,6 @@ fn write_active_work_item(dir: &Path, work_id: &str, guard_id: &str, waiver_reas let content = format!( "[govctl]\nschema = 1\nid = \"{work_id}\"\ntitle = \"Guarded Item\"\nstatus = \"active\"\ncreated = \"2026-01-01\"\nstarted = \"2026-01-01\"\n\n[content]\ndescription = \"Guarded work item\"\n\n[[content.acceptance_criteria]]\ntext = \"done criteria\"\nstatus = \"done\"\ncategory = \"chore\"\n\n[verification]\nrequired_guards = [\"{guard_id}\"]{waiver}" ); - fs::write(path, content).expect("work item should be writable"); + fs::write(path, content)?; + Ok(()) }