c90c2ec4
Add a global trust store, identity aliases and a state cache
a73x 2026-03-21 17:12
Commit message
Cargo.lock
| Old | New | ||
|---|---|---|---|
| @@ -27,6 +27,12 @@ dependencies = [ | |||
| 27 | ] | 27 | ] |
| 28 | 28 | ||
| 29 | [[package]] | 29 | [[package]] |
| 30 | name = "anes" | ||
| 31 | version = "0.1.6" | ||
| 32 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 33 | checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" | ||
| 34 | |||
| 35 | [[package]] | ||
| 30 | name = "anstream" | 36 | name = "anstream" |
| 31 | version = "1.0.0" | 37 | version = "1.0.0" |
| 32 | source = "registry+https://github.com/rust-lang/crates.io-index" | 38 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -173,6 +179,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 173 | checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" | 179 | checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" |
| 174 | 180 | ||
| 175 | [[package]] | 181 | [[package]] |
| 182 | name = "cast" | ||
| 183 | version = "0.3.0" | ||
| 184 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 185 | checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" | ||
| 186 | |||
| 187 | [[package]] | ||
| 176 | name = "castaway" | 188 | name = "castaway" |
| 177 | version = "0.2.4" | 189 | version = "0.2.4" |
| 178 | source = "registry+https://github.com/rust-lang/crates.io-index" | 190 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -220,6 +232,33 @@ dependencies = [ | |||
| 220 | ] | 232 | ] |
| 221 | 233 | ||
| 222 | [[package]] | 234 | [[package]] |
| 235 | name = "ciborium" | ||
| 236 | version = "0.2.2" | ||
| 237 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 238 | checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" | ||
| 239 | dependencies = [ | ||
| 240 | "ciborium-io", | ||
| 241 | "ciborium-ll", | ||
| 242 | "serde", | ||
| 243 | ] | ||
| 244 | |||
| 245 | [[package]] | ||
| 246 | name = "ciborium-io" | ||
| 247 | version = "0.2.2" | ||
| 248 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 249 | checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" | ||
| 250 | |||
| 251 | [[package]] | ||
| 252 | name = "ciborium-ll" | ||
| 253 | version = "0.2.2" | ||
| 254 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 255 | checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" | ||
| 256 | dependencies = [ | ||
| 257 | "ciborium-io", | ||
| 258 | "half", | ||
| 259 | ] | ||
| 260 | |||
| 261 | [[package]] | ||
| 223 | name = "clap" | 262 | name = "clap" |
| 224 | version = "4.6.0" | 263 | version = "4.6.0" |
| 225 | source = "registry+https://github.com/rust-lang/crates.io-index" | 264 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -329,6 +368,67 @@ dependencies = [ | |||
| 329 | ] | 368 | ] |
| 330 | 369 | ||
| 331 | [[package]] | 370 | [[package]] |
| 371 | name = "criterion" | ||
| 372 | version = "0.5.1" | ||
| 373 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 374 | checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" | ||
| 375 | dependencies = [ | ||
| 376 | "anes", | ||
| 377 | "cast", | ||
| 378 | "ciborium", | ||
| 379 | "clap", | ||
| 380 | "criterion-plot", | ||
| 381 | "is-terminal", | ||
| 382 | "itertools 0.10.5", | ||
| 383 | "num-traits", | ||
| 384 | "once_cell", | ||
| 385 | "oorandom", | ||
| 386 | "plotters", | ||
| 387 | "rayon", | ||
| 388 | "regex", | ||
| 389 | "serde", | ||
| 390 | "serde_derive", | ||
| 391 | "serde_json", | ||
| 392 | "tinytemplate", | ||
| 393 | "walkdir", | ||
| 394 | ] | ||
| 395 | |||
| 396 | [[package]] | ||
| 397 | name = "criterion-plot" | ||
| 398 | version = "0.5.0" | ||
| 399 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 400 | checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" | ||
| 401 | dependencies = [ | ||
| 402 | "cast", | ||
| 403 | "itertools 0.10.5", | ||
| 404 | ] | ||
| 405 | |||
| 406 | [[package]] | ||
| 407 | name = "crossbeam-deque" | ||
| 408 | version = "0.8.6" | ||
| 409 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 410 | checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" | ||
| 411 | dependencies = [ | ||
| 412 | "crossbeam-epoch", | ||
| 413 | "crossbeam-utils", | ||
| 414 | ] | ||
| 415 | |||
| 416 | [[package]] | ||
| 417 | name = "crossbeam-epoch" | ||
| 418 | version = "0.9.18" | ||
| 419 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 420 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" | ||
| 421 | dependencies = [ | ||
| 422 | "crossbeam-utils", | ||
| 423 | ] | ||
| 424 | |||
| 425 | [[package]] | ||
| 426 | name = "crossbeam-utils" | ||
| 427 | version = "0.8.21" | ||
| 428 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 429 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" | ||
| 430 | |||
| 431 | [[package]] | ||
| 332 | name = "crossterm" | 432 | name = "crossterm" |
| 333 | version = "0.29.0" | 433 | version = "0.29.0" |
| 334 | source = "registry+https://github.com/rust-lang/crates.io-index" | 434 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -356,6 +456,12 @@ dependencies = [ | |||
| 356 | ] | 456 | ] |
| 357 | 457 | ||
| 358 | [[package]] | 458 | [[package]] |
| 459 | name = "crunchy" | ||
| 460 | version = "0.2.4" | ||
| 461 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 462 | checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" | ||
| 463 | |||
| 464 | [[package]] | ||
| 359 | name = "crypto-common" | 465 | name = "crypto-common" |
| 360 | version = "0.1.7" | 466 | version = "0.1.7" |
| 361 | source = "registry+https://github.com/rust-lang/crates.io-index" | 467 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -723,6 +829,7 @@ dependencies = [ | |||
| 723 | "clap", | 829 | "clap", |
| 724 | "clap_complete", | 830 | "clap_complete", |
| 725 | "clap_mangen", | 831 | "clap_mangen", |
| 832 | "criterion", | ||
| 726 | "crossterm", | 833 | "crossterm", |
| 727 | "dirs", | 834 | "dirs", |
| 728 | "ed25519-dalek", | 835 | "ed25519-dalek", |
| @@ -752,6 +859,17 @@ dependencies = [ | |||
| 752 | ] | 859 | ] |
| 753 | 860 | ||
| 754 | [[package]] | 861 | [[package]] |
| 862 | name = "half" | ||
| 863 | version = "2.7.1" | ||
| 864 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 865 | checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" | ||
| 866 | dependencies = [ | ||
| 867 | "cfg-if", | ||
| 868 | "crunchy", | ||
| 869 | "zerocopy", | ||
| 870 | ] | ||
| 871 | |||
| 872 | [[package]] | ||
| 755 | name = "hashbrown" | 873 | name = "hashbrown" |
| 756 | version = "0.15.5" | 874 | version = "0.15.5" |
| 757 | source = "registry+https://github.com/rust-lang/crates.io-index" | 875 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -778,6 +896,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 778 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | 896 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" |
| 779 | 897 | ||
| 780 | [[package]] | 898 | [[package]] |
| 899 | name = "hermit-abi" | ||
| 900 | version = "0.5.2" | ||
| 901 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 902 | checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" | ||
| 903 | |||
| 904 | [[package]] | ||
| 781 | name = "hex" | 905 | name = "hex" |
| 782 | version = "0.4.3" | 906 | version = "0.4.3" |
| 783 | source = "registry+https://github.com/rust-lang/crates.io-index" | 907 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -956,6 +1080,17 @@ dependencies = [ | |||
| 956 | ] | 1080 | ] |
| 957 | 1081 | ||
| 958 | [[package]] | 1082 | [[package]] |
| 1083 | name = "is-terminal" | ||
| 1084 | version = "0.4.17" | ||
| 1085 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1086 | checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" | ||
| 1087 | dependencies = [ | ||
| 1088 | "hermit-abi", | ||
| 1089 | "libc", | ||
| 1090 | "windows-sys 0.61.2", | ||
| 1091 | ] | ||
| 1092 | |||
| 1093 | [[package]] | ||
| 959 | name = "is_terminal_polyfill" | 1094 | name = "is_terminal_polyfill" |
| 960 | version = "1.70.2" | 1095 | version = "1.70.2" |
| 961 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1096 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -963,6 +1098,15 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" | |||
| 963 | 1098 | ||
| 964 | [[package]] | 1099 | [[package]] |
| 965 | name = "itertools" | 1100 | name = "itertools" |
| 1101 | version = "0.10.5" | ||
| 1102 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1103 | checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" | ||
| 1104 | dependencies = [ | ||
| 1105 | "either", | ||
| 1106 | ] | ||
| 1107 | |||
| 1108 | [[package]] | ||
| 1109 | name = "itertools" | ||
| 966 | version = "0.14.0" | 1110 | version = "0.14.0" |
| 967 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1111 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 968 | checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" | 1112 | checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" |
| @@ -1251,6 +1395,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1251 | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" | 1395 | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" |
| 1252 | 1396 | ||
| 1253 | [[package]] | 1397 | [[package]] |
| 1398 | name = "oorandom" | ||
| 1399 | version = "11.1.5" | ||
| 1400 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1401 | checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" | ||
| 1402 | |||
| 1403 | [[package]] | ||
| 1254 | name = "openssl-probe" | 1404 | name = "openssl-probe" |
| 1255 | version = "0.1.6" | 1405 | version = "0.1.6" |
| 1256 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1406 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1424,6 +1574,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1424 | checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" | 1574 | checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" |
| 1425 | 1575 | ||
| 1426 | [[package]] | 1576 | [[package]] |
| 1577 | name = "plotters" | ||
| 1578 | version = "0.3.7" | ||
| 1579 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1580 | checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" | ||
| 1581 | dependencies = [ | ||
| 1582 | "num-traits", | ||
| 1583 | "plotters-backend", | ||
| 1584 | "plotters-svg", | ||
| 1585 | "wasm-bindgen", | ||
| 1586 | "web-sys", | ||
| 1587 | ] | ||
| 1588 | |||
| 1589 | [[package]] | ||
| 1590 | name = "plotters-backend" | ||
| 1591 | version = "0.3.7" | ||
| 1592 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1593 | checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" | ||
| 1594 | |||
| 1595 | [[package]] | ||
| 1596 | name = "plotters-svg" | ||
| 1597 | version = "0.3.7" | ||
| 1598 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1599 | checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" | ||
| 1600 | dependencies = [ | ||
| 1601 | "plotters-backend", | ||
| 1602 | ] | ||
| 1603 | |||
| 1604 | [[package]] | ||
| 1427 | name = "portable-atomic" | 1605 | name = "portable-atomic" |
| 1428 | version = "1.13.1" | 1606 | version = "1.13.1" |
| 1429 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1607 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1598,7 +1776,7 @@ dependencies = [ | |||
| 1598 | "compact_str", | 1776 | "compact_str", |
| 1599 | "hashbrown 0.16.1", | 1777 | "hashbrown 0.16.1", |
| 1600 | "indoc", | 1778 | "indoc", |
| 1601 | "itertools", | 1779 | "itertools 0.14.0", |
| 1602 | "kasuari", | 1780 | "kasuari", |
| 1603 | "lru", | 1781 | "lru", |
| 1604 | "strum", | 1782 | "strum", |
| @@ -1650,7 +1828,7 @@ dependencies = [ | |||
| 1650 | "hashbrown 0.16.1", | 1828 | "hashbrown 0.16.1", |
| 1651 | "indoc", | 1829 | "indoc", |
| 1652 | "instability", | 1830 | "instability", |
| 1653 | "itertools", | 1831 | "itertools 0.14.0", |
| 1654 | "line-clipping", | 1832 | "line-clipping", |
| 1655 | "ratatui-core", | 1833 | "ratatui-core", |
| 1656 | "strum", | 1834 | "strum", |
| @@ -1660,6 +1838,26 @@ dependencies = [ | |||
| 1660 | ] | 1838 | ] |
| 1661 | 1839 | ||
| 1662 | [[package]] | 1840 | [[package]] |
| 1841 | name = "rayon" | ||
| 1842 | version = "1.11.0" | ||
| 1843 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1844 | checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" | ||
| 1845 | dependencies = [ | ||
| 1846 | "either", | ||
| 1847 | "rayon-core", | ||
| 1848 | ] | ||
| 1849 | |||
| 1850 | [[package]] | ||
| 1851 | name = "rayon-core" | ||
| 1852 | version = "1.13.0" | ||
| 1853 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1854 | checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" | ||
| 1855 | dependencies = [ | ||
| 1856 | "crossbeam-deque", | ||
| 1857 | "crossbeam-utils", | ||
| 1858 | ] | ||
| 1859 | |||
| 1860 | [[package]] | ||
| 1663 | name = "redox_syscall" | 1861 | name = "redox_syscall" |
| 1664 | version = "0.5.18" | 1862 | version = "0.5.18" |
| 1665 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1863 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1761,6 +1959,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1761 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | 1959 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" |
| 1762 | 1960 | ||
| 1763 | [[package]] | 1961 | [[package]] |
| 1962 | name = "same-file" | ||
| 1963 | version = "1.0.6" | ||
| 1964 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1965 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" | ||
| 1966 | dependencies = [ | ||
| 1967 | "winapi-util", | ||
| 1968 | ] | ||
| 1969 | |||
| 1970 | [[package]] | ||
| 1764 | name = "scopeguard" | 1971 | name = "scopeguard" |
| 1765 | version = "1.2.0" | 1972 | version = "1.2.0" |
| 1766 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1973 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2120,6 +2327,16 @@ dependencies = [ | |||
| 2120 | ] | 2327 | ] |
| 2121 | 2328 | ||
| 2122 | [[package]] | 2329 | [[package]] |
| 2330 | name = "tinytemplate" | ||
| 2331 | version = "1.2.1" | ||
| 2332 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2333 | checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" | ||
| 2334 | dependencies = [ | ||
| 2335 | "serde", | ||
| 2336 | "serde_json", | ||
| 2337 | ] | ||
| 2338 | |||
| 2339 | [[package]] | ||
| 2123 | name = "typenum" | 2340 | name = "typenum" |
| 2124 | version = "1.19.0" | 2341 | version = "1.19.0" |
| 2125 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2342 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2155,7 +2372,7 @@ version = "2.0.1" | |||
| 2155 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2372 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2156 | checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" | 2373 | checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" |
| 2157 | dependencies = [ | 2374 | dependencies = [ |
| 2158 | "itertools", | 2375 | "itertools 0.14.0", |
| 2159 | "unicode-segmentation", | 2376 | "unicode-segmentation", |
| 2160 | "unicode-width", | 2377 | "unicode-width", |
| 2161 | ] | 2378 | ] |
| @@ -2239,6 +2456,16 @@ dependencies = [ | |||
| 2239 | ] | 2456 | ] |
| 2240 | 2457 | ||
| 2241 | [[package]] | 2458 | [[package]] |
| 2459 | name = "walkdir" | ||
| 2460 | version = "2.5.0" | ||
| 2461 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2462 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" | ||
| 2463 | dependencies = [ | ||
| 2464 | "same-file", | ||
| 2465 | "winapi-util", | ||
| 2466 | ] | ||
| 2467 | |||
| 2468 | [[package]] | ||
| 2242 | name = "wasi" | 2469 | name = "wasi" |
| 2243 | version = "0.11.1+wasi-snapshot-preview1" | 2470 | version = "0.11.1+wasi-snapshot-preview1" |
| 2244 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2471 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2342,6 +2569,16 @@ dependencies = [ | |||
| 2342 | ] | 2569 | ] |
| 2343 | 2570 | ||
| 2344 | [[package]] | 2571 | [[package]] |
| 2572 | name = "web-sys" | ||
| 2573 | version = "0.3.91" | ||
| 2574 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2575 | checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" | ||
| 2576 | dependencies = [ | ||
| 2577 | "js-sys", | ||
| 2578 | "wasm-bindgen", | ||
| 2579 | ] | ||
| 2580 | |||
| 2581 | [[package]] | ||
| 2345 | name = "wezterm-bidi" | 2582 | name = "wezterm-bidi" |
| 2346 | version = "0.2.3" | 2583 | version = "0.2.3" |
| 2347 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2584 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2430,6 +2667,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2430 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" | 2667 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" |
| 2431 | 2668 | ||
| 2432 | [[package]] | 2669 | [[package]] |
| 2670 | name = "winapi-util" | ||
| 2671 | version = "0.1.11" | ||
| 2672 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2673 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" | ||
| 2674 | dependencies = [ | ||
| 2675 | "windows-sys 0.61.2", | ||
| 2676 | ] | ||
| 2677 | |||
| 2678 | [[package]] | ||
| 2433 | name = "winapi-x86_64-pc-windows-gnu" | 2679 | name = "winapi-x86_64-pc-windows-gnu" |
| 2434 | version = "0.4.0" | 2680 | version = "0.4.0" |
| 2435 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2681 | source = "registry+https://github.com/rust-lang/crates.io-index" |
Cargo.toml
| Old | New | ||
|---|---|---|---|
| @@ -26,3 +26,8 @@ clap_mangen = "0.2" | |||
| 26 | [dev-dependencies] | 26 | [dev-dependencies] |
| 27 | tempfile = "3" | 27 | tempfile = "3" |
| 28 | proptest = "1" | 28 | proptest = "1" |
| 29 | criterion = { version = "0.5", features = ["html_reports"] } | ||
| 30 | |||
| 31 | [[bench]] | ||
| 32 | name = "core_ops" | ||
| 33 | harness = false | ||
benches/core_ops.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,260 @@ | |||
| 1 | use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; | ||
| 2 | use ed25519_dalek::SigningKey; | ||
| 3 | use git2::Repository; | ||
| 4 | use rand_core::OsRng; | ||
| 5 | use tempfile::TempDir; | ||
| 6 | |||
| 7 | use git_collab::dag; | ||
| 8 | use git_collab::event::{Action, Author, Event}; | ||
| 9 | use git_collab::state::{IssueState, PatchState}; | ||
| 10 | |||
| 11 | fn test_author() -> Author { | ||
| 12 | Author { | ||
| 13 | name: "Bench User".to_string(), | ||
| 14 | email: "bench@example.com".to_string(), | ||
| 15 | } | ||
| 16 | } | ||
| 17 | |||
| 18 | fn test_key() -> SigningKey { | ||
| 19 | SigningKey::generate(&mut OsRng) | ||
| 20 | } | ||
| 21 | |||
| 22 | fn now() -> String { | ||
| 23 | chrono::Utc::now().to_rfc3339() | ||
| 24 | } | ||
| 25 | |||
| 26 | fn init_repo(dir: &std::path::Path) -> Repository { | ||
| 27 | let repo = Repository::init(dir).expect("init repo"); | ||
| 28 | { | ||
| 29 | let mut config = repo.config().unwrap(); | ||
| 30 | config.set_str("user.name", "Bench User").unwrap(); | ||
| 31 | config.set_str("user.email", "bench@example.com").unwrap(); | ||
| 32 | } | ||
| 33 | repo | ||
| 34 | } | ||
| 35 | |||
| 36 | /// Create N issues in a repo, each with no comments. Returns the repo and temp dir. | ||
| 37 | fn setup_issues(n: usize) -> (Repository, TempDir) { | ||
| 38 | let dir = TempDir::new().unwrap(); | ||
| 39 | let repo = init_repo(dir.path()); | ||
| 40 | let sk = test_key(); | ||
| 41 | let author = test_author(); | ||
| 42 | |||
| 43 | for i in 0..n { | ||
| 44 | let event = Event { | ||
| 45 | timestamp: now(), | ||
| 46 | author: author.clone(), | ||
| 47 | action: Action::IssueOpen { | ||
| 48 | title: format!("Issue {}", i), | ||
| 49 | body: format!("Body for issue {}", i), | ||
| 50 | relates_to: None, | ||
| 51 | }, | ||
| 52 | clock: 0, | ||
| 53 | }; | ||
| 54 | let oid = dag::create_root_event(&repo, &event, &sk).unwrap(); | ||
| 55 | let ref_name = format!("refs/collab/issues/{}", oid); | ||
| 56 | repo.reference(&ref_name, oid, false, "bench").unwrap(); | ||
| 57 | } | ||
| 58 | |||
| 59 | (repo, dir) | ||
| 60 | } | ||
| 61 | |||
| 62 | /// Create N patches in a repo. Returns the repo and temp dir. | ||
| 63 | fn setup_patches(n: usize) -> (Repository, TempDir) { | ||
| 64 | let dir = TempDir::new().unwrap(); | ||
| 65 | let repo = init_repo(dir.path()); | ||
| 66 | let sk = test_key(); | ||
| 67 | let author = test_author(); | ||
| 68 | |||
| 69 | for i in 0..n { | ||
| 70 | let event = Event { | ||
| 71 | timestamp: now(), | ||
| 72 | author: author.clone(), | ||
| 73 | action: Action::PatchCreate { | ||
| 74 | title: format!("Patch {}", i), | ||
| 75 | body: format!("Body for patch {}", i), | ||
| 76 | base_ref: "main".to_string(), | ||
| 77 | branch: format!("feature-{}", i), | ||
| 78 | fixes: None, | ||
| 79 | }, | ||
| 80 | clock: 0, | ||
| 81 | }; | ||
| 82 | let oid = dag::create_root_event(&repo, &event, &sk).unwrap(); | ||
| 83 | let ref_name = format!("refs/collab/patches/{}", oid); | ||
| 84 | repo.reference(&ref_name, oid, false, "bench").unwrap(); | ||
| 85 | } | ||
| 86 | |||
| 87 | (repo, dir) | ||
| 88 | } | ||
| 89 | |||
| 90 | /// Create a single issue with N comment events appended. Returns (repo, ref_name, id, tempdir). | ||
| 91 | fn setup_issue_with_comments(n: usize) -> (Repository, String, String, TempDir) { | ||
| 92 | let dir = TempDir::new().unwrap(); | ||
| 93 | let repo = init_repo(dir.path()); | ||
| 94 | let sk = test_key(); | ||
| 95 | let author = test_author(); | ||
| 96 | |||
| 97 | let open_event = Event { | ||
| 98 | timestamp: now(), | ||
| 99 | author: author.clone(), | ||
| 100 | action: Action::IssueOpen { | ||
| 101 | title: "Big issue".to_string(), | ||
| 102 | body: "An issue with many comments".to_string(), | ||
| 103 | relates_to: None, | ||
| 104 | }, | ||
| 105 | clock: 0, | ||
| 106 | }; | ||
| 107 | let oid = dag::create_root_event(&repo, &open_event, &sk).unwrap(); | ||
| 108 | let id = oid.to_string(); | ||
| 109 | let ref_name = format!("refs/collab/issues/{}", id); | ||
| 110 | repo.reference(&ref_name, oid, false, "bench").unwrap(); | ||
| 111 | |||
| 112 | for i in 0..n { | ||
| 113 | let event = Event { | ||
| 114 | timestamp: now(), | ||
| 115 | author: author.clone(), | ||
| 116 | action: Action::IssueComment { | ||
| 117 | body: format!("Comment number {}", i), | ||
| 118 | }, | ||
| 119 | clock: 0, | ||
| 120 | }; | ||
| 121 | dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); | ||
| 122 | } | ||
| 123 | |||
| 124 | (repo, ref_name, id, dir) | ||
| 125 | } | ||
| 126 | |||
| 127 | fn bench_list_issues(c: &mut Criterion) { | ||
| 128 | let mut group = c.benchmark_group("list_issues"); | ||
| 129 | for count in [10, 100, 1000] { | ||
| 130 | let (repo, _dir) = setup_issues(count); | ||
| 131 | group.bench_with_input( | ||
| 132 | BenchmarkId::from_parameter(count), | ||
| 133 | &count, | ||
| 134 | |b, _| { | ||
| 135 | b.iter(|| { | ||
| 136 | let issues = git_collab::state::list_issues(&repo).unwrap(); | ||
| 137 | assert_eq!(issues.len(), count); | ||
| 138 | }); | ||
| 139 | }, | ||
| 140 | ); | ||
| 141 | } | ||
| 142 | group.finish(); | ||
| 143 | } | ||
| 144 | |||
| 145 | fn bench_list_patches(c: &mut Criterion) { | ||
| 146 | let mut group = c.benchmark_group("list_patches"); | ||
| 147 | for count in [10, 100, 1000] { | ||
| 148 | let (repo, _dir) = setup_patches(count); | ||
| 149 | group.bench_with_input( | ||
| 150 | BenchmarkId::from_parameter(count), | ||
| 151 | &count, | ||
| 152 | |b, _| { | ||
| 153 | b.iter(|| { | ||
| 154 | let patches = git_collab::state::list_patches(&repo).unwrap(); | ||
| 155 | assert_eq!(patches.len(), count); | ||
| 156 | }); | ||
| 157 | }, | ||
| 158 | ); | ||
| 159 | } | ||
| 160 | group.finish(); | ||
| 161 | } | ||
| 162 | |||
| 163 | fn bench_walk_events(c: &mut Criterion) { | ||
| 164 | let mut group = c.benchmark_group("walk_events"); | ||
| 165 | for count in [10, 100, 500] { | ||
| 166 | let (repo, ref_name, _id, _dir) = setup_issue_with_comments(count); | ||
| 167 | group.bench_with_input( | ||
| 168 | BenchmarkId::from_parameter(count), | ||
| 169 | &count, | ||
| 170 | |b, _| { | ||
| 171 | b.iter(|| { | ||
| 172 | let events = dag::walk_events(&repo, &ref_name).unwrap(); | ||
| 173 | // 1 open + N comments | ||
| 174 | assert_eq!(events.len(), count + 1); | ||
| 175 | }); | ||
| 176 | }, | ||
| 177 | ); | ||
| 178 | } | ||
| 179 | group.finish(); | ||
| 180 | } | ||
| 181 | |||
| 182 | fn bench_issue_from_ref(c: &mut Criterion) { | ||
| 183 | let mut group = c.benchmark_group("issue_from_ref"); | ||
| 184 | for count in [10, 100, 500] { | ||
| 185 | let (repo, ref_name, id, _dir) = setup_issue_with_comments(count); | ||
| 186 | group.bench_with_input( | ||
| 187 | BenchmarkId::from_parameter(count), | ||
| 188 | &count, | ||
| 189 | |b, _| { | ||
| 190 | b.iter(|| { | ||
| 191 | let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 192 | assert_eq!(state.comments.len(), count); | ||
| 193 | }); | ||
| 194 | }, | ||
| 195 | ); | ||
| 196 | } | ||
| 197 | group.finish(); | ||
| 198 | } | ||
| 199 | |||
| 200 | fn bench_patch_from_ref(c: &mut Criterion) { | ||
| 201 | let mut group = c.benchmark_group("patch_from_ref"); | ||
| 202 | let dir = TempDir::new().unwrap(); | ||
| 203 | let repo = init_repo(dir.path()); | ||
| 204 | let sk = test_key(); | ||
| 205 | let author = test_author(); | ||
| 206 | |||
| 207 | // Create a patch with many comments | ||
| 208 | for count in [10, 100, 500] { | ||
| 209 | let create_event = Event { | ||
| 210 | timestamp: now(), | ||
| 211 | author: author.clone(), | ||
| 212 | action: Action::PatchCreate { | ||
| 213 | title: format!("Patch with {} comments", count), | ||
| 214 | body: "A patch".to_string(), | ||
| 215 | base_ref: "main".to_string(), | ||
| 216 | branch: format!("feature-bench-{}", count), | ||
| 217 | fixes: None, | ||
| 218 | }, | ||
| 219 | clock: 0, | ||
| 220 | }; | ||
| 221 | let oid = dag::create_root_event(&repo, &create_event, &sk).unwrap(); | ||
| 222 | let id = oid.to_string(); | ||
| 223 | let ref_name = format!("refs/collab/patches/{}", id); | ||
| 224 | repo.reference(&ref_name, oid, false, "bench").unwrap(); | ||
| 225 | |||
| 226 | for i in 0..count { | ||
| 227 | let event = Event { | ||
| 228 | timestamp: now(), | ||
| 229 | author: author.clone(), | ||
| 230 | action: Action::PatchComment { | ||
| 231 | body: format!("Patch comment {}", i), | ||
| 232 | }, | ||
| 233 | clock: 0, | ||
| 234 | }; | ||
| 235 | dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); | ||
| 236 | } | ||
| 237 | |||
| 238 | group.bench_with_input( | ||
| 239 | BenchmarkId::from_parameter(count), | ||
| 240 | &count, | ||
| 241 | |b, _| { | ||
| 242 | b.iter(|| { | ||
| 243 | let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 244 | assert_eq!(state.comments.len(), count); | ||
| 245 | }); | ||
| 246 | }, | ||
| 247 | ); | ||
| 248 | } | ||
| 249 | group.finish(); | ||
| 250 | } | ||
| 251 | |||
| 252 | criterion_group!( | ||
| 253 | benches, | ||
| 254 | bench_list_issues, | ||
| 255 | bench_list_patches, | ||
| 256 | bench_walk_events, | ||
| 257 | bench_issue_from_ref, | ||
| 258 | bench_patch_from_ref, | ||
| 259 | ); | ||
| 260 | criterion_main!(benches); | ||
src/cache.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,75 @@ | |||
| 1 | use std::fs; | ||
| 2 | use std::path::PathBuf; | ||
| 3 | |||
| 4 | use git2::{Oid, Repository}; | ||
| 5 | use serde::de::DeserializeOwned; | ||
| 6 | use serde::Serialize; | ||
| 7 | |||
| 8 | /// Return the cache directory path for a given repository. | ||
| 9 | fn cache_dir(repo: &Repository) -> PathBuf { | ||
| 10 | repo.path().join("collab").join("cache") | ||
| 11 | } | ||
| 12 | |||
| 13 | /// Sanitize a ref name for use as a filename by replacing `/` with `_`. | ||
| 14 | fn sanitize_ref_name(ref_name: &str) -> String { | ||
| 15 | ref_name.replace('/', "_") | ||
| 16 | } | ||
| 17 | |||
| 18 | /// Cache entry stored on disk: the tip OID at cache time + serialized state. | ||
| 19 | #[derive(serde::Serialize, serde::Deserialize)] | ||
| 20 | struct CacheEntry { | ||
| 21 | tip_oid: String, | ||
| 22 | state: serde_json::Value, | ||
| 23 | } | ||
| 24 | |||
| 25 | /// Try to read a cached state for the given ref. | ||
| 26 | /// | ||
| 27 | /// Returns `Some(state)` if a cache file exists and its stored tip OID matches | ||
| 28 | /// the current ref tip. Returns `None` on any mismatch, missing file, or error. | ||
| 29 | pub fn get_cached_state<T: DeserializeOwned>( | ||
| 30 | repo: &Repository, | ||
| 31 | ref_name: &str, | ||
| 32 | ) -> Option<T> { | ||
| 33 | let current_tip = repo.refname_to_id(ref_name).ok()?; | ||
| 34 | let path = cache_dir(repo).join(sanitize_ref_name(ref_name)); | ||
| 35 | let data = fs::read_to_string(&path).ok()?; | ||
| 36 | let entry: CacheEntry = serde_json::from_str(&data).ok()?; | ||
| 37 | if entry.tip_oid != current_tip.to_string() { | ||
| 38 | return None; | ||
| 39 | } | ||
| 40 | serde_json::from_value(entry.state).ok() | ||
| 41 | } | ||
| 42 | |||
| 43 | /// Write a cached state for the given ref, keyed by the current tip OID. | ||
| 44 | /// | ||
| 45 | /// Silently ignores errors (cache is best-effort). | ||
| 46 | pub fn set_cached_state<T: Serialize>( | ||
| 47 | repo: &Repository, | ||
| 48 | ref_name: &str, | ||
| 49 | tip_oid: Oid, | ||
| 50 | state: &T, | ||
| 51 | ) { | ||
| 52 | let dir = cache_dir(repo); | ||
| 53 | if fs::create_dir_all(&dir).is_err() { | ||
| 54 | return; | ||
| 55 | } | ||
| 56 | let entry = CacheEntry { | ||
| 57 | tip_oid: tip_oid.to_string(), | ||
| 58 | state: match serde_json::to_value(state) { | ||
| 59 | Ok(v) => v, | ||
| 60 | Err(_) => return, | ||
| 61 | }, | ||
| 62 | }; | ||
| 63 | let json = match serde_json::to_string(&entry) { | ||
| 64 | Ok(j) => j, | ||
| 65 | Err(_) => return, | ||
| 66 | }; | ||
| 67 | let path = dir.join(sanitize_ref_name(ref_name)); | ||
| 68 | let _ = fs::write(&path, json); | ||
| 69 | } | ||
| 70 | |||
| 71 | /// Invalidate (delete) the cache entry for a ref. Best-effort. | ||
| 72 | pub fn invalidate(repo: &Repository, ref_name: &str) { | ||
| 73 | let path = cache_dir(repo).join(sanitize_ref_name(ref_name)); | ||
| 74 | let _ = fs::remove_file(path); | ||
| 75 | } | ||
src/cli.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,17 @@ | |||
| 1 | use clap::{Parser, Subcommand}; | 1 | use clap::{Parser, Subcommand, ValueEnum}; |
| 2 | use clap_complete::Shell; | 2 | use clap_complete::Shell; |
| 3 | 3 | ||
| 4 | #[derive(Debug, Clone, Copy, Default, ValueEnum)] | ||
| 5 | pub enum SortMode { | ||
| 6 | /// Sort by last updated (most recent first) | ||
| 7 | #[default] | ||
| 8 | Recent, | ||
| 9 | /// Sort by creation time (newest first) | ||
| 10 | Created, | ||
| 11 | /// Sort alphabetically by title | ||
| 12 | Alpha, | ||
| 13 | } | ||
| 14 | |||
| 4 | #[derive(Parser)] | 15 | #[derive(Parser)] |
| 5 | #[command( | 16 | #[command( |
| 6 | name = "git-collab", | 17 | name = "git-collab", |
| @@ -61,6 +72,13 @@ pub enum Commands { | |||
| 61 | /// Manage trusted keys | 72 | /// Manage trusted keys |
| 62 | #[command(subcommand)] | 73 | #[command(subcommand)] |
| 63 | Key(KeyCmd), | 74 | Key(KeyCmd), |
| 75 | |||
| 76 | /// Show current user identity | ||
| 77 | Whoami, | ||
| 78 | |||
| 79 | /// Manage identity aliases | ||
| 80 | #[command(subcommand)] | ||
| 81 | Identity(IdentityCmd), | ||
| 64 | } | 82 | } |
| 65 | 83 | ||
| 66 | #[derive(Subcommand)] | 84 | #[derive(Subcommand)] |
| @@ -73,12 +91,18 @@ pub enum IssueCmd { | |||
| 73 | /// Issue body | 91 | /// Issue body |
| 74 | #[arg(short, long, default_value = "")] | 92 | #[arg(short, long, default_value = "")] |
| 75 | body: String, | 93 | body: String, |
| 94 | /// Related issue ID | ||
| 95 | #[arg(long)] | ||
| 96 | relates_to: Option<String>, | ||
| 76 | }, | 97 | }, |
| 77 | /// List issues | 98 | /// List issues |
| 78 | List { | 99 | List { |
| 79 | /// Show closed issues too | 100 | /// Show closed issues too |
| 80 | #[arg(short = 'a', long)] | 101 | #[arg(short = 'a', long)] |
| 81 | all: bool, | 102 | all: bool, |
| 103 | /// Include archived issues | ||
| 104 | #[arg(long)] | ||
| 105 | archived: bool, | ||
| 82 | /// Maximum number of issues to display | 106 | /// Maximum number of issues to display |
| 83 | #[arg(short = 'n', long)] | 107 | #[arg(short = 'n', long)] |
| 84 | limit: Option<usize>, | 108 | limit: Option<usize>, |
| @@ -88,6 +112,9 @@ pub enum IssueCmd { | |||
| 88 | /// Output as JSON | 112 | /// Output as JSON |
| 89 | #[arg(long)] | 113 | #[arg(long)] |
| 90 | json: bool, | 114 | json: bool, |
| 115 | /// Sort order: recent (default), created, alpha | ||
| 116 | #[arg(long, default_value = "recent")] | ||
| 117 | sort: SortMode, | ||
| 91 | }, | 118 | }, |
| 92 | /// Show issue details | 119 | /// Show issue details |
| 93 | Show { | 120 | Show { |
| @@ -152,8 +179,8 @@ pub enum IssueCmd { | |||
| 152 | #[arg(short, long)] | 179 | #[arg(short, long)] |
| 153 | reason: Option<String>, | 180 | reason: Option<String>, |
| 154 | }, | 181 | }, |
| 155 | /// Reopen a closed issue | 182 | /// Delete an issue (removes the local collab ref) |
| 156 | Reopen { | 183 | Delete { |
| 157 | /// Issue ID (prefix match) | 184 | /// Issue ID (prefix match) |
| 158 | id: String, | 185 | id: String, |
| 159 | }, | 186 | }, |
| @@ -171,13 +198,23 @@ pub enum KeyCmd { | |||
| 171 | /// Human-readable label for the key | 198 | /// Human-readable label for the key |
| 172 | #[arg(long)] | 199 | #[arg(long)] |
| 173 | label: Option<String>, | 200 | label: Option<String>, |
| 201 | /// Store in global trust store (~/.config/git-collab/trusted-keys) | ||
| 202 | #[arg(long)] | ||
| 203 | global: bool, | ||
| 174 | }, | 204 | }, |
| 175 | /// List trusted public keys | 205 | /// List trusted public keys |
| 176 | List, | 206 | List { |
| 207 | /// Show only global trusted keys | ||
| 208 | #[arg(long)] | ||
| 209 | global: bool, | ||
| 210 | }, | ||
| 177 | /// Remove a trusted public key | 211 | /// Remove a trusted public key |
| 178 | Remove { | 212 | Remove { |
| 179 | /// Base64-encoded public key to remove | 213 | /// Base64-encoded public key to remove |
| 180 | pubkey: String, | 214 | pubkey: String, |
| 215 | /// Remove from global trust store (~/.config/git-collab/trusted-keys) | ||
| 216 | #[arg(long)] | ||
| 217 | global: bool, | ||
| 181 | }, | 218 | }, |
| 182 | } | 219 | } |
| 183 | 220 | ||
| @@ -206,6 +243,9 @@ pub enum PatchCmd { | |||
| 206 | /// Show closed/merged patches too | 243 | /// Show closed/merged patches too |
| 207 | #[arg(short = 'a', long)] | 244 | #[arg(short = 'a', long)] |
| 208 | all: bool, | 245 | all: bool, |
| 246 | /// Include archived patches | ||
| 247 | #[arg(long)] | ||
| 248 | archived: bool, | ||
| 209 | /// Maximum number of patches to display | 249 | /// Maximum number of patches to display |
| 210 | #[arg(short = 'n', long)] | 250 | #[arg(short = 'n', long)] |
| 211 | limit: Option<usize>, | 251 | limit: Option<usize>, |
| @@ -215,6 +255,9 @@ pub enum PatchCmd { | |||
| 215 | /// Output as JSON | 255 | /// Output as JSON |
| 216 | #[arg(long)] | 256 | #[arg(long)] |
| 217 | json: bool, | 257 | json: bool, |
| 258 | /// Sort order: recent (default), created, alpha | ||
| 259 | #[arg(long, default_value = "recent")] | ||
| 260 | sort: SortMode, | ||
| 218 | }, | 261 | }, |
| 219 | /// Show patch details | 262 | /// Show patch details |
| 220 | Show { | 263 | Show { |
| @@ -275,4 +318,25 @@ pub enum PatchCmd { | |||
| 275 | #[arg(short, long)] | 318 | #[arg(short, long)] |
| 276 | reason: Option<String>, | 319 | reason: Option<String>, |
| 277 | }, | 320 | }, |
| 321 | /// Delete a patch (removes the local collab ref) | ||
| 322 | Delete { | ||
| 323 | /// Patch ID (prefix match) | ||
| 324 | id: String, | ||
| 325 | }, | ||
| 326 | } | ||
| 327 | |||
| 328 | #[derive(Subcommand)] | ||
| 329 | pub enum IdentityCmd { | ||
| 330 | /// Link another email to your current identity | ||
| 331 | Alias { | ||
| 332 | /// Email address to add as alias | ||
| 333 | email: String, | ||
| 334 | }, | ||
| 335 | /// Remove an email alias | ||
| 336 | Unalias { | ||
| 337 | /// Email address to remove | ||
| 338 | email: String, | ||
| 339 | }, | ||
| 340 | /// Show current identity and aliases | ||
| 341 | List, | ||
| 278 | } | 342 | } |
src/event.rs
| Old | New | ||
|---|---|---|---|
| @@ -22,6 +22,8 @@ pub enum Action { | |||
| 22 | IssueOpen { | 22 | IssueOpen { |
| 23 | title: String, | 23 | title: String, |
| 24 | body: String, | 24 | body: String, |
| 25 | #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| 26 | relates_to: Option<String>, | ||
| 25 | }, | 27 | }, |
| 26 | #[serde(rename = "issue.comment")] | 28 | #[serde(rename = "issue.comment")] |
| 27 | IssueComment { | 29 | IssueComment { |
src/identity.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,12 @@ | |||
| 1 | use std::fs; | ||
| 2 | use std::path::PathBuf; | ||
| 3 | |||
| 4 | use base64::Engine; | ||
| 1 | use git2::Repository; | 5 | use git2::Repository; |
| 2 | 6 | ||
| 7 | use crate::error::Error; | ||
| 3 | use crate::event::Author; | 8 | use crate::event::Author; |
| 9 | use crate::signing; | ||
| 4 | 10 | ||
| 5 | pub fn get_author(repo: &Repository) -> Result<Author, git2::Error> { | 11 | pub fn get_author(repo: &Repository) -> Result<Author, git2::Error> { |
| 6 | let config = repo.config()?; | 12 | let config = repo.config()?; |
| @@ -12,3 +18,175 @@ pub fn get_author(repo: &Repository) -> Result<Author, git2::Error> { | |||
| 12 | pub fn author_signature(author: &Author) -> Result<git2::Signature<'static>, git2::Error> { | 18 | pub fn author_signature(author: &Author) -> Result<git2::Signature<'static>, git2::Error> { |
| 13 | git2::Signature::now(&author.name, &author.email) | 19 | git2::Signature::now(&author.name, &author.email) |
| 14 | } | 20 | } |
| 21 | |||
| 22 | /// Path to the identity-aliases file inside the repo's collab directory. | ||
| 23 | fn aliases_path(repo: &Repository) -> PathBuf { | ||
| 24 | repo.path().join("collab").join("identity-aliases") | ||
| 25 | } | ||
| 26 | |||
| 27 | /// Load the list of email aliases from `.git/collab/identity-aliases`. | ||
| 28 | /// Returns an empty vec if the file doesn't exist. | ||
| 29 | pub fn load_aliases(repo: &Repository) -> Result<Vec<String>, Error> { | ||
| 30 | let path = aliases_path(repo); | ||
| 31 | if !path.exists() { | ||
| 32 | return Ok(Vec::new()); | ||
| 33 | } | ||
| 34 | let contents = fs::read_to_string(&path)?; | ||
| 35 | let aliases: Vec<String> = contents | ||
| 36 | .lines() | ||
| 37 | .map(|l| l.trim().to_string()) | ||
| 38 | .filter(|l| !l.is_empty()) | ||
| 39 | .collect(); | ||
| 40 | Ok(aliases) | ||
| 41 | } | ||
| 42 | |||
| 43 | /// Save the list of email aliases to `.git/collab/identity-aliases`. | ||
| 44 | fn save_aliases(repo: &Repository, aliases: &[String]) -> Result<(), Error> { | ||
| 45 | let path = aliases_path(repo); | ||
| 46 | if let Some(parent) = path.parent() { | ||
| 47 | fs::create_dir_all(parent)?; | ||
| 48 | } | ||
| 49 | let contents = aliases.join("\n"); | ||
| 50 | let contents = if contents.is_empty() { | ||
| 51 | contents | ||
| 52 | } else { | ||
| 53 | format!("{}\n", contents) | ||
| 54 | }; | ||
| 55 | fs::write(&path, contents)?; | ||
| 56 | Ok(()) | ||
| 57 | } | ||
| 58 | |||
| 59 | /// Add an email alias for the current identity. Returns true if added, false if already present. | ||
| 60 | pub fn add_alias(repo: &Repository, email: &str) -> Result<bool, Error> { | ||
| 61 | let author = get_author(repo)?; | ||
| 62 | if email == author.email { | ||
| 63 | return Err(Error::Cmd(format!( | ||
| 64 | "'{}' is already your primary email; cannot add as alias", | ||
| 65 | |||
| 66 | ))); | ||
| 67 | } | ||
| 68 | let mut aliases = load_aliases(repo)?; | ||
| 69 | if aliases.iter().any(|a| a == email) { | ||
| 70 | return Ok(false); | ||
| 71 | } | ||
| 72 | aliases.push(email.to_string()); | ||
| 73 | save_aliases(repo, &aliases)?; | ||
| 74 | Ok(true) | ||
| 75 | } | ||
| 76 | |||
| 77 | /// Remove an email alias. Returns an error if the alias is not found. | ||
| 78 | pub fn remove_alias(repo: &Repository, email: &str) -> Result<(), Error> { | ||
| 79 | let mut aliases = load_aliases(repo)?; | ||
| 80 | let before = aliases.len(); | ||
| 81 | aliases.retain(|a| a != email); | ||
| 82 | if aliases.len() == before { | ||
| 83 | return Err(Error::Cmd(format!( | ||
| 84 | "alias '{}' not found", | ||
| 85 | |||
| 86 | ))); | ||
| 87 | } | ||
| 88 | save_aliases(repo, &aliases)?; | ||
| 89 | Ok(()) | ||
| 90 | } | ||
| 91 | |||
| 92 | /// Display full identity information: name, email, signing key, aliases. | ||
| 93 | pub fn whoami(repo: &Repository) -> Result<String, Error> { | ||
| 94 | let author = get_author(repo)?; | ||
| 95 | let mut lines = Vec::new(); | ||
| 96 | lines.push(format!("Name: {}", author.name)); | ||
| 97 | lines.push(format!("Email: {}", author.email)); | ||
| 98 | |||
| 99 | // Signing key | ||
| 100 | match signing::signing_key_dir() { | ||
| 101 | Ok(config_dir) => match signing::load_verifying_key(&config_dir) { | ||
| 102 | Ok(vk) => { | ||
| 103 | let pubkey_b64 = | ||
| 104 | base64::engine::general_purpose::STANDARD.encode(vk.to_bytes()); | ||
| 105 | lines.push(format!("Signing key: {}", pubkey_b64)); | ||
| 106 | } | ||
| 107 | Err(_) => { | ||
| 108 | lines.push("Signing key: (not configured)".to_string()); | ||
| 109 | } | ||
| 110 | }, | ||
| 111 | Err(_) => { | ||
| 112 | lines.push("Signing key: (not configured)".to_string()); | ||
| 113 | } | ||
| 114 | } | ||
| 115 | |||
| 116 | // Aliases | ||
| 117 | let aliases = load_aliases(repo)?; | ||
| 118 | if aliases.is_empty() { | ||
| 119 | lines.push("Aliases: (none)".to_string()); | ||
| 120 | } else { | ||
| 121 | lines.push("Aliases:".to_string()); | ||
| 122 | for alias in &aliases { | ||
| 123 | lines.push(format!(" {}", alias)); | ||
| 124 | } | ||
| 125 | } | ||
| 126 | |||
| 127 | Ok(lines.join("\n")) | ||
| 128 | } | ||
| 129 | |||
| 130 | #[cfg(test)] | ||
| 131 | mod tests { | ||
| 132 | use super::*; | ||
| 133 | use tempfile::TempDir; | ||
| 134 | |||
| 135 | fn test_repo(name: &str, email: &str) -> (TempDir, Repository) { | ||
| 136 | let dir = TempDir::new().unwrap(); | ||
| 137 | let repo = Repository::init(dir.path()).unwrap(); | ||
| 138 | { | ||
| 139 | let mut config = repo.config().unwrap(); | ||
| 140 | config.set_str("user.name", name).unwrap(); | ||
| 141 | config.set_str("user.email", email).unwrap(); | ||
| 142 | } | ||
| 143 | (dir, repo) | ||
| 144 | } | ||
| 145 | |||
| 146 | #[test] | ||
| 147 | fn test_load_aliases_empty_when_no_file() { | ||
| 148 | let (_dir, repo) = test_repo("Test", "test@example.com"); | ||
| 149 | let aliases = load_aliases(&repo).unwrap(); | ||
| 150 | assert!(aliases.is_empty()); | ||
| 151 | } | ||
| 152 | |||
| 153 | #[test] | ||
| 154 | fn test_add_and_load_alias() { | ||
| 155 | let (_dir, repo) = test_repo("Test", "test@example.com"); | ||
| 156 | let added = add_alias(&repo, "other@example.com").unwrap(); | ||
| 157 | assert!(added); | ||
| 158 | let aliases = load_aliases(&repo).unwrap(); | ||
| 159 | assert_eq!(aliases, vec!["other@example.com"]); | ||
| 160 | } | ||
| 161 | |||
| 162 | #[test] | ||
| 163 | fn test_add_duplicate_alias_returns_false() { | ||
| 164 | let (_dir, repo) = test_repo("Test", "test@example.com"); | ||
| 165 | add_alias(&repo, "other@example.com").unwrap(); | ||
| 166 | let added = add_alias(&repo, "other@example.com").unwrap(); | ||
| 167 | assert!(!added); | ||
| 168 | } | ||
| 169 | |||
| 170 | #[test] | ||
| 171 | fn test_add_primary_email_errors() { | ||
| 172 | let (_dir, repo) = test_repo("Test", "test@example.com"); | ||
| 173 | let result = add_alias(&repo, "test@example.com"); | ||
| 174 | assert!(result.is_err()); | ||
| 175 | } | ||
| 176 | |||
| 177 | #[test] | ||
| 178 | fn test_remove_alias() { | ||
| 179 | let (_dir, repo) = test_repo("Test", "test@example.com"); | ||
| 180 | add_alias(&repo, "other@example.com").unwrap(); | ||
| 181 | remove_alias(&repo, "other@example.com").unwrap(); | ||
| 182 | let aliases = load_aliases(&repo).unwrap(); | ||
| 183 | assert!(aliases.is_empty()); | ||
| 184 | } | ||
| 185 | |||
| 186 | #[test] | ||
| 187 | fn test_remove_nonexistent_alias_errors() { | ||
| 188 | let (_dir, repo) = test_repo("Test", "test@example.com"); | ||
| 189 | let result = remove_alias(&repo, "nobody@example.com"); | ||
| 190 | assert!(result.is_err()); | ||
| 191 | } | ||
| 192 | } | ||
src/issue.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,12 +1,18 @@ | |||
| 1 | use git2::Repository; | 1 | use git2::Repository; |
| 2 | 2 | ||
| 3 | use crate::cli::SortMode; | ||
| 3 | use crate::dag; | 4 | use crate::dag; |
| 4 | use crate::event::{Action, Event}; | 5 | use crate::event::{Action, Event}; |
| 5 | use crate::identity::get_author; | 6 | use crate::identity::get_author; |
| 6 | use crate::signing; | 7 | use crate::signing; |
| 7 | use crate::state::{self, IssueState, IssueStatus}; | 8 | use crate::state::{self, IssueState, IssueStatus}; |
| 8 | 9 | ||
| 9 | pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate::error::Error> { | 10 | pub fn open( |
| 11 | repo: &Repository, | ||
| 12 | title: &str, | ||
| 13 | body: &str, | ||
| 14 | relates_to: Option<&str>, | ||
| 15 | ) -> Result<String, crate::error::Error> { | ||
| 10 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; | 16 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; |
| 11 | let author = get_author(repo)?; | 17 | let author = get_author(repo)?; |
| 12 | let event = Event { | 18 | let event = Event { |
| @@ -15,6 +21,7 @@ pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate: | |||
| 15 | action: Action::IssueOpen { | 21 | action: Action::IssueOpen { |
| 16 | title: title.to_string(), | 22 | title: title.to_string(), |
| 17 | body: body.to_string(), | 23 | body: body.to_string(), |
| 24 | relates_to: relates_to.map(|s| s.to_string()), | ||
| 18 | }, | 25 | }, |
| 19 | clock: 0, | 26 | clock: 0, |
| 20 | }; | 27 | }; |
| @@ -33,17 +40,31 @@ pub struct ListEntry { | |||
| 33 | pub fn list( | 40 | pub fn list( |
| 34 | repo: &Repository, | 41 | repo: &Repository, |
| 35 | show_closed: bool, | 42 | show_closed: bool, |
| 43 | show_archived: bool, | ||
| 36 | limit: Option<usize>, | 44 | limit: Option<usize>, |
| 37 | offset: Option<usize>, | 45 | offset: Option<usize>, |
| 46 | sort: SortMode, | ||
| 38 | ) -> Result<Vec<ListEntry>, crate::error::Error> { | 47 | ) -> Result<Vec<ListEntry>, crate::error::Error> { |
| 39 | let issues = state::list_issues(repo)?; | 48 | let issues = if show_archived { |
| 40 | let entries: Vec<_> = issues | 49 | state::list_issues_with_archived(repo)? |
| 50 | } else { | ||
| 51 | state::list_issues(repo)? | ||
| 52 | }; | ||
| 53 | let mut entries: Vec<_> = issues | ||
| 41 | .into_iter() | 54 | .into_iter() |
| 42 | .filter(|i| show_closed || i.status == IssueStatus::Open) | 55 | .filter(|i| show_closed || i.status == IssueStatus::Open) |
| 43 | .map(|issue| { | 56 | .map(|issue| { |
| 44 | let unread = count_unread(repo, &issue.id); | 57 | let unread = count_unread(repo, &issue.id); |
| 45 | ListEntry { issue, unread } | 58 | ListEntry { issue, unread } |
| 46 | }) | 59 | }) |
| 60 | .collect(); | ||
| 61 | match sort { | ||
| 62 | SortMode::Recent => entries.sort_by(|a, b| b.issue.last_updated.cmp(&a.issue.last_updated)), | ||
| 63 | SortMode::Created => entries.sort_by(|a, b| b.issue.created_at.cmp(&a.issue.created_at)), | ||
| 64 | SortMode::Alpha => entries.sort_by(|a, b| a.issue.title.cmp(&b.issue.title)), | ||
| 65 | } | ||
| 66 | let entries = entries | ||
| 67 | .into_iter() | ||
| 47 | .skip(offset.unwrap_or(0)) | 68 | .skip(offset.unwrap_or(0)) |
| 48 | .take(limit.unwrap_or(usize::MAX)) | 69 | .take(limit.unwrap_or(usize::MAX)) |
| 49 | .collect(); | 70 | .collect(); |
| @@ -53,11 +74,13 @@ pub fn list( | |||
| 53 | pub fn list_to_writer( | 74 | pub fn list_to_writer( |
| 54 | repo: &Repository, | 75 | repo: &Repository, |
| 55 | show_closed: bool, | 76 | show_closed: bool, |
| 77 | show_archived: bool, | ||
| 56 | limit: Option<usize>, | 78 | limit: Option<usize>, |
| 57 | offset: Option<usize>, | 79 | offset: Option<usize>, |
| 80 | sort: SortMode, | ||
| 58 | writer: &mut dyn std::io::Write, | 81 | writer: &mut dyn std::io::Write, |
| 59 | ) -> Result<(), crate::error::Error> { | 82 | ) -> Result<(), crate::error::Error> { |
| 60 | let entries = list(repo, show_closed, limit, offset)?; | 83 | let entries = list(repo, show_closed, show_archived, limit, offset, sort)?; |
| 61 | if entries.is_empty() { | 84 | if entries.is_empty() { |
| 62 | writeln!(writer, "No issues found.").ok(); | 85 | writeln!(writer, "No issues found.").ok(); |
| 63 | return Ok(()); | 86 | return Ok(()); |
| @@ -107,12 +130,21 @@ fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> { | |||
| 107 | Some(revwalk.count()) | 130 | Some(revwalk.count()) |
| 108 | } | 131 | } |
| 109 | 132 | ||
| 110 | pub fn list_json(repo: &Repository, show_closed: bool) -> Result<String, crate::error::Error> { | 133 | pub fn list_json(repo: &Repository, show_closed: bool, show_archived: bool, sort: SortMode) -> Result<String, crate::error::Error> { |
| 111 | let issues = state::list_issues(repo)?; | 134 | let issues = if show_archived { |
| 112 | let filtered: Vec<_> = issues | 135 | state::list_issues_with_archived(repo)? |
| 136 | } else { | ||
| 137 | state::list_issues(repo)? | ||
| 138 | }; | ||
| 139 | let mut filtered: Vec<_> = issues | ||
| 113 | .into_iter() | 140 | .into_iter() |
| 114 | .filter(|i| show_closed || i.status == IssueStatus::Open) | 141 | .filter(|i| show_closed || i.status == IssueStatus::Open) |
| 115 | .collect(); | 142 | .collect(); |
| 143 | match sort { | ||
| 144 | SortMode::Recent => filtered.sort_by(|a, b| b.last_updated.cmp(&a.last_updated)), | ||
| 145 | SortMode::Created => filtered.sort_by(|a, b| b.created_at.cmp(&a.created_at)), | ||
| 146 | SortMode::Alpha => filtered.sort_by(|a, b| a.title.cmp(&b.title)), | ||
| 147 | } | ||
| 116 | Ok(serde_json::to_string_pretty(&filtered)?) | 148 | Ok(serde_json::to_string_pretty(&filtered)?) |
| 117 | } | 149 | } |
| 118 | 150 | ||
| @@ -253,7 +285,7 @@ pub fn close( | |||
| 253 | reason: Option<&str>, | 285 | reason: Option<&str>, |
| 254 | ) -> Result<(), crate::error::Error> { | 286 | ) -> Result<(), crate::error::Error> { |
| 255 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; | 287 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; |
| 256 | let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; | 288 | let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?; |
| 257 | let author = get_author(repo)?; | 289 | let author = get_author(repo)?; |
| 258 | let event = Event { | 290 | let event = Event { |
| 259 | timestamp: chrono::Utc::now().to_rfc3339(), | 291 | timestamp: chrono::Utc::now().to_rfc3339(), |
| @@ -264,9 +296,19 @@ pub fn close( | |||
| 264 | clock: 0, | 296 | clock: 0, |
| 265 | }; | 297 | }; |
| 266 | dag::append_event(repo, &ref_name, &event, &sk)?; | 298 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 299 | // Archive the ref (move to refs/collab/archive/issues/) | ||
| 300 | if ref_name.starts_with("refs/collab/issues/") { | ||
| 301 | state::archive_issue_ref(repo, &id)?; | ||
| 302 | } | ||
| 267 | Ok(()) | 303 | Ok(()) |
| 268 | } | 304 | } |
| 269 | 305 | ||
| 306 | pub fn delete(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> { | ||
| 307 | let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?; | ||
| 308 | repo.find_reference(&ref_name)?.delete()?; | ||
| 309 | Ok(id) | ||
| 310 | } | ||
| 311 | |||
| 270 | pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { | 312 | pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { |
| 271 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; | 313 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; |
| 272 | let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; | 314 | let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; |
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,3 +1,4 @@ | |||
| 1 | pub mod cache; | ||
| 1 | pub mod cli; | 2 | pub mod cli; |
| 2 | pub mod dag; | 3 | pub mod dag; |
| 3 | pub mod editor; | 4 | pub mod editor; |
| @@ -16,7 +17,7 @@ pub mod trust; | |||
| 16 | pub mod tui; | 17 | pub mod tui; |
| 17 | 18 | ||
| 18 | use base64::Engine; | 19 | use base64::Engine; |
| 19 | use cli::{Commands, IssueCmd, KeyCmd, PatchCmd}; | 20 | use cli::{Commands, IdentityCmd, IssueCmd, KeyCmd, PatchCmd}; |
| 20 | use event::ReviewVerdict; | 21 | use event::ReviewVerdict; |
| 21 | use git2::Repository; | 22 | use git2::Repository; |
| 22 | use state::{IssueStatus, PatchStatus}; | 23 | use state::{IssueStatus, PatchStatus}; |
| @@ -25,18 +26,18 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 25 | match cli.command { | 26 | match cli.command { |
| 26 | Commands::Init => sync::init(repo), | 27 | Commands::Init => sync::init(repo), |
| 27 | Commands::Issue(cmd) => match cmd { | 28 | Commands::Issue(cmd) => match cmd { |
| 28 | IssueCmd::Open { title, body } => { | 29 | IssueCmd::Open { title, body, relates_to } => { |
| 29 | let id = issue::open(repo, &title, &body)?; | 30 | let id = issue::open(repo, &title, &body, relates_to.as_deref())?; |
| 30 | println!("Opened issue {:.8}", id); | 31 | println!("Opened issue {:.8}", id); |
| 31 | Ok(()) | 32 | Ok(()) |
| 32 | } | 33 | } |
| 33 | IssueCmd::List { all, limit, offset, json } => { | 34 | IssueCmd::List { all, archived, limit, offset, json, sort } => { |
| 34 | if json { | 35 | if json { |
| 35 | let output = issue::list_json(repo, all)?; | 36 | let output = issue::list_json(repo, all, archived, sort)?; |
| 36 | println!("{}", output); | 37 | println!("{}", output); |
| 37 | return Ok(()); | 38 | return Ok(()); |
| 38 | } | 39 | } |
| 39 | let entries = issue::list(repo, all, limit, offset)?; | 40 | let entries = issue::list(repo, all, archived, limit, offset, sort)?; |
| 40 | if entries.is_empty() { | 41 | if entries.is_empty() { |
| 41 | println!("No issues found."); | 42 | println!("No issues found."); |
| 42 | } else { | 43 | } else { |
| @@ -84,6 +85,9 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 84 | if !i.assignees.is_empty() { | 85 | if !i.assignees.is_empty() { |
| 85 | println!("Assignees: {}", i.assignees.join(", ")); | 86 | println!("Assignees: {}", i.assignees.join(", ")); |
| 86 | } | 87 | } |
| 88 | if let Some(ref relates_to) = i.relates_to { | ||
| 89 | println!("Relates-to: {:.8}", relates_to); | ||
| 90 | } | ||
| 87 | if let Some(ref reason) = i.close_reason { | 91 | if let Some(ref reason) = i.close_reason { |
| 88 | println!("Closed: {}", reason); | 92 | println!("Closed: {}", reason); |
| 89 | } | 93 | } |
| @@ -133,9 +137,9 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 133 | println!("Issue closed."); | 137 | println!("Issue closed."); |
| 134 | Ok(()) | 138 | Ok(()) |
| 135 | } | 139 | } |
| 136 | IssueCmd::Reopen { id } => { | 140 | IssueCmd::Delete { id } => { |
| 137 | issue::reopen(repo, &id)?; | 141 | let full_id = issue::delete(repo, &id)?; |
| 138 | println!("Issue reopened."); | 142 | println!("Deleted issue {:.8}", full_id); |
| 139 | Ok(()) | 143 | Ok(()) |
| 140 | } | 144 | } |
| 141 | }, | 145 | }, |
| @@ -181,13 +185,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 181 | println!("Created patch {:.8}", id); | 185 | println!("Created patch {:.8}", id); |
| 182 | Ok(()) | 186 | Ok(()) |
| 183 | } | 187 | } |
| 184 | PatchCmd::List { all, limit, offset, json } => { | 188 | PatchCmd::List { all, archived, limit, offset, json, sort } => { |
| 185 | if json { | 189 | if json { |
| 186 | let output = patch::list_json(repo, all)?; | 190 | let output = patch::list_json(repo, all, archived, sort)?; |
| 187 | println!("{}", output); | 191 | println!("{}", output); |
| 188 | return Ok(()); | 192 | return Ok(()); |
| 189 | } | 193 | } |
| 190 | let patches = patch::list(repo, all, limit, offset)?; | 194 | let patches = patch::list(repo, all, archived, limit, offset, sort)?; |
| 191 | if patches.is_empty() { | 195 | if patches.is_empty() { |
| 192 | println!("No patches found."); | 196 | println!("No patches found."); |
| 193 | } else { | 197 | } else { |
| @@ -315,6 +319,11 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 315 | println!("Patch closed."); | 319 | println!("Patch closed."); |
| 316 | Ok(()) | 320 | Ok(()) |
| 317 | } | 321 | } |
| 322 | PatchCmd::Delete { id } => { | ||
| 323 | let full_id = patch::delete(repo, &id)?; | ||
| 324 | println!("Deleted patch {:.8}", full_id); | ||
| 325 | Ok(()) | ||
| 326 | } | ||
| 318 | }, | 327 | }, |
| 319 | Commands::Status => { | 328 | Commands::Status => { |
| 320 | let project_status = status::compute(repo)?; | 329 | let project_status = status::compute(repo)?; |
| @@ -341,11 +350,48 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 341 | println!("Public key: {}", pubkey_b64); | 350 | println!("Public key: {}", pubkey_b64); |
| 342 | Ok(()) | 351 | Ok(()) |
| 343 | } | 352 | } |
| 353 | Commands::Whoami => { | ||
| 354 | let info = identity::whoami(repo)?; | ||
| 355 | println!("{}", info); | ||
| 356 | Ok(()) | ||
| 357 | } | ||
| 358 | Commands::Identity(cmd) => match cmd { | ||
| 359 | IdentityCmd::Alias { email } => { | ||
| 360 | let added = identity::add_alias(repo, &email)?; | ||
| 361 | if added { | ||
| 362 | println!("Alias '{}' added.", email); | ||
| 363 | } else { | ||
| 364 | println!("Alias '{}' already exists.", email); | ||
| 365 | } | ||
| 366 | Ok(()) | ||
| 367 | } | ||
| 368 | IdentityCmd::Unalias { email } => { | ||
| 369 | identity::remove_alias(repo, &email)?; | ||
| 370 | println!("Alias '{}' removed.", email); | ||
| 371 | Ok(()) | ||
| 372 | } | ||
| 373 | IdentityCmd::List => { | ||
| 374 | let author = identity::get_author(repo)?; | ||
| 375 | println!("Name: {}", author.name); | ||
| 376 | println!("Email: {} (primary)", author.email); | ||
| 377 | let aliases = identity::load_aliases(repo)?; | ||
| 378 | if aliases.is_empty() { | ||
| 379 | println!("Aliases: (none)"); | ||
| 380 | } else { | ||
| 381 | println!("Aliases:"); | ||
| 382 | for alias in &aliases { | ||
| 383 | println!(" {}", alias); | ||
| 384 | } | ||
| 385 | } | ||
| 386 | Ok(()) | ||
| 387 | } | ||
| 388 | }, | ||
| 344 | Commands::Key(cmd) => match cmd { | 389 | Commands::Key(cmd) => match cmd { |
| 345 | KeyCmd::Add { | 390 | KeyCmd::Add { |
| 346 | pubkey, | 391 | pubkey, |
| 347 | self_key, | 392 | self_key, |
| 348 | label, | 393 | label, |
| 394 | global, | ||
| 349 | } => { | 395 | } => { |
| 350 | if self_key && pubkey.is_some() { | 396 | if self_key && pubkey.is_some() { |
| 351 | return Err(error::Error::Cmd( | 397 | return Err(error::Error::Cmd( |
| @@ -367,32 +413,52 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 367 | } | 413 | } |
| 368 | }; | 414 | }; |
| 369 | trust::validate_pubkey(&key)?; | 415 | trust::validate_pubkey(&key)?; |
| 370 | let added = trust::save_trusted_key(repo, &key, label.as_deref())?; | 416 | let added = if global { |
| 417 | trust::save_trusted_key_global(&key, label.as_deref())? | ||
| 418 | } else { | ||
| 419 | trust::save_trusted_key(repo, &key, label.as_deref())? | ||
| 420 | }; | ||
| 371 | if added { | 421 | if added { |
| 372 | let label_display = label | 422 | let label_display = label |
| 373 | .as_ref() | 423 | .as_ref() |
| 374 | .map(|l| format!(" ({})", l)) | 424 | .map(|l| format!(" ({})", l)) |
| 375 | .unwrap_or_default(); | 425 | .unwrap_or_default(); |
| 376 | println!("Trusted key added: {}{}", key, label_display); | 426 | let scope = if global { " (global)" } else { "" }; |
| 427 | println!("Trusted key added{}: {}{}", scope, key, label_display); | ||
| 377 | } else { | 428 | } else { |
| 378 | println!("Key {} is already trusted.", key); | 429 | println!("Key {} is already trusted.", key); |
| 379 | } | 430 | } |
| 380 | Ok(()) | 431 | Ok(()) |
| 381 | } | 432 | } |
| 382 | KeyCmd::List => { | 433 | KeyCmd::List { global } => { |
| 383 | let policy = trust::load_trust_policy(repo)?; | 434 | if global { |
| 384 | match policy { | 435 | let path = trust::global_trusted_keys_path()?; |
| 385 | trust::TrustPolicy::Unconfigured => { | 436 | let keys = trust::parse_trusted_keys_file(&path)?; |
| 386 | println!("No trusted keys configured."); | 437 | if keys.is_empty() { |
| 438 | println!("No global trusted keys configured."); | ||
| 439 | } else { | ||
| 440 | for k in &keys { | ||
| 441 | match &k.label { | ||
| 442 | Some(l) => println!("{} {}", k.pubkey, l), | ||
| 443 | None => println!("{}", k.pubkey), | ||
| 444 | } | ||
| 445 | } | ||
| 387 | } | 446 | } |
| 388 | trust::TrustPolicy::Configured(keys) => { | 447 | } else { |
| 389 | if keys.is_empty() { | 448 | let policy = trust::load_trust_policy(repo)?; |
| 449 | match policy { | ||
| 450 | trust::TrustPolicy::Unconfigured => { | ||
| 390 | println!("No trusted keys configured."); | 451 | println!("No trusted keys configured."); |
| 391 | } else { | 452 | } |
| 392 | for k in &keys { | 453 | trust::TrustPolicy::Configured(keys) => { |
| 393 | match &k.label { | 454 | if keys.is_empty() { |
| 394 | Some(l) => println!("{} {}", k.pubkey, l), | 455 | println!("No trusted keys configured."); |
| 395 | None => println!("{}", k.pubkey), | 456 | } else { |
| 457 | for k in &keys { | ||
| 458 | match &k.label { | ||
| 459 | Some(l) => println!("{} {}", k.pubkey, l), | ||
| 460 | None => println!("{}", k.pubkey), | ||
| 461 | } | ||
| 396 | } | 462 | } |
| 397 | } | 463 | } |
| 398 | } | 464 | } |
| @@ -400,14 +466,19 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 400 | } | 466 | } |
| 401 | Ok(()) | 467 | Ok(()) |
| 402 | } | 468 | } |
| 403 | KeyCmd::Remove { pubkey } => { | 469 | KeyCmd::Remove { pubkey, global } => { |
| 404 | let removed = trust::remove_trusted_key(repo, &pubkey)?; | 470 | let removed = if global { |
| 471 | trust::remove_trusted_key_global(&pubkey)? | ||
| 472 | } else { | ||
| 473 | trust::remove_trusted_key(repo, &pubkey)? | ||
| 474 | }; | ||
| 405 | let label_display = removed | 475 | let label_display = removed |
| 406 | .label | 476 | .label |
| 407 | .as_ref() | 477 | .as_ref() |
| 408 | .map(|l| format!(" ({})", l)) | 478 | .map(|l| format!(" ({})", l)) |
| 409 | .unwrap_or_default(); | 479 | .unwrap_or_default(); |
| 410 | println!("Removed trusted key: {}{}", removed.pubkey, label_display); | 480 | let scope = if global { " (global)" } else { "" }; |
| 481 | println!("Removed trusted key{}: {}{}", scope, removed.pubkey, label_display); | ||
| 411 | Ok(()) | 482 | Ok(()) |
| 412 | } | 483 | } |
| 413 | }, | 484 | }, |
src/patch.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,6 @@ | |||
| 1 | use git2::{DiffFormat, Repository}; | 1 | use git2::{DiffFormat, Repository}; |
| 2 | 2 | ||
| 3 | use crate::cli::SortMode; | ||
| 3 | use crate::dag; | 4 | use crate::dag; |
| 4 | use crate::error::Error; | 5 | use crate::error::Error; |
| 5 | use crate::event::{Action, Event, ReviewVerdict}; | 6 | use crate::event::{Action, Event, ReviewVerdict}; |
| @@ -62,13 +63,27 @@ pub fn create( | |||
| 62 | pub fn list( | 63 | pub fn list( |
| 63 | repo: &Repository, | 64 | repo: &Repository, |
| 64 | show_closed: bool, | 65 | show_closed: bool, |
| 66 | show_archived: bool, | ||
| 65 | limit: Option<usize>, | 67 | limit: Option<usize>, |
| 66 | offset: Option<usize>, | 68 | offset: Option<usize>, |
| 69 | sort: SortMode, | ||
| 67 | ) -> Result<Vec<PatchState>, crate::error::Error> { | 70 | ) -> Result<Vec<PatchState>, crate::error::Error> { |
| 68 | let patches = state::list_patches(repo)?; | 71 | let patches = if show_archived { |
| 69 | let filtered = patches | 72 | state::list_patches_with_archived(repo)? |
| 73 | } else { | ||
| 74 | state::list_patches(repo)? | ||
| 75 | }; | ||
| 76 | let mut filtered: Vec<_> = patches | ||
| 70 | .into_iter() | 77 | .into_iter() |
| 71 | .filter(|p| show_closed || p.status == PatchStatus::Open) | 78 | .filter(|p| show_closed || p.status == PatchStatus::Open) |
| 79 | .collect(); | ||
| 80 | match sort { | ||
| 81 | SortMode::Recent => filtered.sort_by(|a, b| b.last_updated.cmp(&a.last_updated)), | ||
| 82 | SortMode::Created => filtered.sort_by(|a, b| b.created_at.cmp(&a.created_at)), | ||
| 83 | SortMode::Alpha => filtered.sort_by(|a, b| a.title.cmp(&b.title)), | ||
| 84 | } | ||
| 85 | let filtered = filtered | ||
| 86 | .into_iter() | ||
| 72 | .skip(offset.unwrap_or(0)) | 87 | .skip(offset.unwrap_or(0)) |
| 73 | .take(limit.unwrap_or(usize::MAX)) | 88 | .take(limit.unwrap_or(usize::MAX)) |
| 74 | .collect(); | 89 | .collect(); |
| @@ -78,11 +93,13 @@ pub fn list( | |||
| 78 | pub fn list_to_writer( | 93 | pub fn list_to_writer( |
| 79 | repo: &Repository, | 94 | repo: &Repository, |
| 80 | show_closed: bool, | 95 | show_closed: bool, |
| 96 | show_archived: bool, | ||
| 81 | limit: Option<usize>, | 97 | limit: Option<usize>, |
| 82 | offset: Option<usize>, | 98 | offset: Option<usize>, |
| 99 | sort: SortMode, | ||
| 83 | writer: &mut dyn std::io::Write, | 100 | writer: &mut dyn std::io::Write, |
| 84 | ) -> Result<(), crate::error::Error> { | 101 | ) -> Result<(), crate::error::Error> { |
| 85 | let patches = list(repo, show_closed, limit, offset)?; | 102 | let patches = list(repo, show_closed, show_archived, limit, offset, sort)?; |
| 86 | if patches.is_empty() { | 103 | if patches.is_empty() { |
| 87 | writeln!(writer, "No patches found.").ok(); | 104 | writeln!(writer, "No patches found.").ok(); |
| 88 | return Ok(()); | 105 | return Ok(()); |
| @@ -103,12 +120,21 @@ pub fn list_to_writer( | |||
| 103 | Ok(()) | 120 | Ok(()) |
| 104 | } | 121 | } |
| 105 | 122 | ||
| 106 | pub fn list_json(repo: &Repository, show_closed: bool) -> Result<String, crate::error::Error> { | 123 | pub fn list_json(repo: &Repository, show_closed: bool, show_archived: bool, sort: SortMode) -> Result<String, crate::error::Error> { |
| 107 | let patches = state::list_patches(repo)?; | 124 | let patches = if show_archived { |
| 108 | let filtered: Vec<_> = patches | 125 | state::list_patches_with_archived(repo)? |
| 126 | } else { | ||
| 127 | state::list_patches(repo)? | ||
| 128 | }; | ||
| 129 | let mut filtered: Vec<_> = patches | ||
| 109 | .into_iter() | 130 | .into_iter() |
| 110 | .filter(|p| show_closed || p.status == PatchStatus::Open) | 131 | .filter(|p| show_closed || p.status == PatchStatus::Open) |
| 111 | .collect(); | 132 | .collect(); |
| 133 | match sort { | ||
| 134 | SortMode::Recent => filtered.sort_by(|a, b| b.last_updated.cmp(&a.last_updated)), | ||
| 135 | SortMode::Created => filtered.sort_by(|a, b| b.created_at.cmp(&a.created_at)), | ||
| 136 | SortMode::Alpha => filtered.sort_by(|a, b| a.title.cmp(&b.title)), | ||
| 137 | } | ||
| 112 | Ok(serde_json::to_string_pretty(&filtered)?) | 138 | Ok(serde_json::to_string_pretty(&filtered)?) |
| 113 | } | 139 | } |
| 114 | 140 | ||
| @@ -265,9 +291,14 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::er | |||
| 265 | }; | 291 | }; |
| 266 | dag::append_event(repo, &ref_name, &event, &sk)?; | 292 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 267 | 293 | ||
| 294 | // Archive the patch ref | ||
| 295 | if ref_name.starts_with("refs/collab/patches/") { | ||
| 296 | state::archive_patch_ref(repo, &id)?; | ||
| 297 | } | ||
| 298 | |||
| 268 | // Auto-close linked issue if present | 299 | // Auto-close linked issue if present |
| 269 | if let Some(ref fixes_id) = p.fixes { | 300 | if let Some(ref fixes_id) = p.fixes { |
| 270 | if let Ok((issue_ref, _)) = state::resolve_issue_ref(repo, fixes_id) { | 301 | if let Ok((issue_ref, issue_id)) = state::resolve_issue_ref(repo, fixes_id) { |
| 271 | let close_event = Event { | 302 | let close_event = Event { |
| 272 | timestamp: chrono::Utc::now().to_rfc3339(), | 303 | timestamp: chrono::Utc::now().to_rfc3339(), |
| 273 | author, | 304 | author, |
| @@ -277,6 +308,10 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::er | |||
| 277 | clock: 0, | 308 | clock: 0, |
| 278 | }; | 309 | }; |
| 279 | dag::append_event(repo, &issue_ref, &close_event, &sk)?; | 310 | dag::append_event(repo, &issue_ref, &close_event, &sk)?; |
| 311 | // Archive the issue ref | ||
| 312 | if issue_ref.starts_with("refs/collab/issues/") { | ||
| 313 | state::archive_issue_ref(repo, &issue_id)?; | ||
| 314 | } | ||
| 280 | } | 315 | } |
| 281 | } | 316 | } |
| 282 | 317 | ||
| @@ -339,13 +374,19 @@ pub fn generate_diff(repo: &Repository, patch: &state::PatchState) -> Result<Str | |||
| 339 | Ok(output) | 374 | Ok(output) |
| 340 | } | 375 | } |
| 341 | 376 | ||
| 377 | pub fn delete(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> { | ||
| 378 | let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; | ||
| 379 | repo.find_reference(&ref_name)?.delete()?; | ||
| 380 | Ok(id) | ||
| 381 | } | ||
| 382 | |||
| 342 | pub fn close( | 383 | pub fn close( |
| 343 | repo: &Repository, | 384 | repo: &Repository, |
| 344 | id_prefix: &str, | 385 | id_prefix: &str, |
| 345 | reason: Option<&str>, | 386 | reason: Option<&str>, |
| 346 | ) -> Result<(), crate::error::Error> { | 387 | ) -> Result<(), crate::error::Error> { |
| 347 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; | 388 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; |
| 348 | let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; | 389 | let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; |
| 349 | let author = get_author(repo)?; | 390 | let author = get_author(repo)?; |
| 350 | let event = Event { | 391 | let event = Event { |
| 351 | timestamp: chrono::Utc::now().to_rfc3339(), | 392 | timestamp: chrono::Utc::now().to_rfc3339(), |
| @@ -356,6 +397,10 @@ pub fn close( | |||
| 356 | clock: 0, | 397 | clock: 0, |
| 357 | }; | 398 | }; |
| 358 | dag::append_event(repo, &ref_name, &event, &sk)?; | 399 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 400 | // Archive the ref (move to refs/collab/archive/patches/) | ||
| 401 | if ref_name.starts_with("refs/collab/patches/") { | ||
| 402 | state::archive_patch_ref(repo, &id)?; | ||
| 403 | } | ||
| 359 | Ok(()) | 404 | Ok(()) |
| 360 | } | 405 | } |
| 361 | 406 | ||
src/signing.rs
| Old | New | ||
|---|---|---|---|
| @@ -321,6 +321,7 @@ mod tests { | |||
| 321 | action: Action::IssueOpen { | 321 | action: Action::IssueOpen { |
| 322 | title: "Test".to_string(), | 322 | title: "Test".to_string(), |
| 323 | body: "Body".to_string(), | 323 | body: "Body".to_string(), |
| 324 | relates_to: None, | ||
| 324 | }, | 325 | }, |
| 325 | clock: 0, | 326 | clock: 0, |
| 326 | }; | 327 | }; |
src/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,7 @@ | |||
| 1 | use git2::{Oid, Repository}; | 1 | use git2::{Oid, Repository}; |
| 2 | use serde::Serialize; | 2 | use serde::{Deserialize, Serialize}; |
| 3 | 3 | ||
| 4 | use crate::cache; | ||
| 4 | use crate::dag; | 5 | use crate::dag; |
| 5 | use crate::event::{Action, Author, ReviewVerdict}; | 6 | use crate::event::{Action, Author, ReviewVerdict}; |
| 6 | 7 | ||
| @@ -15,6 +16,19 @@ fn serialize_oid_option<S: serde::Serializer>(oid: &Option<Oid>, s: S) -> Result | |||
| 15 | } | 16 | } |
| 16 | } | 17 | } |
| 17 | 18 | ||
| 19 | fn deserialize_oid<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Oid, D::Error> { | ||
| 20 | let s = String::deserialize(d)?; | ||
| 21 | Oid::from_str(&s).map_err(serde::de::Error::custom) | ||
| 22 | } | ||
| 23 | |||
| 24 | fn deserialize_oid_option<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Oid>, D::Error> { | ||
| 25 | let opt: Option<String> = Option::deserialize(d)?; | ||
| 26 | match opt { | ||
| 27 | Some(s) => Oid::from_str(&s).map(Some).map_err(serde::de::Error::custom), | ||
| 28 | None => Ok(None), | ||
| 29 | } | ||
| 30 | } | ||
| 31 | |||
| 18 | fn serialize_verdict<S: serde::Serializer>(v: &ReviewVerdict, s: S) -> Result<S::Ok, S::Error> { | 32 | fn serialize_verdict<S: serde::Serializer>(v: &ReviewVerdict, s: S) -> Result<S::Ok, S::Error> { |
| 19 | let str_val = match v { | 33 | let str_val = match v { |
| 20 | ReviewVerdict::Approve => "approve", | 34 | ReviewVerdict::Approve => "approve", |
| @@ -24,49 +38,69 @@ fn serialize_verdict<S: serde::Serializer>(v: &ReviewVerdict, s: S) -> Result<S: | |||
| 24 | s.serialize_str(str_val) | 38 | s.serialize_str(str_val) |
| 25 | } | 39 | } |
| 26 | 40 | ||
| 27 | #[derive(Debug, Clone, PartialEq, Serialize)] | 41 | fn deserialize_verdict<'de, D: serde::Deserializer<'de>>(d: D) -> Result<ReviewVerdict, D::Error> { |
| 42 | let s = String::deserialize(d)?; | ||
| 43 | match s.as_str() { | ||
| 44 | "approve" => Ok(ReviewVerdict::Approve), | ||
| 45 | "request-changes" => Ok(ReviewVerdict::RequestChanges), | ||
| 46 | "comment" => Ok(ReviewVerdict::Comment), | ||
| 47 | other => Err(serde::de::Error::custom(format!( | ||
| 48 | "unknown verdict: {}", | ||
| 49 | other | ||
| 50 | ))), | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 28 | #[serde(rename_all = "lowercase")] | 55 | #[serde(rename_all = "lowercase")] |
| 29 | pub enum IssueStatus { | 56 | pub enum IssueStatus { |
| 30 | Open, | 57 | Open, |
| 31 | Closed, | 58 | Closed, |
| 32 | } | 59 | } |
| 33 | 60 | ||
| 34 | #[derive(Debug, Clone, Serialize)] | 61 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 35 | #[allow(dead_code)] | 62 | #[allow(dead_code)] |
| 36 | pub struct Comment { | 63 | pub struct Comment { |
| 37 | pub author: Author, | 64 | pub author: Author, |
| 38 | pub body: String, | 65 | pub body: String, |
| 39 | pub timestamp: String, | 66 | pub timestamp: String, |
| 40 | #[serde(serialize_with = "serialize_oid")] | 67 | #[serde(serialize_with = "serialize_oid", deserialize_with = "deserialize_oid")] |
| 41 | pub commit_id: Oid, | 68 | pub commit_id: Oid, |
| 42 | } | 69 | } |
| 43 | 70 | ||
| 44 | #[derive(Debug, Clone, Serialize)] | 71 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 45 | pub struct IssueState { | 72 | pub struct IssueState { |
| 46 | pub id: String, | 73 | pub id: String, |
| 47 | pub title: String, | 74 | pub title: String, |
| 48 | pub body: String, | 75 | pub body: String, |
| 49 | pub status: IssueStatus, | 76 | pub status: IssueStatus, |
| 50 | pub close_reason: Option<String>, | 77 | pub close_reason: Option<String>, |
| 51 | #[serde(serialize_with = "serialize_oid_option")] | 78 | #[serde( |
| 79 | serialize_with = "serialize_oid_option", | ||
| 80 | deserialize_with = "deserialize_oid_option" | ||
| 81 | )] | ||
| 52 | pub closed_by: Option<Oid>, | 82 | pub closed_by: Option<Oid>, |
| 53 | pub labels: Vec<String>, | 83 | pub labels: Vec<String>, |
| 54 | pub assignees: Vec<String>, | 84 | pub assignees: Vec<String>, |
| 55 | pub comments: Vec<Comment>, | 85 | pub comments: Vec<Comment>, |
| 56 | pub created_at: String, | 86 | pub created_at: String, |
| 87 | #[serde(default)] | ||
| 88 | pub last_updated: String, | ||
| 57 | pub author: Author, | 89 | pub author: Author, |
| 90 | #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| 91 | pub relates_to: Option<String>, | ||
| 58 | } | 92 | } |
| 59 | 93 | ||
| 60 | #[derive(Debug, Clone, Serialize)] | 94 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 61 | pub struct Review { | 95 | pub struct Review { |
| 62 | pub author: Author, | 96 | pub author: Author, |
| 63 | #[serde(serialize_with = "serialize_verdict")] | 97 | #[serde(serialize_with = "serialize_verdict", deserialize_with = "deserialize_verdict")] |
| 64 | pub verdict: ReviewVerdict, | 98 | pub verdict: ReviewVerdict, |
| 65 | pub body: String, | 99 | pub body: String, |
| 66 | pub timestamp: String, | 100 | pub timestamp: String, |
| 67 | } | 101 | } |
| 68 | 102 | ||
| 69 | #[derive(Debug, Clone, PartialEq, Serialize)] | 103 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 70 | #[serde(rename_all = "lowercase")] | 104 | #[serde(rename_all = "lowercase")] |
| 71 | #[allow(dead_code)] | 105 | #[allow(dead_code)] |
| 72 | pub enum PatchStatus { | 106 | pub enum PatchStatus { |
| @@ -75,7 +109,7 @@ pub enum PatchStatus { | |||
| 75 | Merged, | 109 | Merged, |
| 76 | } | 110 | } |
| 77 | 111 | ||
| 78 | #[derive(Debug, Clone, Serialize)] | 112 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 79 | pub struct InlineComment { | 113 | pub struct InlineComment { |
| 80 | pub author: Author, | 114 | pub author: Author, |
| 81 | pub file: String, | 115 | pub file: String, |
| @@ -84,7 +118,7 @@ pub struct InlineComment { | |||
| 84 | pub timestamp: String, | 118 | pub timestamp: String, |
| 85 | } | 119 | } |
| 86 | 120 | ||
| 87 | #[derive(Debug, Clone, Serialize)] | 121 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 88 | pub struct PatchState { | 122 | pub struct PatchState { |
| 89 | pub id: String, | 123 | pub id: String, |
| 90 | pub title: String, | 124 | pub title: String, |
| @@ -97,6 +131,8 @@ pub struct PatchState { | |||
| 97 | pub inline_comments: Vec<InlineComment>, | 131 | pub inline_comments: Vec<InlineComment>, |
| 98 | pub reviews: Vec<Review>, | 132 | pub reviews: Vec<Review>, |
| 99 | pub created_at: String, | 133 | pub created_at: String, |
| 134 | #[serde(default)] | ||
| 135 | pub last_updated: String, | ||
| 100 | pub author: Author, | 136 | pub author: Author, |
| 101 | } | 137 | } |
| 102 | 138 | ||
| @@ -106,16 +142,41 @@ impl IssueState { | |||
| 106 | ref_name: &str, | 142 | ref_name: &str, |
| 107 | id: &str, | 143 | id: &str, |
| 108 | ) -> Result<Self, crate::error::Error> { | 144 | ) -> Result<Self, crate::error::Error> { |
| 145 | // Check cache first | ||
| 146 | if let Some(cached) = cache::get_cached_state::<IssueState>(repo, ref_name) { | ||
| 147 | return Ok(cached); | ||
| 148 | } | ||
| 149 | |||
| 150 | let state = Self::from_ref_uncached(repo, ref_name, id)?; | ||
| 151 | |||
| 152 | // Cache the result | ||
| 153 | if let Ok(tip) = repo.refname_to_id(ref_name) { | ||
| 154 | cache::set_cached_state(repo, ref_name, tip, &state); | ||
| 155 | } | ||
| 156 | |||
| 157 | Ok(state) | ||
| 158 | } | ||
| 159 | |||
| 160 | /// Walk the DAG and materialize state without consulting the cache. | ||
| 161 | pub fn from_ref_uncached( | ||
| 162 | repo: &Repository, | ||
| 163 | ref_name: &str, | ||
| 164 | id: &str, | ||
| 165 | ) -> Result<Self, crate::error::Error> { | ||
| 109 | let events = dag::walk_events(repo, ref_name)?; | 166 | let events = dag::walk_events(repo, ref_name)?; |
| 110 | let mut state: Option<IssueState> = None; | 167 | let mut state: Option<IssueState> = None; |
| 168 | let mut max_timestamp = String::new(); | ||
| 111 | 169 | ||
| 112 | // Track the (clock, commit_oid_hex) of the latest status-changing event. | 170 | // Track the (clock, commit_oid_hex) of the latest status-changing event. |
| 113 | // Higher clock wins; on tie, lexicographically higher OID wins. | 171 | // Higher clock wins; on tie, lexicographically higher OID wins. |
| 114 | let mut status_key: Option<(u64, String)> = None; | 172 | let mut status_key: Option<(u64, String)> = None; |
| 115 | 173 | ||
| 116 | for (oid, event) in events { | 174 | for (oid, event) in events { |
| 175 | if event.timestamp > max_timestamp { | ||
| 176 | max_timestamp = event.timestamp.clone(); | ||
| 177 | } | ||
| 117 | match event.action { | 178 | match event.action { |
| 118 | Action::IssueOpen { title, body } => { | 179 | Action::IssueOpen { title, body, relates_to } => { |
| 119 | state = Some(IssueState { | 180 | state = Some(IssueState { |
| 120 | id: id.to_string(), | 181 | id: id.to_string(), |
| 121 | title, | 182 | title, |
| @@ -127,7 +188,9 @@ impl IssueState { | |||
| 127 | assignees: Vec::new(), | 188 | assignees: Vec::new(), |
| 128 | comments: Vec::new(), | 189 | comments: Vec::new(), |
| 129 | created_at: event.timestamp.clone(), | 190 | created_at: event.timestamp.clone(), |
| 191 | last_updated: String::new(), | ||
| 130 | author: event.author.clone(), | 192 | author: event.author.clone(), |
| 193 | relates_to, | ||
| 131 | }); | 194 | }); |
| 132 | } | 195 | } |
| 133 | Action::IssueComment { body } => { | 196 | Action::IssueComment { body } => { |
| @@ -201,6 +264,9 @@ impl IssueState { | |||
| 201 | } | 264 | } |
| 202 | } | 265 | } |
| 203 | 266 | ||
| 267 | if let Some(ref mut s) = state { | ||
| 268 | s.last_updated = max_timestamp; | ||
| 269 | } | ||
| 204 | state.ok_or_else(|| git2::Error::from_str("no IssueOpen event found in DAG").into()) | 270 | state.ok_or_else(|| git2::Error::from_str("no IssueOpen event found in DAG").into()) |
| 205 | } | 271 | } |
| 206 | } | 272 | } |
| @@ -240,12 +306,37 @@ impl PatchState { | |||
| 240 | ref_name: &str, | 306 | ref_name: &str, |
| 241 | id: &str, | 307 | id: &str, |
| 242 | ) -> Result<Self, crate::error::Error> { | 308 | ) -> Result<Self, crate::error::Error> { |
| 309 | // Check cache first | ||
| 310 | if let Some(cached) = cache::get_cached_state::<PatchState>(repo, ref_name) { | ||
| 311 | return Ok(cached); | ||
| 312 | } | ||
| 313 | |||
| 314 | let state = Self::from_ref_uncached(repo, ref_name, id)?; | ||
| 315 | |||
| 316 | // Cache the result | ||
| 317 | if let Ok(tip) = repo.refname_to_id(ref_name) { | ||
| 318 | cache::set_cached_state(repo, ref_name, tip, &state); | ||
| 319 | } | ||
| 320 | |||
| 321 | Ok(state) | ||
| 322 | } | ||
| 323 | |||
| 324 | /// Walk the DAG and materialize state without consulting the cache. | ||
| 325 | pub fn from_ref_uncached( | ||
| 326 | repo: &Repository, | ||
| 327 | ref_name: &str, | ||
| 328 | id: &str, | ||
| 329 | ) -> Result<Self, crate::error::Error> { | ||
| 243 | let events = dag::walk_events(repo, ref_name)?; | 330 | let events = dag::walk_events(repo, ref_name)?; |
| 244 | let mut state: Option<PatchState> = None; | 331 | let mut state: Option<PatchState> = None; |
| 332 | let mut max_timestamp = String::new(); | ||
| 245 | 333 | ||
| 246 | let mut status_key: Option<(u64, String)> = None; | 334 | let mut status_key: Option<(u64, String)> = None; |
| 247 | 335 | ||
| 248 | for (oid, event) in events { | 336 | for (oid, event) in events { |
| 337 | if event.timestamp > max_timestamp { | ||
| 338 | max_timestamp = event.timestamp.clone(); | ||
| 339 | } | ||
| 249 | match event.action { | 340 | match event.action { |
| 250 | Action::PatchCreate { | 341 | Action::PatchCreate { |
| 251 | title, | 342 | title, |
| @@ -266,6 +357,7 @@ impl PatchState { | |||
| 266 | inline_comments: Vec::new(), | 357 | inline_comments: Vec::new(), |
| 267 | reviews: Vec::new(), | 358 | reviews: Vec::new(), |
| 268 | created_at: event.timestamp.clone(), | 359 | created_at: event.timestamp.clone(), |
| 360 | last_updated: String::new(), | ||
| 269 | author: event.author.clone(), | 361 | author: event.author.clone(), |
| 270 | }); | 362 | }); |
| 271 | } | 363 | } |
| @@ -330,6 +422,9 @@ impl PatchState { | |||
| 330 | } | 422 | } |
| 331 | } | 423 | } |
| 332 | 424 | ||
| 425 | if let Some(ref mut s) = state { | ||
| 426 | s.last_updated = max_timestamp; | ||
| 427 | } | ||
| 333 | state.ok_or_else(|| git2::Error::from_str("no PatchCreate event found in DAG").into()) | 428 | state.ok_or_else(|| git2::Error::from_str("no PatchCreate event found in DAG").into()) |
| 334 | } | 429 | } |
| 335 | } | 430 | } |
| @@ -355,17 +450,46 @@ fn collab_refs( | |||
| 355 | Ok(result) | 450 | Ok(result) |
| 356 | } | 451 | } |
| 357 | 452 | ||
| 358 | /// Resolve a short ID prefix to a full ref. Returns (ref_name, id). | 453 | /// Enumerate all collab refs of a given kind under the archive namespace. |
| 454 | fn collab_archive_refs( | ||
| 455 | repo: &Repository, | ||
| 456 | kind: &str, | ||
| 457 | ) -> Result<Vec<(String, String)>, crate::error::Error> { | ||
| 458 | let prefix = format!("refs/collab/archive/{}/", kind); | ||
| 459 | let glob = format!("{}*", prefix); | ||
| 460 | let refs = repo.references_glob(&glob)?; | ||
| 461 | let mut result = Vec::new(); | ||
| 462 | for r in refs { | ||
| 463 | let r = r?; | ||
| 464 | let ref_name = r.name().unwrap_or_default().to_string(); | ||
| 465 | let id = ref_name | ||
| 466 | .strip_prefix(&prefix) | ||
| 467 | .unwrap_or_default() | ||
| 468 | .to_string(); | ||
| 469 | result.push((ref_name, id)); | ||
| 470 | } | ||
| 471 | Ok(result) | ||
| 472 | } | ||
| 473 | |||
| 474 | /// Resolve a short ID prefix to a full ref. Searches both active and archive namespaces. | ||
| 475 | /// Returns (ref_name, id). | ||
| 359 | fn resolve_ref( | 476 | fn resolve_ref( |
| 360 | repo: &Repository, | 477 | repo: &Repository, |
| 361 | kind: &str, | 478 | kind: &str, |
| 362 | singular: &str, | 479 | singular: &str, |
| 363 | prefix: &str, | 480 | prefix: &str, |
| 364 | ) -> Result<(String, String), crate::error::Error> { | 481 | ) -> Result<(String, String), crate::error::Error> { |
| 365 | let matches: Vec<_> = collab_refs(repo, kind)? | 482 | let mut matches: Vec<_> = collab_refs(repo, kind)? |
| 483 | .into_iter() | ||
| 484 | .filter(|(_, id)| id.starts_with(prefix)) | ||
| 485 | .collect(); | ||
| 486 | // Also search archive namespace | ||
| 487 | let archive_matches: Vec<_> = collab_archive_refs(repo, kind)? | ||
| 366 | .into_iter() | 488 | .into_iter() |
| 367 | .filter(|(_, id)| id.starts_with(prefix)) | 489 | .filter(|(_, id)| id.starts_with(prefix)) |
| 368 | .collect(); | 490 | .collect(); |
| 491 | matches.extend(archive_matches); | ||
| 492 | |||
| 369 | match matches.len() { | 493 | match matches.len() { |
| 370 | 0 => Err( | 494 | 0 => Err( |
| 371 | git2::Error::from_str(&format!("no {} found matching '{}'", singular, prefix)).into(), | 495 | git2::Error::from_str(&format!("no {} found matching '{}'", singular, prefix)).into(), |
| @@ -399,6 +523,54 @@ pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error:: | |||
| 399 | Ok(items) | 523 | Ok(items) |
| 400 | } | 524 | } |
| 401 | 525 | ||
| 526 | /// List all issue refs (active + archived) and return their materialized state. | ||
| 527 | pub fn list_issues_with_archived(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> { | ||
| 528 | let mut items: Vec<_> = collab_refs(repo, "issues")? | ||
| 529 | .into_iter() | ||
| 530 | .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok()) | ||
| 531 | .collect(); | ||
| 532 | let archived: Vec<_> = collab_archive_refs(repo, "issues")? | ||
| 533 | .into_iter() | ||
| 534 | .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok()) | ||
| 535 | .collect(); | ||
| 536 | items.extend(archived); | ||
| 537 | Ok(items) | ||
| 538 | } | ||
| 539 | |||
| 540 | /// List all patch refs (active + archived) and return their materialized state. | ||
| 541 | pub fn list_patches_with_archived(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> { | ||
| 542 | let mut items: Vec<_> = collab_refs(repo, "patches")? | ||
| 543 | .into_iter() | ||
| 544 | .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok()) | ||
| 545 | .collect(); | ||
| 546 | let archived: Vec<_> = collab_archive_refs(repo, "patches")? | ||
| 547 | .into_iter() | ||
| 548 | .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok()) | ||
| 549 | .collect(); | ||
| 550 | items.extend(archived); | ||
| 551 | Ok(items) | ||
| 552 | } | ||
| 553 | |||
| 554 | /// Move an issue ref from active to archive namespace. | ||
| 555 | pub fn archive_issue_ref(repo: &Repository, id: &str) -> Result<(), crate::error::Error> { | ||
| 556 | let old_ref = format!("refs/collab/issues/{}", id); | ||
| 557 | let oid = repo.refname_to_id(&old_ref)?; | ||
| 558 | let new_ref = format!("refs/collab/archive/issues/{}", id); | ||
| 559 | repo.reference(&new_ref, oid, false, "archive issue")?; | ||
| 560 | repo.find_reference(&old_ref)?.delete()?; | ||
| 561 | Ok(()) | ||
| 562 | } | ||
| 563 | |||
| 564 | /// Move a patch ref from active to archive namespace. | ||
| 565 | pub fn archive_patch_ref(repo: &Repository, id: &str) -> Result<(), crate::error::Error> { | ||
| 566 | let old_ref = format!("refs/collab/patches/{}", id); | ||
| 567 | let oid = repo.refname_to_id(&old_ref)?; | ||
| 568 | let new_ref = format!("refs/collab/archive/patches/{}", id); | ||
| 569 | repo.reference(&new_ref, oid, false, "archive patch")?; | ||
| 570 | repo.find_reference(&old_ref)?.delete()?; | ||
| 571 | Ok(()) | ||
| 572 | } | ||
| 573 | |||
| 402 | /// Resolve a short ID prefix to the full issue ref name. Returns (ref_name, id). | 574 | /// Resolve a short ID prefix to the full issue ref name. Returns (ref_name, id). |
| 403 | pub fn resolve_issue_ref( | 575 | pub fn resolve_issue_ref( |
| 404 | repo: &Repository, | 576 | repo: &Repository, |
src/sync.rs
| Old | New | ||
|---|---|---|---|
| @@ -182,7 +182,12 @@ fn push_ref(workdir: &Path, remote_name: &str, ref_name: &str) -> RefPushResult | |||
| 182 | /// Collect all collab ref names that should be pushed. | 182 | /// Collect all collab ref names that should be pushed. |
| 183 | fn collect_push_refs(repo: &Repository) -> Result<Vec<String>, Error> { | 183 | fn collect_push_refs(repo: &Repository) -> Result<Vec<String>, Error> { |
| 184 | let mut refs = Vec::new(); | 184 | let mut refs = Vec::new(); |
| 185 | for pattern in &["refs/collab/issues/*", "refs/collab/patches/*"] { | 185 | for pattern in &[ |
| 186 | "refs/collab/issues/*", | ||
| 187 | "refs/collab/patches/*", | ||
| 188 | "refs/collab/archive/issues/*", | ||
| 189 | "refs/collab/archive/patches/*", | ||
| 190 | ] { | ||
| 186 | let iter = repo.references_glob(pattern)?; | 191 | let iter = repo.references_glob(pattern)?; |
| 187 | for r in iter { | 192 | for r in iter { |
| 188 | let r = r?; | 193 | let r = r?; |
| @@ -294,6 +299,8 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { | |||
| 294 | remote_name, | 299 | remote_name, |
| 295 | "+refs/collab/issues/*:refs/collab/sync/issues/*", | 300 | "+refs/collab/issues/*:refs/collab/sync/issues/*", |
| 296 | "+refs/collab/patches/*:refs/collab/sync/patches/*", | 301 | "+refs/collab/patches/*:refs/collab/sync/patches/*", |
| 302 | "+refs/collab/archive/issues/*:refs/collab/sync/archive/issues/*", | ||
| 303 | "+refs/collab/archive/patches/*:refs/collab/sync/archive/patches/*", | ||
| 297 | ]) | 304 | ]) |
| 298 | .current_dir(&workdir) | 305 | .current_dir(&workdir) |
| 299 | .status() | 306 | .status() |
src/trust.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,6 @@ | |||
| 1 | use std::collections::HashSet; | 1 | use std::collections::HashSet; |
| 2 | use std::fs; | 2 | use std::fs; |
| 3 | use std::path::PathBuf; | 3 | use std::path::{Path, PathBuf}; |
| 4 | 4 | ||
| 5 | use base64::engine::general_purpose::STANDARD; | 5 | use base64::engine::general_purpose::STANDARD; |
| 6 | use base64::Engine; | 6 | use base64::Engine; |
| @@ -33,6 +33,12 @@ pub fn trusted_keys_path(repo: &Repository) -> PathBuf { | |||
| 33 | repo.path().join("collab/trusted-keys") | 33 | repo.path().join("collab/trusted-keys") |
| 34 | } | 34 | } |
| 35 | 35 | ||
| 36 | /// Return the path to the global trusted keys file (~/.config/git-collab/trusted-keys). | ||
| 37 | pub fn global_trusted_keys_path() -> Result<PathBuf, Error> { | ||
| 38 | let config_dir = crate::signing::signing_key_dir()?; | ||
| 39 | Ok(config_dir.join("trusted-keys")) | ||
| 40 | } | ||
| 41 | |||
| 36 | /// Validate that a string is a valid base64-encoded 32-byte Ed25519 public key. | 42 | /// Validate that a string is a valid base64-encoded 32-byte Ed25519 public key. |
| 37 | pub fn validate_pubkey(pubkey: &str) -> Result<(), Error> { | 43 | pub fn validate_pubkey(pubkey: &str) -> Result<(), Error> { |
| 38 | let bytes = STANDARD | 44 | let bytes = STANDARD |
| @@ -50,18 +56,19 @@ pub fn validate_pubkey(pubkey: &str) -> Result<(), Error> { | |||
| 50 | Ok(()) | 56 | Ok(()) |
| 51 | } | 57 | } |
| 52 | 58 | ||
| 53 | /// Load the trust policy from the trusted keys file. | 59 | /// Parse trusted keys from a file. Returns an empty Vec if the file does not exist. |
| 54 | /// | 60 | /// Returns `None` if the file does not exist (to distinguish from an empty file). |
| 55 | /// Returns `TrustPolicy::Unconfigured` if the file does not exist. | ||
| 56 | /// Returns `TrustPolicy::Configured(keys)` if the file exists (even if empty). | ||
| 57 | /// Malformed lines are skipped with a warning to stderr. | 61 | /// Malformed lines are skipped with a warning to stderr. |
| 58 | /// Duplicate keys are deduplicated (last wins for label). | 62 | /// Duplicate keys are deduplicated (last wins for label). |
| 59 | pub fn load_trust_policy(repo: &Repository) -> Result<TrustPolicy, Error> { | 63 | pub fn parse_trusted_keys_file(path: &Path) -> Result<Vec<TrustedKey>, Error> { |
| 60 | let path = trusted_keys_path(repo); | ||
| 61 | if !path.exists() { | 64 | if !path.exists() { |
| 62 | return Ok(TrustPolicy::Unconfigured); | 65 | return Ok(Vec::new()); |
| 63 | } | 66 | } |
| 64 | let content = fs::read_to_string(&path)?; | 67 | parse_trusted_keys_content(&fs::read_to_string(path)?) |
| 68 | } | ||
| 69 | |||
| 70 | /// Parse trusted keys from a string content. | ||
| 71 | fn parse_trusted_keys_content(content: &str) -> Result<Vec<TrustedKey>, Error> { | ||
| 65 | let mut seen = HashSet::new(); | 72 | let mut seen = HashSet::new(); |
| 66 | let mut keys = Vec::new(); | 73 | let mut keys = Vec::new(); |
| 67 | 74 | ||
| @@ -87,7 +94,74 @@ pub fn load_trust_policy(repo: &Repository) -> Result<TrustPolicy, Error> { | |||
| 87 | keys.push(TrustedKey { pubkey, label }); | 94 | keys.push(TrustedKey { pubkey, label }); |
| 88 | } | 95 | } |
| 89 | 96 | ||
| 90 | Ok(TrustPolicy::Configured(keys)) | 97 | Ok(keys) |
| 98 | } | ||
| 99 | |||
| 100 | /// Merge two key lists. Keys from `later` override labels from `earlier` on duplicates. | ||
| 101 | fn merge_keys(earlier: Vec<TrustedKey>, later: Vec<TrustedKey>) -> Vec<TrustedKey> { | ||
| 102 | let mut seen = HashSet::new(); | ||
| 103 | let mut merged = Vec::new(); | ||
| 104 | |||
| 105 | // Add all from earlier | ||
| 106 | for k in earlier { | ||
| 107 | seen.insert(k.pubkey.clone()); | ||
| 108 | merged.push(k); | ||
| 109 | } | ||
| 110 | |||
| 111 | // Add/override from later | ||
| 112 | for k in later { | ||
| 113 | if seen.contains(&k.pubkey) { | ||
| 114 | // Replace earlier entry with later (last wins for label) | ||
| 115 | for existing in merged.iter_mut() { | ||
| 116 | if existing.pubkey == k.pubkey { | ||
| 117 | existing.label = k.label.clone(); | ||
| 118 | break; | ||
| 119 | } | ||
| 120 | } | ||
| 121 | } else { | ||
| 122 | seen.insert(k.pubkey.clone()); | ||
| 123 | merged.push(k); | ||
| 124 | } | ||
| 125 | } | ||
| 126 | |||
| 127 | merged | ||
| 128 | } | ||
| 129 | |||
| 130 | /// Load the trust policy from both global and repo-local trusted keys files. | ||
| 131 | /// | ||
| 132 | /// Returns `TrustPolicy::Unconfigured` if neither file exists. | ||
| 133 | /// Returns `TrustPolicy::Configured(keys)` if either file exists (even if empty). | ||
| 134 | /// Global keys are loaded first, then repo-local keys are merged in. | ||
| 135 | /// Repo-local keys take precedence for labels on duplicate keys. | ||
| 136 | pub fn load_trust_policy(repo: &Repository) -> Result<TrustPolicy, Error> { | ||
| 137 | let global_path = global_trusted_keys_path().ok(); | ||
| 138 | load_trust_policy_with_global(repo, global_path.as_deref()) | ||
| 139 | } | ||
| 140 | |||
| 141 | /// Load trust policy with an explicit global path (for testing). | ||
| 142 | pub fn load_trust_policy_with_global(repo: &Repository, global_path: Option<&Path>) -> Result<TrustPolicy, Error> { | ||
| 143 | let repo_path = trusted_keys_path(repo); | ||
| 144 | let repo_exists = repo_path.exists(); | ||
| 145 | let global_exists = global_path.is_some_and(|p| p.exists()); | ||
| 146 | |||
| 147 | if !repo_exists && !global_exists { | ||
| 148 | return Ok(TrustPolicy::Unconfigured); | ||
| 149 | } | ||
| 150 | |||
| 151 | let global_keys = if let Some(gp) = global_path { | ||
| 152 | parse_trusted_keys_file(gp)? | ||
| 153 | } else { | ||
| 154 | Vec::new() | ||
| 155 | }; | ||
| 156 | |||
| 157 | let repo_keys = if repo_exists { | ||
| 158 | parse_trusted_keys_file(&repo_path)? | ||
| 159 | } else { | ||
| 160 | Vec::new() | ||
| 161 | }; | ||
| 162 | |||
| 163 | let merged = merge_keys(global_keys, repo_keys); | ||
| 164 | Ok(TrustPolicy::Configured(merged)) | ||
| 91 | } | 165 | } |
| 92 | 166 | ||
| 93 | /// Check whether a pubkey is in the trusted set. | 167 | /// Check whether a pubkey is in the trusted set. |
| @@ -95,23 +169,20 @@ pub fn is_key_trusted(keys: &[TrustedKey], pubkey: &str) -> bool { | |||
| 95 | keys.iter().any(|k| k.pubkey == pubkey) | 169 | keys.iter().any(|k| k.pubkey == pubkey) |
| 96 | } | 170 | } |
| 97 | 171 | ||
| 98 | /// Save (append) a trusted key to the trusted keys file. | 172 | /// Save (append) a trusted key to an arbitrary trusted keys file. |
| 99 | /// | 173 | /// |
| 100 | /// Creates the `.git/collab/` directory and file if needed. | 174 | /// Creates the parent directory and file if needed. |
| 101 | /// Returns `true` if the key was added, `false` if it was already trusted. | 175 | /// Returns `true` if the key was added, `false` if it was already present. |
| 102 | pub fn save_trusted_key(repo: &Repository, pubkey: &str, label: Option<&str>) -> Result<bool, Error> { | 176 | pub fn save_trusted_key_to_file(path: &Path, pubkey: &str, label: Option<&str>) -> Result<bool, Error> { |
| 103 | let path = trusted_keys_path(repo); | ||
| 104 | // Ensure directory exists | 177 | // Ensure directory exists |
| 105 | if let Some(parent) = path.parent() { | 178 | if let Some(parent) = path.parent() { |
| 106 | fs::create_dir_all(parent)?; | 179 | fs::create_dir_all(parent)?; |
| 107 | } | 180 | } |
| 108 | // Check for duplicates | 181 | // Check for duplicates |
| 109 | if path.exists() { | 182 | if path.exists() { |
| 110 | let policy = load_trust_policy(repo)?; | 183 | let keys = parse_trusted_keys_file(path)?; |
| 111 | if let TrustPolicy::Configured(ref keys) = policy { | 184 | if is_key_trusted(&keys, pubkey) { |
| 112 | if is_key_trusted(keys, pubkey) { | 185 | return Ok(false); |
| 113 | return Ok(false); | ||
| 114 | } | ||
| 115 | } | 186 | } |
| 116 | } | 187 | } |
| 117 | // Append key | 188 | // Append key |
| @@ -123,61 +194,84 @@ pub fn save_trusted_key(repo: &Repository, pubkey: &str, label: Option<&str>) -> | |||
| 123 | let mut file = fs::OpenOptions::new() | 194 | let mut file = fs::OpenOptions::new() |
| 124 | .create(true) | 195 | .create(true) |
| 125 | .append(true) | 196 | .append(true) |
| 126 | .open(&path)?; | 197 | .open(path)?; |
| 127 | file.write_all(line.as_bytes())?; | 198 | file.write_all(line.as_bytes())?; |
| 128 | Ok(true) | 199 | Ok(true) |
| 129 | } | 200 | } |
| 130 | 201 | ||
| 131 | /// Remove a trusted key from the file. Returns the removed entry. | 202 | /// Save (append) a trusted key to the repo-local trusted keys file. |
| 132 | pub fn remove_trusted_key(repo: &Repository, pubkey: &str) -> Result<TrustedKey, Error> { | 203 | /// |
| 204 | /// Creates the `.git/collab/` directory and file if needed. | ||
| 205 | /// Returns `true` if the key was added, `false` if it was already trusted. | ||
| 206 | pub fn save_trusted_key(repo: &Repository, pubkey: &str, label: Option<&str>) -> Result<bool, Error> { | ||
| 133 | let path = trusted_keys_path(repo); | 207 | let path = trusted_keys_path(repo); |
| 134 | let policy = load_trust_policy(repo)?; | 208 | save_trusted_key_to_file(&path, pubkey, label) |
| 135 | match policy { | 209 | } |
| 136 | TrustPolicy::Unconfigured => { | 210 | |
| 137 | Err(Error::Cmd(format!( | 211 | /// Save (append) a trusted key to the global trusted keys file. |
| 138 | "key {} is not in the trusted keys list", | 212 | /// |
| 139 | pubkey | 213 | /// Creates `~/.config/git-collab/` directory and file if needed. |
| 140 | ))) | 214 | /// Returns `true` if the key was added, `false` if it was already trusted. |
| 141 | } | 215 | pub fn save_trusted_key_global(pubkey: &str, label: Option<&str>) -> Result<bool, Error> { |
| 142 | TrustPolicy::Configured(keys) => { | 216 | let path = global_trusted_keys_path()?; |
| 143 | let removed = keys.iter().find(|k| k.pubkey == pubkey).cloned(); | 217 | save_trusted_key_to_file(&path, pubkey, label) |
| 144 | match removed { | 218 | } |
| 145 | None => { | 219 | |
| 146 | Err(Error::Cmd(format!( | 220 | /// Remove a trusted key from an arbitrary trusted keys file. Returns the removed entry. |
| 147 | "key {} is not in the trusted keys list", | 221 | pub fn remove_trusted_key_from_file(path: &Path, pubkey: &str) -> Result<TrustedKey, Error> { |
| 148 | pubkey | 222 | if !path.exists() { |
| 149 | ))) | 223 | return Err(Error::Cmd(format!( |
| 224 | "key {} is not in the trusted keys list", | ||
| 225 | pubkey | ||
| 226 | ))); | ||
| 227 | } | ||
| 228 | let keys = parse_trusted_keys_file(path)?; | ||
| 229 | let removed = keys.iter().find(|k| k.pubkey == pubkey).cloned(); | ||
| 230 | match removed { | ||
| 231 | None => Err(Error::Cmd(format!( | ||
| 232 | "key {} is not in the trusted keys list", | ||
| 233 | pubkey | ||
| 234 | ))), | ||
| 235 | Some(removed_key) => { | ||
| 236 | // Rewrite file without the removed key | ||
| 237 | let content = fs::read_to_string(path)?; | ||
| 238 | let mut new_lines = Vec::new(); | ||
| 239 | for line in content.lines() { | ||
| 240 | let trimmed = line.trim(); | ||
| 241 | if trimmed.is_empty() || trimmed.starts_with('#') { | ||
| 242 | new_lines.push(line.to_string()); | ||
| 243 | continue; | ||
| 150 | } | 244 | } |
| 151 | Some(removed_key) => { | 245 | let key_part = trimmed.split_once(' ') |
| 152 | // Rewrite file without the removed key | 246 | .map(|(k, _)| k) |
| 153 | let content = fs::read_to_string(&path)?; | 247 | .unwrap_or(trimmed); |
| 154 | let mut new_lines = Vec::new(); | 248 | if key_part != pubkey { |
| 155 | for line in content.lines() { | 249 | new_lines.push(line.to_string()); |
| 156 | let trimmed = line.trim(); | ||
| 157 | if trimmed.is_empty() || trimmed.starts_with('#') { | ||
| 158 | new_lines.push(line.to_string()); | ||
| 159 | continue; | ||
| 160 | } | ||
| 161 | let key_part = trimmed.split_once(' ') | ||
| 162 | .map(|(k, _)| k) | ||
| 163 | .unwrap_or(trimmed); | ||
| 164 | if key_part != pubkey { | ||
| 165 | new_lines.push(line.to_string()); | ||
| 166 | } | ||
| 167 | } | ||
| 168 | let new_content = if new_lines.is_empty() { | ||
| 169 | String::new() | ||
| 170 | } else { | ||
| 171 | new_lines.join("\n") + "\n" | ||
| 172 | }; | ||
| 173 | fs::write(&path, new_content)?; | ||
| 174 | Ok(removed_key) | ||
| 175 | } | 250 | } |
| 176 | } | 251 | } |
| 252 | let new_content = if new_lines.is_empty() { | ||
| 253 | String::new() | ||
| 254 | } else { | ||
| 255 | new_lines.join("\n") + "\n" | ||
| 256 | }; | ||
| 257 | fs::write(path, new_content)?; | ||
| 258 | Ok(removed_key) | ||
| 177 | } | 259 | } |
| 178 | } | 260 | } |
| 179 | } | 261 | } |
| 180 | 262 | ||
| 263 | /// Remove a trusted key from the repo-local file. Returns the removed entry. | ||
| 264 | pub fn remove_trusted_key(repo: &Repository, pubkey: &str) -> Result<TrustedKey, Error> { | ||
| 265 | let path = trusted_keys_path(repo); | ||
| 266 | remove_trusted_key_from_file(&path, pubkey) | ||
| 267 | } | ||
| 268 | |||
| 269 | /// Remove a trusted key from the global file. Returns the removed entry. | ||
| 270 | pub fn remove_trusted_key_global(pubkey: &str) -> Result<TrustedKey, Error> { | ||
| 271 | let path = global_trusted_keys_path()?; | ||
| 272 | remove_trusted_key_from_file(&path, pubkey) | ||
| 273 | } | ||
| 274 | |||
| 181 | /// Check trust for a set of signature verification results. | 275 | /// Check trust for a set of signature verification results. |
| 182 | /// | 276 | /// |
| 183 | /// If `TrustPolicy::Unconfigured`, returns the results unchanged. | 277 | /// If `TrustPolicy::Unconfigured`, returns the results unchanged. |
| @@ -502,4 +596,185 @@ mod tests { | |||
| 502 | _ => panic!("expected Configured (empty)"), | 596 | _ => panic!("expected Configured (empty)"), |
| 503 | } | 597 | } |
| 504 | } | 598 | } |
| 599 | |||
| 600 | // ===================================================== | ||
| 601 | // Global trust store tests | ||
| 602 | // ===================================================== | ||
| 603 | |||
| 604 | #[test] | ||
| 605 | fn global_trusted_keys_path_returns_config_path() { | ||
| 606 | let path = global_trusted_keys_path().unwrap(); | ||
| 607 | assert!(path.to_string_lossy().contains("git-collab")); | ||
| 608 | assert!(path.to_string_lossy().ends_with("trusted-keys")); | ||
| 609 | } | ||
| 610 | |||
| 611 | #[test] | ||
| 612 | fn load_trust_policy_merges_global_and_repo_keys() { | ||
| 613 | let (_dir, repo) = test_repo(); | ||
| 614 | let global_dir = TempDir::new().unwrap(); | ||
| 615 | let global_path = global_dir.path().join("trusted-keys"); | ||
| 616 | |||
| 617 | let pk_global = valid_test_pubkey(); | ||
| 618 | let pk_repo = valid_test_pubkey(); | ||
| 619 | |||
| 620 | // Write a global key | ||
| 621 | fs::write(&global_path, format!("{} GlobalAlice\n", pk_global)).unwrap(); | ||
| 622 | |||
| 623 | // Write a repo key | ||
| 624 | save_trusted_key(&repo, &pk_repo, Some("RepoAlice")).unwrap(); | ||
| 625 | |||
| 626 | // Load with custom global path | ||
| 627 | let policy = load_trust_policy_with_global(&repo, Some(&global_path)).unwrap(); | ||
| 628 | match policy { | ||
| 629 | TrustPolicy::Configured(keys) => { | ||
| 630 | assert_eq!(keys.len(), 2, "should have both global and repo keys"); | ||
| 631 | let pubkeys: Vec<&str> = keys.iter().map(|k| k.pubkey.as_str()).collect(); | ||
| 632 | assert!(pubkeys.contains(&pk_global.as_str()), "should contain global key"); | ||
| 633 | assert!(pubkeys.contains(&pk_repo.as_str()), "should contain repo key"); | ||
| 634 | } | ||
| 635 | _ => panic!("expected Configured"), | ||
| 636 | } | ||
| 637 | } | ||
| 638 | |||
| 639 | #[test] | ||
| 640 | fn load_trust_policy_repo_key_overrides_global_label() { | ||
| 641 | let (_dir, repo) = test_repo(); | ||
| 642 | let global_dir = TempDir::new().unwrap(); | ||
| 643 | let global_path = global_dir.path().join("trusted-keys"); | ||
| 644 | |||
| 645 | let pk = valid_test_pubkey(); | ||
| 646 | |||
| 647 | // Same key in both global and repo, with different labels | ||
| 648 | fs::write(&global_path, format!("{} GlobalLabel\n", pk)).unwrap(); | ||
| 649 | save_trusted_key(&repo, &pk, Some("RepoLabel")).unwrap(); | ||
| 650 | |||
| 651 | let policy = load_trust_policy_with_global(&repo, Some(&global_path)).unwrap(); | ||
| 652 | match policy { | ||
| 653 | TrustPolicy::Configured(keys) => { | ||
| 654 | assert_eq!(keys.len(), 1, "duplicates should be merged"); | ||
| 655 | // Repo-local takes precedence (loaded second, last wins) | ||
| 656 | assert_eq!(keys[0].label.as_deref(), Some("RepoLabel")); | ||
| 657 | } | ||
| 658 | _ => panic!("expected Configured"), | ||
| 659 | } | ||
| 660 | } | ||
| 661 | |||
| 662 | #[test] | ||
| 663 | fn load_trust_policy_global_only_no_repo_file() { | ||
| 664 | let (_dir, repo) = test_repo(); | ||
| 665 | let global_dir = TempDir::new().unwrap(); | ||
| 666 | let global_path = global_dir.path().join("trusted-keys"); | ||
| 667 | |||
| 668 | let pk = valid_test_pubkey(); | ||
| 669 | fs::write(&global_path, format!("{} GlobalOnly\n", pk)).unwrap(); | ||
| 670 | |||
| 671 | let policy = load_trust_policy_with_global(&repo, Some(&global_path)).unwrap(); | ||
| 672 | match policy { | ||
| 673 | TrustPolicy::Configured(keys) => { | ||
| 674 | assert_eq!(keys.len(), 1); | ||
| 675 | assert_eq!(keys[0].pubkey, pk); | ||
| 676 | assert_eq!(keys[0].label.as_deref(), Some("GlobalOnly")); | ||
| 677 | } | ||
| 678 | _ => panic!("expected Configured"), | ||
| 679 | } | ||
| 680 | } | ||
| 681 | |||
| 682 | #[test] | ||
| 683 | fn load_trust_policy_no_global_file_falls_back_to_repo_only() { | ||
| 684 | let (_dir, repo) = test_repo(); | ||
| 685 | let global_dir = TempDir::new().unwrap(); | ||
| 686 | let global_path = global_dir.path().join("nonexistent-trusted-keys"); | ||
| 687 | |||
| 688 | let pk = valid_test_pubkey(); | ||
| 689 | save_trusted_key(&repo, &pk, Some("RepoOnly")).unwrap(); | ||
| 690 | |||
| 691 | let policy = load_trust_policy_with_global(&repo, Some(&global_path)).unwrap(); | ||
| 692 | match policy { | ||
| 693 | TrustPolicy::Configured(keys) => { | ||
| 694 | assert_eq!(keys.len(), 1); | ||
| 695 | assert_eq!(keys[0].pubkey, pk); | ||
| 696 | } | ||
| 697 | _ => panic!("expected Configured"), | ||
| 698 | } | ||
| 699 | } | ||
| 700 | |||
| 701 | #[test] | ||
| 702 | fn load_trust_policy_neither_global_nor_repo_returns_unconfigured() { | ||
| 703 | let (_dir, repo) = test_repo(); | ||
| 704 | let global_dir = TempDir::new().unwrap(); | ||
| 705 | let global_path = global_dir.path().join("nonexistent-trusted-keys"); | ||
| 706 | |||
| 707 | let policy = load_trust_policy_with_global(&repo, Some(&global_path)).unwrap(); | ||
| 708 | assert!(matches!(policy, TrustPolicy::Unconfigured)); | ||
| 709 | } | ||
| 710 | |||
| 711 | #[test] | ||
| 712 | fn save_trusted_key_global_creates_file_and_appends() { | ||
| 713 | let global_dir = TempDir::new().unwrap(); | ||
| 714 | let global_path = global_dir.path().join("trusted-keys"); | ||
| 715 | |||
| 716 | let pk1 = valid_test_pubkey(); | ||
| 717 | let pk2 = valid_test_pubkey(); | ||
| 718 | |||
| 719 | let added = save_trusted_key_to_file(&global_path, &pk1, Some("Alice")).unwrap(); | ||
| 720 | assert!(added, "first add should return true"); | ||
| 721 | |||
| 722 | let added = save_trusted_key_to_file(&global_path, &pk2, None).unwrap(); | ||
| 723 | assert!(added, "second add should return true"); | ||
| 724 | |||
| 725 | let keys = parse_trusted_keys_file(&global_path).unwrap(); | ||
| 726 | assert_eq!(keys.len(), 2); | ||
| 727 | assert_eq!(keys[0].pubkey, pk1); | ||
| 728 | assert_eq!(keys[0].label.as_deref(), Some("Alice")); | ||
| 729 | assert_eq!(keys[1].pubkey, pk2); | ||
| 730 | assert!(keys[1].label.is_none()); | ||
| 731 | } | ||
| 732 | |||
| 733 | #[test] | ||
| 734 | fn save_trusted_key_global_prevents_duplicates() { | ||
| 735 | let global_dir = TempDir::new().unwrap(); | ||
| 736 | let global_path = global_dir.path().join("trusted-keys"); | ||
| 737 | |||
| 738 | let pk = valid_test_pubkey(); | ||
| 739 | let added = save_trusted_key_to_file(&global_path, &pk, Some("Alice")).unwrap(); | ||
| 740 | assert!(added); | ||
| 741 | let added = save_trusted_key_to_file(&global_path, &pk, Some("Alice again")).unwrap(); | ||
| 742 | assert!(!added, "duplicate add should return false"); | ||
| 743 | |||
| 744 | let keys = parse_trusted_keys_file(&global_path).unwrap(); | ||
| 745 | assert_eq!(keys.len(), 1); | ||
| 746 | } | ||
| 747 | |||
| 748 | #[test] | ||
| 749 | fn remove_trusted_key_from_file_works() { | ||
| 750 | let global_dir = TempDir::new().unwrap(); | ||
| 751 | let global_path = global_dir.path().join("trusted-keys"); | ||
| 752 | |||
| 753 | let pk1 = valid_test_pubkey(); | ||
| 754 | let pk2 = valid_test_pubkey(); | ||
| 755 | save_trusted_key_to_file(&global_path, &pk1, Some("Alice")).unwrap(); | ||
| 756 | save_trusted_key_to_file(&global_path, &pk2, Some("Bob")).unwrap(); | ||
| 757 | |||
| 758 | let removed = remove_trusted_key_from_file(&global_path, &pk1).unwrap(); | ||
| 759 | assert_eq!(removed.pubkey, pk1); | ||
| 760 | assert_eq!(removed.label.as_deref(), Some("Alice")); | ||
| 761 | |||
| 762 | let keys = parse_trusted_keys_file(&global_path).unwrap(); | ||
| 763 | assert_eq!(keys.len(), 1); | ||
| 764 | assert_eq!(keys[0].pubkey, pk2); | ||
| 765 | } | ||
| 766 | |||
| 767 | #[test] | ||
| 768 | fn remove_trusted_key_from_file_errors_when_not_found() { | ||
| 769 | let global_dir = TempDir::new().unwrap(); | ||
| 770 | let global_path = global_dir.path().join("trusted-keys"); | ||
| 771 | |||
| 772 | let pk = valid_test_pubkey(); | ||
| 773 | save_trusted_key_to_file(&global_path, &pk, None).unwrap(); | ||
| 774 | |||
| 775 | let other_pk = valid_test_pubkey(); | ||
| 776 | let result = remove_trusted_key_from_file(&global_path, &other_pk); | ||
| 777 | assert!(result.is_err()); | ||
| 778 | assert!(result.unwrap_err().to_string().contains("not in the trusted keys list")); | ||
| 779 | } | ||
| 505 | } | 780 | } |
src/tui.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,1857 +0,0 @@ | |||
| 1 | use std::io::{self, stdout}; | ||
| 2 | use std::time::Duration; | ||
| 3 | |||
| 4 | use crossterm::event::{self, Event, KeyCode, KeyModifiers}; | ||
| 5 | use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; | ||
| 6 | use crossterm::ExecutableCommand; | ||
| 7 | use git2::{Oid, Repository}; | ||
| 8 | use ratatui::prelude::*; | ||
| 9 | use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; | ||
| 10 | |||
| 11 | use crate::error::Error; | ||
| 12 | use crate::event::Action; | ||
| 13 | use crate::issue as issue_mod; | ||
| 14 | use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; | ||
| 15 | |||
| 16 | #[derive(Debug, PartialEq)] | ||
| 17 | enum Pane { | ||
| 18 | ItemList, | ||
| 19 | Detail, | ||
| 20 | } | ||
| 21 | |||
| 22 | #[derive(Debug, PartialEq)] | ||
| 23 | enum ViewMode { | ||
| 24 | Details, | ||
| 25 | CommitList, | ||
| 26 | CommitDetail, | ||
| 27 | } | ||
| 28 | |||
| 29 | #[derive(Debug, PartialEq)] | ||
| 30 | enum KeyAction { | ||
| 31 | Continue, | ||
| 32 | Quit, | ||
| 33 | Reload, | ||
| 34 | OpenCommitBrowser, | ||
| 35 | } | ||
| 36 | |||
| 37 | #[derive(Debug, PartialEq, Clone, Copy)] | ||
| 38 | enum StatusFilter { | ||
| 39 | Open, | ||
| 40 | Closed, | ||
| 41 | All, | ||
| 42 | } | ||
| 43 | |||
| 44 | impl StatusFilter { | ||
| 45 | fn next(self) -> Self { | ||
| 46 | match self { | ||
| 47 | StatusFilter::Open => StatusFilter::All, | ||
| 48 | StatusFilter::All => StatusFilter::Closed, | ||
| 49 | StatusFilter::Closed => StatusFilter::Open, | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | fn label(self) -> &'static str { | ||
| 54 | match self { | ||
| 55 | StatusFilter::Open => "open", | ||
| 56 | StatusFilter::Closed => "closed", | ||
| 57 | StatusFilter::All => "all", | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | #[derive(Debug, PartialEq)] | ||
| 63 | enum InputMode { | ||
| 64 | Normal, | ||
| 65 | Search, | ||
| 66 | CreateTitle, | ||
| 67 | CreateBody, | ||
| 68 | } | ||
| 69 | |||
| 70 | struct App { | ||
| 71 | issues: Vec<IssueState>, | ||
| 72 | patches: Vec<PatchState>, | ||
| 73 | list_state: ListState, | ||
| 74 | scroll: u16, | ||
| 75 | pane: Pane, | ||
| 76 | mode: ViewMode, | ||
| 77 | status_filter: StatusFilter, | ||
| 78 | search_query: String, | ||
| 79 | input_mode: InputMode, | ||
| 80 | input_buf: String, | ||
| 81 | create_title: String, | ||
| 82 | status_msg: Option<String>, | ||
| 83 | event_history: Vec<(Oid, crate::event::Event)>, | ||
| 84 | event_list_state: ListState, | ||
| 85 | } | ||
| 86 | |||
| 87 | impl App { | ||
| 88 | fn new(issues: Vec<IssueState>, patches: Vec<PatchState>) -> Self { | ||
| 89 | let mut list_state = ListState::default(); | ||
| 90 | if !issues.is_empty() { | ||
| 91 | list_state.select(Some(0)); | ||
| 92 | } | ||
| 93 | Self { | ||
| 94 | issues, | ||
| 95 | patches, | ||
| 96 | list_state, | ||
| 97 | scroll: 0, | ||
| 98 | pane: Pane::ItemList, | ||
| 99 | mode: ViewMode::Details, | ||
| 100 | status_filter: StatusFilter::Open, | ||
| 101 | search_query: String::new(), | ||
| 102 | input_mode: InputMode::Normal, | ||
| 103 | input_buf: String::new(), | ||
| 104 | create_title: String::new(), | ||
| 105 | status_msg: None, | ||
| 106 | event_history: Vec::new(), | ||
| 107 | event_list_state: ListState::default(), | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | fn matches_search(&self, title: &str) -> bool { | ||
| 112 | if self.search_query.is_empty() { | ||
| 113 | return true; | ||
| 114 | } | ||
| 115 | title | ||
| 116 | .to_lowercase() | ||
| 117 | .contains(&self.search_query.to_lowercase()) | ||
| 118 | } | ||
| 119 | |||
| 120 | fn visible_issues(&self) -> Vec<&IssueState> { | ||
| 121 | self.issues | ||
| 122 | .iter() | ||
| 123 | .filter(|i| match self.status_filter { | ||
| 124 | StatusFilter::Open => i.status == IssueStatus::Open, | ||
| 125 | StatusFilter::Closed => i.status == IssueStatus::Closed, | ||
| 126 | StatusFilter::All => true, | ||
| 127 | }) | ||
| 128 | .filter(|i| self.matches_search(&i.title)) | ||
| 129 | .collect() | ||
| 130 | } | ||
| 131 | |||
| 132 | fn visible_count(&self) -> usize { | ||
| 133 | self.visible_issues().len() | ||
| 134 | } | ||
| 135 | |||
| 136 | fn move_selection(&mut self, delta: i32) { | ||
| 137 | let len = self.visible_count(); | ||
| 138 | if len == 0 { | ||
| 139 | return; | ||
| 140 | } | ||
| 141 | let current = self.list_state.selected().unwrap_or(0); | ||
| 142 | let new = if delta > 0 { | ||
| 143 | (current + delta as usize).min(len - 1) | ||
| 144 | } else { | ||
| 145 | current.saturating_sub((-delta) as usize) | ||
| 146 | }; | ||
| 147 | self.list_state.select(Some(new)); | ||
| 148 | self.scroll = 0; | ||
| 149 | } | ||
| 150 | |||
| 151 | fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction { | ||
| 152 | // Handle CommitDetail mode first | ||
| 153 | if self.mode == ViewMode::CommitDetail { | ||
| 154 | match code { | ||
| 155 | KeyCode::Esc => { | ||
| 156 | self.mode = ViewMode::CommitList; | ||
| 157 | self.scroll = 0; | ||
| 158 | return KeyAction::Continue; | ||
| 159 | } | ||
| 160 | KeyCode::Char('q') => return KeyAction::Quit, | ||
| 161 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { | ||
| 162 | return KeyAction::Quit; | ||
| 163 | } | ||
| 164 | KeyCode::Char('j') | KeyCode::Down => { | ||
| 165 | self.scroll = self.scroll.saturating_add(1); | ||
| 166 | return KeyAction::Continue; | ||
| 167 | } | ||
| 168 | KeyCode::Char('k') | KeyCode::Up => { | ||
| 169 | self.scroll = self.scroll.saturating_sub(1); | ||
| 170 | return KeyAction::Continue; | ||
| 171 | } | ||
| 172 | KeyCode::PageDown => { | ||
| 173 | self.scroll = self.scroll.saturating_add(20); | ||
| 174 | return KeyAction::Continue; | ||
| 175 | } | ||
| 176 | KeyCode::PageUp => { | ||
| 177 | self.scroll = self.scroll.saturating_sub(20); | ||
| 178 | return KeyAction::Continue; | ||
| 179 | } | ||
| 180 | _ => return KeyAction::Continue, | ||
| 181 | } | ||
| 182 | } | ||
| 183 | |||
| 184 | // Handle CommitList mode | ||
| 185 | if self.mode == ViewMode::CommitList { | ||
| 186 | match code { | ||
| 187 | KeyCode::Esc => { | ||
| 188 | self.event_history.clear(); | ||
| 189 | self.event_list_state = ListState::default(); | ||
| 190 | self.mode = ViewMode::Details; | ||
| 191 | self.scroll = 0; | ||
| 192 | return KeyAction::Continue; | ||
| 193 | } | ||
| 194 | KeyCode::Char('q') => return KeyAction::Quit, | ||
| 195 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { | ||
| 196 | return KeyAction::Quit; | ||
| 197 | } | ||
| 198 | KeyCode::Char('j') | KeyCode::Down => { | ||
| 199 | let len = self.event_history.len(); | ||
| 200 | if len > 0 { | ||
| 201 | let current = self.event_list_state.selected().unwrap_or(0); | ||
| 202 | let new = (current + 1).min(len - 1); | ||
| 203 | self.event_list_state.select(Some(new)); | ||
| 204 | } | ||
| 205 | return KeyAction::Continue; | ||
| 206 | } | ||
| 207 | KeyCode::Char('k') | KeyCode::Up => { | ||
| 208 | if !self.event_history.is_empty() { | ||
| 209 | let current = self.event_list_state.selected().unwrap_or(0); | ||
| 210 | let new = current.saturating_sub(1); | ||
| 211 | self.event_list_state.select(Some(new)); | ||
| 212 | } | ||
| 213 | return KeyAction::Continue; | ||
| 214 | } | ||
| 215 | KeyCode::Enter => { | ||
| 216 | if self.event_list_state.selected().is_some() { | ||
| 217 | self.mode = ViewMode::CommitDetail; | ||
| 218 | self.scroll = 0; | ||
| 219 | } | ||
| 220 | return KeyAction::Continue; | ||
| 221 | } | ||
| 222 | _ => return KeyAction::Continue, | ||
| 223 | } | ||
| 224 | } | ||
| 225 | |||
| 226 | // Normal Details mode handling | ||
| 227 | match code { | ||
| 228 | KeyCode::Char('q') | KeyCode::Esc => KeyAction::Quit, | ||
| 229 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => KeyAction::Quit, | ||
| 230 | KeyCode::Char('c') => { | ||
| 231 | // Open commit browser: only when in detail pane with an item selected | ||
| 232 | if self.pane == Pane::Detail && self.list_state.selected().is_some() { | ||
| 233 | KeyAction::OpenCommitBrowser | ||
| 234 | } else { | ||
| 235 | KeyAction::Continue | ||
| 236 | } | ||
| 237 | } | ||
| 238 | KeyCode::Char('j') | KeyCode::Down => { | ||
| 239 | if self.pane == Pane::ItemList { | ||
| 240 | self.move_selection(1); | ||
| 241 | } else { | ||
| 242 | self.scroll = self.scroll.saturating_add(1); | ||
| 243 | } | ||
| 244 | KeyAction::Continue | ||
| 245 | } | ||
| 246 | KeyCode::Char('k') | KeyCode::Up => { | ||
| 247 | if self.pane == Pane::ItemList { | ||
| 248 | self.move_selection(-1); | ||
| 249 | } else { | ||
| 250 | self.scroll = self.scroll.saturating_sub(1); | ||
| 251 | } | ||
| 252 | KeyAction::Continue | ||
| 253 | } | ||
| 254 | KeyCode::PageDown => { | ||
| 255 | self.scroll = self.scroll.saturating_add(20); | ||
| 256 | KeyAction::Continue | ||
| 257 | } | ||
| 258 | KeyCode::PageUp => { | ||
| 259 | self.scroll = self.scroll.saturating_sub(20); | ||
| 260 | KeyAction::Continue | ||
| 261 | } | ||
| 262 | KeyCode::Tab | KeyCode::Enter => { | ||
| 263 | self.pane = match self.pane { | ||
| 264 | Pane::ItemList => Pane::Detail, | ||
| 265 | Pane::Detail => Pane::ItemList, | ||
| 266 | }; | ||
| 267 | KeyAction::Continue | ||
| 268 | } | ||
| 269 | KeyCode::Char('a') => { | ||
| 270 | self.status_filter = self.status_filter.next(); | ||
| 271 | let count = self.visible_count(); | ||
| 272 | self.list_state | ||
| 273 | .select(if count > 0 { Some(0) } else { None }); | ||
| 274 | KeyAction::Continue | ||
| 275 | } | ||
| 276 | KeyCode::Char('r') => KeyAction::Reload, | ||
| 277 | _ => KeyAction::Continue, | ||
| 278 | } | ||
| 279 | } | ||
| 280 | |||
| 281 | fn selected_item_id(&self) -> Option<String> { | ||
| 282 | let idx = self.list_state.selected()?; | ||
| 283 | let visible = self.visible_issues(); | ||
| 284 | visible.get(idx).map(|i| i.id.clone()) | ||
| 285 | } | ||
| 286 | |||
| 287 | fn selected_ref_name(&self) -> Option<String> { | ||
| 288 | let id = self.selected_item_id()?; | ||
| 289 | Some(format!("refs/collab/issues/{}", id)) | ||
| 290 | } | ||
| 291 | |||
| 292 | fn reload(&mut self, repo: &Repository) { | ||
| 293 | if let Ok(issues) = state::list_issues(repo) { | ||
| 294 | self.issues = issues; | ||
| 295 | } | ||
| 296 | if let Ok(patches) = state::list_patches(repo) { | ||
| 297 | self.patches = patches; | ||
| 298 | } | ||
| 299 | let visible_len = self.visible_count(); | ||
| 300 | if let Some(sel) = self.list_state.selected() { | ||
| 301 | if sel >= visible_len { | ||
| 302 | self.list_state.select(if visible_len > 0 { | ||
| 303 | Some(visible_len - 1) | ||
| 304 | } else { | ||
| 305 | None | ||
| 306 | }); | ||
| 307 | } | ||
| 308 | } | ||
| 309 | } | ||
| 310 | } | ||
| 311 | |||
| 312 | fn action_type_label(action: &Action) -> &str { | ||
| 313 | match action { | ||
| 314 | Action::IssueOpen { .. } => "Issue Open", | ||
| 315 | Action::IssueComment { .. } => "Issue Comment", | ||
| 316 | Action::IssueClose { .. } => "Issue Close", | ||
| 317 | Action::IssueReopen => "Issue Reopen", | ||
| 318 | Action::PatchCreate { .. } => "Patch Create", | ||
| 319 | Action::PatchRevise { .. } => "Patch Revise", | ||
| 320 | Action::PatchReview { .. } => "Patch Review", | ||
| 321 | Action::PatchComment { .. } => "Patch Comment", | ||
| 322 | Action::PatchInlineComment { .. } => "Inline Comment", | ||
| 323 | Action::PatchClose { .. } => "Patch Close", | ||
| 324 | Action::PatchMerge => "Patch Merge", | ||
| 325 | Action::Merge => "Merge", | ||
| 326 | Action::IssueEdit { .. } => "Issue Edit", | ||
| 327 | Action::IssueLabel { .. } => "Issue Label", | ||
| 328 | Action::IssueUnlabel { .. } => "Issue Unlabel", | ||
| 329 | Action::IssueAssign { .. } => "Issue Assign", | ||
| 330 | Action::IssueUnassign { .. } => "Issue Unassign", | ||
| 331 | } | ||
| 332 | } | ||
| 333 | |||
| 334 | fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> String { | ||
| 335 | let short_oid = &oid.to_string()[..7]; | ||
| 336 | let action_label = action_type_label(&event.action); | ||
| 337 | |||
| 338 | let mut detail = format!( | ||
| 339 | "Commit: {}\nAuthor: {} <{}>\nDate: {}\nType: {}\n", | ||
| 340 | short_oid, event.author.name, event.author.email, event.timestamp, action_label, | ||
| 341 | ); | ||
| 342 | |||
| 343 | // Action-specific payload | ||
| 344 | match &event.action { | ||
| 345 | Action::IssueOpen { title, body } => { | ||
| 346 | detail.push_str(&format!("\nTitle: {}\n", title)); | ||
| 347 | if !body.is_empty() { | ||
| 348 | detail.push_str(&format!("\n{}\n", body)); | ||
| 349 | } | ||
| 350 | } | ||
| 351 | Action::IssueComment { body } | Action::PatchComment { body } => { | ||
| 352 | detail.push_str(&format!("\n{}\n", body)); | ||
| 353 | } | ||
| 354 | Action::IssueClose { reason } | Action::PatchClose { reason } => { | ||
| 355 | if let Some(r) = reason { | ||
| 356 | detail.push_str(&format!("\nReason: {}\n", r)); | ||
| 357 | } | ||
| 358 | } | ||
| 359 | Action::PatchCreate { | ||
| 360 | title, | ||
| 361 | body, | ||
| 362 | base_ref, | ||
| 363 | branch, | ||
| 364 | .. | ||
| 365 | } => { | ||
| 366 | detail.push_str(&format!("\nTitle: {}\n", title)); | ||
| 367 | detail.push_str(&format!("Base: {}\n", base_ref)); | ||
| 368 | detail.push_str(&format!("Branch: {}\n", branch)); | ||
| 369 | if !body.is_empty() { | ||
| 370 | detail.push_str(&format!("\n{}\n", body)); | ||
| 371 | } | ||
| 372 | } | ||
| 373 | Action::PatchRevise { body } => { | ||
| 374 | if let Some(b) = body { | ||
| 375 | if !b.is_empty() { | ||
| 376 | detail.push_str(&format!("\n{}\n", b)); | ||
| 377 | } | ||
| 378 | } | ||
| 379 | } | ||
| 380 | Action::PatchReview { verdict, body } => { | ||
| 381 | detail.push_str(&format!("\nVerdict: {:?}\n", verdict)); | ||
| 382 | if !body.is_empty() { | ||
| 383 | detail.push_str(&format!("\n{}\n", body)); | ||
| 384 | } | ||
| 385 | } | ||
| 386 | Action::PatchInlineComment { file, line, body } => { | ||
| 387 | detail.push_str(&format!("\nFile: {}:{}\n", file, line)); | ||
| 388 | if !body.is_empty() { | ||
| 389 | detail.push_str(&format!("\n{}\n", body)); | ||
| 390 | } | ||
| 391 | } | ||
| 392 | Action::IssueEdit { title, body } => { | ||
| 393 | if let Some(t) = title { | ||
| 394 | detail.push_str(&format!("\nNew Title: {}\n", t)); | ||
| 395 | } | ||
| 396 | if let Some(b) = body { | ||
| 397 | if !b.is_empty() { | ||
| 398 | detail.push_str(&format!("\nNew Body: {}\n", b)); | ||
| 399 | } | ||
| 400 | } | ||
| 401 | } | ||
| 402 | Action::IssueLabel { label } => { | ||
| 403 | detail.push_str(&format!("\nLabel: {}\n", label)); | ||
| 404 | } | ||
| 405 | Action::IssueUnlabel { label } => { | ||
| 406 | detail.push_str(&format!("\nRemoved Label: {}\n", label)); | ||
| 407 | } | ||
| 408 | Action::IssueAssign { assignee } => { | ||
| 409 | detail.push_str(&format!("\nAssignee: {}\n", assignee)); | ||
| 410 | } | ||
| 411 | Action::IssueUnassign { assignee } => { | ||
| 412 | detail.push_str(&format!("\nRemoved Assignee: {}\n", assignee)); | ||
| 413 | } | ||
| 414 | Action::IssueReopen | Action::PatchMerge | Action::Merge => {} | ||
| 415 | } | ||
| 416 | |||
| 417 | detail | ||
| 418 | } | ||
| 419 | |||
| 420 | pub fn run(repo: &Repository) -> Result<(), Error> { | ||
| 421 | let issues = state::list_issues(repo)?; | ||
| 422 | let patches = state::list_patches(repo)?; | ||
| 423 | |||
| 424 | let mut app = App::new(issues, patches); | ||
| 425 | |||
| 426 | terminal::enable_raw_mode()?; | ||
| 427 | stdout().execute(EnterAlternateScreen)?; | ||
| 428 | let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; | ||
| 429 | |||
| 430 | let result = run_loop(&mut terminal, &mut app, repo); | ||
| 431 | |||
| 432 | terminal::disable_raw_mode()?; | ||
| 433 | stdout().execute(LeaveAlternateScreen)?; | ||
| 434 | |||
| 435 | result | ||
| 436 | } | ||
| 437 | |||
| 438 | fn run_loop( | ||
| 439 | terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, | ||
| 440 | app: &mut App, | ||
| 441 | repo: &Repository, | ||
| 442 | ) -> Result<(), Error> { | ||
| 443 | loop { | ||
| 444 | terminal.draw(|frame| ui(frame, app))?; | ||
| 445 | |||
| 446 | if event::poll(Duration::from_millis(100))? { | ||
| 447 | if let Event::Key(key) = event::read()? { | ||
| 448 | app.status_msg = None; // clear status on any keypress | ||
| 449 | |||
| 450 | // Input mode intercept — handle keys before normal bindings | ||
| 451 | match app.input_mode { | ||
| 452 | InputMode::Search => { | ||
| 453 | match key.code { | ||
| 454 | KeyCode::Esc => { | ||
| 455 | app.input_mode = InputMode::Normal; | ||
| 456 | app.search_query.clear(); | ||
| 457 | let count = app.visible_count(); | ||
| 458 | app.list_state | ||
| 459 | .select(if count > 0 { Some(0) } else { None }); | ||
| 460 | } | ||
| 461 | KeyCode::Backspace => { | ||
| 462 | app.search_query.pop(); | ||
| 463 | let count = app.visible_count(); | ||
| 464 | app.list_state | ||
| 465 | .select(if count > 0 { Some(0) } else { None }); | ||
| 466 | } | ||
| 467 | KeyCode::Char(c) => { | ||
| 468 | app.search_query.push(c); | ||
| 469 | let count = app.visible_count(); | ||
| 470 | app.list_state | ||
| 471 | .select(if count > 0 { Some(0) } else { None }); | ||
| 472 | } | ||
| 473 | _ => {} | ||
| 474 | } | ||
| 475 | continue; | ||
| 476 | } | ||
| 477 | InputMode::CreateTitle => { | ||
| 478 | match key.code { | ||
| 479 | KeyCode::Esc => { | ||
| 480 | app.input_mode = InputMode::Normal; | ||
| 481 | app.input_buf.clear(); | ||
| 482 | } | ||
| 483 | KeyCode::Enter => { | ||
| 484 | let title = app.input_buf.trim().to_string(); | ||
| 485 | if title.is_empty() { | ||
| 486 | app.input_mode = InputMode::Normal; | ||
| 487 | app.input_buf.clear(); | ||
| 488 | } else { | ||
| 489 | app.create_title = title; | ||
| 490 | app.input_buf.clear(); | ||
| 491 | app.input_mode = InputMode::CreateBody; | ||
| 492 | } | ||
| 493 | } | ||
| 494 | KeyCode::Backspace => { | ||
| 495 | app.input_buf.pop(); | ||
| 496 | } | ||
| 497 | KeyCode::Char(c) => { | ||
| 498 | app.input_buf.push(c); | ||
| 499 | } | ||
| 500 | _ => {} | ||
| 501 | } | ||
| 502 | continue; | ||
| 503 | } | ||
| 504 | InputMode::CreateBody => { | ||
| 505 | match key.code { | ||
| 506 | KeyCode::Esc => { | ||
| 507 | // Submit with title only, no body | ||
| 508 | let title = app.create_title.clone(); | ||
| 509 | match issue_mod::open(repo, &title, "") { | ||
| 510 | Ok(id) => { | ||
| 511 | app.reload(repo); | ||
| 512 | app.status_msg = | ||
| 513 | Some(format!("Issue created: {:.8}", id)); | ||
| 514 | } | ||
| 515 | Err(e) => { | ||
| 516 | app.status_msg = | ||
| 517 | Some(format!("Error creating issue: {}", e)); | ||
| 518 | } | ||
| 519 | } | ||
| 520 | app.input_mode = InputMode::Normal; | ||
| 521 | app.input_buf.clear(); | ||
| 522 | app.create_title.clear(); | ||
| 523 | } | ||
| 524 | KeyCode::Enter => { | ||
| 525 | let title = app.create_title.clone(); | ||
| 526 | let body = app.input_buf.clone(); | ||
| 527 | match issue_mod::open(repo, &title, &body) { | ||
| 528 | Ok(id) => { | ||
| 529 | app.reload(repo); | ||
| 530 | app.status_msg = | ||
| 531 | Some(format!("Issue created: {:.8}", id)); | ||
| 532 | } | ||
| 533 | Err(e) => { | ||
| 534 | app.status_msg = | ||
| 535 | Some(format!("Error creating issue: {}", e)); | ||
| 536 | } | ||
| 537 | } | ||
| 538 | app.input_mode = InputMode::Normal; | ||
| 539 | app.input_buf.clear(); | ||
| 540 | app.create_title.clear(); | ||
| 541 | } | ||
| 542 | KeyCode::Backspace => { | ||
| 543 | app.input_buf.pop(); | ||
| 544 | } | ||
| 545 | KeyCode::Char(c) => { | ||
| 546 | app.input_buf.push(c); | ||
| 547 | } | ||
| 548 | _ => {} | ||
| 549 | } | ||
| 550 | continue; | ||
| 551 | } | ||
| 552 | InputMode::Normal => {} | ||
| 553 | } | ||
| 554 | |||
| 555 | // Handle keys that need repo access or are run_loop-specific | ||
| 556 | // before delegating to handle_key | ||
| 557 | if app.mode == ViewMode::Details { | ||
| 558 | match key.code { | ||
| 559 | KeyCode::Char('/') => { | ||
| 560 | app.input_mode = InputMode::Search; | ||
| 561 | app.search_query.clear(); | ||
| 562 | continue; | ||
| 563 | } | ||
| 564 | KeyCode::Char('n') => { | ||
| 565 | app.input_mode = InputMode::CreateTitle; | ||
| 566 | app.input_buf.clear(); | ||
| 567 | app.create_title.clear(); | ||
| 568 | continue; | ||
| 569 | } | ||
| 570 | KeyCode::Char('o') => { | ||
| 571 | // Check out the relevant commit for local browsing | ||
| 572 | let checkout_target = { | ||
| 573 | let visible = app.visible_issues(); | ||
| 574 | app.list_state | ||
| 575 | .selected() | ||
| 576 | .and_then(|idx| visible.get(idx)) | ||
| 577 | .and_then(|issue| { | ||
| 578 | // Try linked patch first | ||
| 579 | app.patches | ||
| 580 | .iter() | ||
| 581 | .find(|p| p.fixes.as_deref() == Some(&issue.id)) | ||
| 582 | .map(|p| p.branch.clone()) | ||
| 583 | // Fall back to closing commit | ||
| 584 | .or_else(|| { | ||
| 585 | issue.closed_by.map(|oid| oid.to_string()) | ||
| 586 | }) | ||
| 587 | }) | ||
| 588 | }; | ||
| 589 | if let Some(head) = checkout_target { | ||
| 590 | // Exit TUI, checkout, and return | ||
| 591 | terminal::disable_raw_mode()?; | ||
| 592 | stdout().execute(LeaveAlternateScreen)?; | ||
| 593 | let status = std::process::Command::new("git") | ||
| 594 | .args(["checkout", &head]) | ||
| 595 | .status(); | ||
| 596 | match status { | ||
| 597 | Ok(s) if s.success() => { | ||
| 598 | println!("Checked out commit: {:.8}", head); | ||
| 599 | println!("Use 'git checkout -' to return."); | ||
| 600 | } | ||
| 601 | Ok(s) => { | ||
| 602 | eprintln!("git checkout exited with {}", s); | ||
| 603 | } | ||
| 604 | Err(e) => { | ||
| 605 | eprintln!("Failed to run git checkout: {}", e); | ||
| 606 | } | ||
| 607 | } | ||
| 608 | return Ok(()); | ||
| 609 | } else { | ||
| 610 | app.status_msg = | ||
| 611 | Some("No linked patch to check out".to_string()); | ||
| 612 | } | ||
| 613 | continue; | ||
| 614 | } | ||
| 615 | _ => {} | ||
| 616 | } | ||
| 617 | } | ||
| 618 | |||
| 619 | match app.handle_key(key.code, key.modifiers) { | ||
| 620 | KeyAction::Quit => return Ok(()), | ||
| 621 | KeyAction::Reload => app.reload(repo), | ||
| 622 | KeyAction::OpenCommitBrowser => { | ||
| 623 | if let Some(ref_name) = app.selected_ref_name() { | ||
| 624 | match crate::dag::walk_events(repo, &ref_name) { | ||
| 625 | Ok(events) => { | ||
| 626 | app.event_history = events; | ||
| 627 | app.event_list_state = ListState::default(); | ||
| 628 | if !app.event_history.is_empty() { | ||
| 629 | app.event_list_state.select(Some(0)); | ||
| 630 | } | ||
| 631 | app.mode = ViewMode::CommitList; | ||
| 632 | app.scroll = 0; | ||
| 633 | } | ||
| 634 | Err(e) => { | ||
| 635 | app.status_msg = | ||
| 636 | Some(format!("Error loading events: {}", e)); | ||
| 637 | } | ||
| 638 | } | ||
| 639 | } | ||
| 640 | } | ||
| 641 | KeyAction::Continue => {} | ||
| 642 | } | ||
| 643 | } | ||
| 644 | } | ||
| 645 | } | ||
| 646 | } | ||
| 647 | |||
| 648 | fn ui(frame: &mut Frame, app: &mut App) { | ||
| 649 | let chunks = Layout::default() | ||
| 650 | .direction(Direction::Vertical) | ||
| 651 | .constraints([ | ||
| 652 | Constraint::Min(1), | ||
| 653 | Constraint::Length(1), | ||
| 654 | ]) | ||
| 655 | .split(frame.area()); | ||
| 656 | |||
| 657 | let main_area = chunks[0]; | ||
| 658 | let footer_area = chunks[1]; | ||
| 659 | |||
| 660 | let panes = Layout::default() | ||
| 661 | .direction(Direction::Horizontal) | ||
| 662 | .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) | ||
| 663 | .split(main_area); | ||
| 664 | |||
| 665 | render_list(frame, app, panes[0]); | ||
| 666 | render_detail(frame, app, panes[1]); | ||
| 667 | render_footer(frame, app, footer_area); | ||
| 668 | } | ||
| 669 | |||
| 670 | fn render_list(frame: &mut Frame, app: &mut App, area: Rect) { | ||
| 671 | let border_style = if app.pane == Pane::ItemList { | ||
| 672 | Style::default().fg(Color::Yellow) | ||
| 673 | } else { | ||
| 674 | Style::default().fg(Color::DarkGray) | ||
| 675 | }; | ||
| 676 | |||
| 677 | let visible = app.visible_issues(); | ||
| 678 | let items: Vec<ListItem> = visible | ||
| 679 | .iter() | ||
| 680 | .map(|i| { | ||
| 681 | let status = match i.status { | ||
| 682 | IssueStatus::Open => "open", | ||
| 683 | IssueStatus::Closed => "closed", | ||
| 684 | }; | ||
| 685 | let style = match i.status { | ||
| 686 | IssueStatus::Open => Style::default().fg(Color::Green), | ||
| 687 | IssueStatus::Closed => Style::default().fg(Color::Red), | ||
| 688 | }; | ||
| 689 | ListItem::new(format!("{:.8} {:6} {}", i.id, status, i.title)).style(style) | ||
| 690 | }) | ||
| 691 | .collect(); | ||
| 692 | |||
| 693 | let title = format!("Issues ({})", app.status_filter.label()); | ||
| 694 | |||
| 695 | let list = List::new(items) | ||
| 696 | .block( | ||
| 697 | Block::default() | ||
| 698 | .borders(Borders::ALL) | ||
| 699 | .title(title) | ||
| 700 | .border_style(border_style), | ||
| 701 | ) | ||
| 702 | .highlight_style( | ||
| 703 | Style::default() | ||
| 704 | .bg(Color::DarkGray) | ||
| 705 | .add_modifier(Modifier::BOLD), | ||
| 706 | ) | ||
| 707 | .highlight_symbol("> "); | ||
| 708 | |||
| 709 | frame.render_stateful_widget(list, area, &mut app.list_state); | ||
| 710 | } | ||
| 711 | |||
| 712 | fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { | ||
| 713 | let border_style = if app.pane == Pane::Detail { | ||
| 714 | Style::default().fg(Color::Yellow) | ||
| 715 | } else { | ||
| 716 | Style::default().fg(Color::DarkGray) | ||
| 717 | }; | ||
| 718 | |||
| 719 | // Handle commit browser modes | ||
| 720 | if app.mode == ViewMode::CommitList { | ||
| 721 | let items: Vec<ListItem> = app | ||
| 722 | .event_history | ||
| 723 | .iter() | ||
| 724 | .map(|(_oid, evt)| { | ||
| 725 | let label = action_type_label(&evt.action); | ||
| 726 | ListItem::new(format!( | ||
| 727 | "{} | {} | {}", | ||
| 728 | label, evt.author.name, evt.timestamp | ||
| 729 | )) | ||
| 730 | }) | ||
| 731 | .collect(); | ||
| 732 | |||
| 733 | let list = List::new(items) | ||
| 734 | .block( | ||
| 735 | Block::default() | ||
| 736 | .borders(Borders::ALL) | ||
| 737 | .title("Event History") | ||
| 738 | .border_style(border_style), | ||
| 739 | ) | ||
| 740 | .highlight_style( | ||
| 741 | Style::default() | ||
| 742 | .bg(Color::DarkGray) | ||
| 743 | .add_modifier(Modifier::BOLD), | ||
| 744 | ) | ||
| 745 | .highlight_symbol("> "); | ||
| 746 | |||
| 747 | frame.render_stateful_widget(list, area, &mut app.event_list_state); | ||
| 748 | return; | ||
| 749 | } | ||
| 750 | |||
| 751 | if app.mode == ViewMode::CommitDetail { | ||
| 752 | let content = if let Some(idx) = app.event_list_state.selected() { | ||
| 753 | if let Some((oid, evt)) = app.event_history.get(idx) { | ||
| 754 | format_event_detail(oid, evt) | ||
| 755 | } else { | ||
| 756 | "No event selected.".to_string() | ||
| 757 | } | ||
| 758 | } else { | ||
| 759 | "No event selected.".to_string() | ||
| 760 | }; | ||
| 761 | |||
| 762 | let block = Block::default() | ||
| 763 | .borders(Borders::ALL) | ||
| 764 | .title("Event Detail") | ||
| 765 | .border_style(border_style); | ||
| 766 | |||
| 767 | let para = Paragraph::new(content) | ||
| 768 | .block(block) | ||
| 769 | .wrap(Wrap { trim: false }) | ||
| 770 | .scroll((app.scroll, 0)); | ||
| 771 | |||
| 772 | frame.render_widget(para, area); | ||
| 773 | return; | ||
| 774 | } | ||
| 775 | |||
| 776 | let visible = app.visible_issues(); | ||
| 777 | let selected_idx = app.list_state.selected().unwrap_or(0); | ||
| 778 | let content: Text = match visible.get(selected_idx) { | ||
| 779 | Some(issue) => build_issue_detail(issue, &app.patches), | ||
| 780 | None => Text::raw("No matches for current filter."), | ||
| 781 | }; | ||
| 782 | |||
| 783 | let block = Block::default() | ||
| 784 | .borders(Borders::ALL) | ||
| 785 | .title("Issue Details") | ||
| 786 | .border_style(border_style); | ||
| 787 | |||
| 788 | let para = Paragraph::new(content) | ||
| 789 | .block(block) | ||
| 790 | .wrap(Wrap { trim: false }) | ||
| 791 | .scroll((app.scroll, 0)); | ||
| 792 | |||
| 793 | frame.render_widget(para, area); | ||
| 794 | } | ||
| 795 | |||
| 796 | fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'static> { | ||
| 797 | let status = match issue.status { | ||
| 798 | IssueStatus::Open => "open", | ||
| 799 | IssueStatus::Closed => "closed", | ||
| 800 | }; | ||
| 801 | |||
| 802 | let mut lines: Vec<Line> = vec![ | ||
| 803 | Line::from(vec![ | ||
| 804 | Span::styled("Issue ", Style::default().add_modifier(Modifier::BOLD)), | ||
| 805 | Span::styled( | ||
| 806 | format!("{:.8}", issue.id), | ||
| 807 | Style::default() | ||
| 808 | .fg(Color::Yellow) | ||
| 809 | .add_modifier(Modifier::BOLD), | ||
| 810 | ), | ||
| 811 | Span::raw(format!(" [{}]", status)), | ||
| 812 | ]), | ||
| 813 | Line::from(vec![ | ||
| 814 | Span::styled("Title: ", Style::default().fg(Color::DarkGray)), | ||
| 815 | Span::raw(issue.title.clone()), | ||
| 816 | ]), | ||
| 817 | Line::from(vec![ | ||
| 818 | Span::styled("Author: ", Style::default().fg(Color::DarkGray)), | ||
| 819 | Span::raw(format!("{} <{}>", issue.author.name, issue.author.email)), | ||
| 820 | ]), | ||
| 821 | Line::from(vec![ | ||
| 822 | Span::styled("Created: ", Style::default().fg(Color::DarkGray)), | ||
| 823 | Span::raw(issue.created_at.clone()), | ||
| 824 | ]), | ||
| 825 | ]; | ||
| 826 | |||
| 827 | if !issue.labels.is_empty() { | ||
| 828 | lines.push(Line::from(vec![ | ||
| 829 | Span::styled("Labels: ", Style::default().fg(Color::DarkGray)), | ||
| 830 | Span::raw(issue.labels.join(", ")), | ||
| 831 | ])); | ||
| 832 | } | ||
| 833 | |||
| 834 | if !issue.assignees.is_empty() { | ||
| 835 | lines.push(Line::from(vec![ | ||
| 836 | Span::styled("Assign: ", Style::default().fg(Color::DarkGray)), | ||
| 837 | Span::raw(issue.assignees.join(", ")), | ||
| 838 | ])); | ||
| 839 | } | ||
| 840 | |||
| 841 | if let Some(ref reason) = issue.close_reason { | ||
| 842 | lines.push(Line::from(vec![ | ||
| 843 | Span::styled("Closed: ", Style::default().fg(Color::Red)), | ||
| 844 | Span::raw(reason.clone()), | ||
| 845 | ])); | ||
| 846 | } | ||
| 847 | |||
| 848 | if let Some(ref oid) = issue.closed_by { | ||
| 849 | lines.push(Line::from(vec![ | ||
| 850 | Span::styled("Commit: ", Style::default().fg(Color::DarkGray)), | ||
| 851 | Span::styled( | ||
| 852 | format!("{:.8}", oid), | ||
| 853 | Style::default().fg(Color::Cyan), | ||
| 854 | ), | ||
| 855 | ])); | ||
| 856 | } | ||
| 857 | |||
| 858 | // Show patches that reference this issue via --fixes | ||
| 859 | let fixing_patches: Vec<&PatchState> = patches | ||
| 860 | .iter() | ||
| 861 | .filter(|p| p.fixes.as_deref() == Some(&issue.id)) | ||
| 862 | .collect(); | ||
| 863 | if !fixing_patches.is_empty() { | ||
| 864 | lines.push(Line::raw("")); | ||
| 865 | lines.push(Line::styled( | ||
| 866 | "--- Linked Patches ---", | ||
| 867 | Style::default() | ||
| 868 | .fg(Color::Magenta) | ||
| 869 | .add_modifier(Modifier::BOLD), | ||
| 870 | )); | ||
| 871 | for p in &fixing_patches { | ||
| 872 | let status = match p.status { | ||
| 873 | PatchStatus::Open => ("open", Color::Green), | ||
| 874 | PatchStatus::Closed => ("closed", Color::Red), | ||
| 875 | PatchStatus::Merged => ("merged", Color::Cyan), | ||
| 876 | }; | ||
| 877 | lines.push(Line::from(vec![ | ||
| 878 | Span::styled( | ||
| 879 | format!("{:.8}", p.id), | ||
| 880 | Style::default().fg(Color::Yellow), | ||
| 881 | ), | ||
| 882 | Span::raw(" "), | ||
| 883 | Span::styled(status.0, Style::default().fg(status.1)), | ||
| 884 | Span::raw(format!(" {}", p.title)), | ||
| 885 | ])); | ||
| 886 | } | ||
| 887 | } | ||
| 888 | |||
| 889 | if !issue.body.is_empty() { | ||
| 890 | lines.push(Line::raw("")); | ||
| 891 | for l in issue.body.lines() { | ||
| 892 | lines.push(Line::raw(l.to_string())); | ||
| 893 | } | ||
| 894 | } | ||
| 895 | |||
| 896 | if !issue.comments.is_empty() { | ||
| 897 | lines.push(Line::raw("")); | ||
| 898 | lines.push(Line::styled( | ||
| 899 | "--- Comments ---", | ||
| 900 | Style::default() | ||
| 901 | .fg(Color::Blue) | ||
| 902 | .add_modifier(Modifier::BOLD), | ||
| 903 | )); | ||
| 904 | for c in &issue.comments { | ||
| 905 | lines.push(Line::raw("")); | ||
| 906 | lines.push(Line::from(vec![ | ||
| 907 | Span::styled( | ||
| 908 | c.author.name.clone(), | ||
| 909 | Style::default().add_modifier(Modifier::BOLD), | ||
| 910 | ), | ||
| 911 | Span::styled( | ||
| 912 | format!(" ({})", c.timestamp), | ||
| 913 | Style::default().fg(Color::DarkGray), | ||
| 914 | ), | ||
| 915 | ])); | ||
| 916 | for l in c.body.lines() { | ||
| 917 | lines.push(Line::raw(format!(" {}", l))); | ||
| 918 | } | ||
| 919 | } | ||
| 920 | } | ||
| 921 | |||
| 922 | Text::from(lines) | ||
| 923 | } | ||
| 924 | |||
| 925 | fn render_footer(frame: &mut Frame, app: &App, area: Rect) { | ||
| 926 | match app.input_mode { | ||
| 927 | InputMode::Search => { | ||
| 928 | let max_query_len = (area.width as usize).saturating_sub(12); | ||
| 929 | let display_query = if app.search_query.len() > max_query_len { | ||
| 930 | &app.search_query[app.search_query.len() - max_query_len..] | ||
| 931 | } else { | ||
| 932 | &app.search_query | ||
| 933 | }; | ||
| 934 | let text = format!(" Search: {}_", display_query); | ||
| 935 | let style = Style::default().bg(Color::Blue).fg(Color::White); | ||
| 936 | let para = Paragraph::new(text).style(style); | ||
| 937 | frame.render_widget(para, area); | ||
| 938 | return; | ||
| 939 | } | ||
| 940 | InputMode::CreateTitle => { | ||
| 941 | let max_len = (area.width as usize).saturating_sub(22); | ||
| 942 | let display = if app.input_buf.len() > max_len { | ||
| 943 | &app.input_buf[app.input_buf.len() - max_len..] | ||
| 944 | } else { | ||
| 945 | &app.input_buf | ||
| 946 | }; | ||
| 947 | let text = format!(" New issue - Title: {}_", display); | ||
| 948 | let style = Style::default().bg(Color::Green).fg(Color::Black); | ||
| 949 | let para = Paragraph::new(text).style(style); | ||
| 950 | frame.render_widget(para, area); | ||
| 951 | return; | ||
| 952 | } | ||
| 953 | InputMode::CreateBody => { | ||
| 954 | let max_len = (area.width as usize).saturating_sub(21); | ||
| 955 | let display = if app.input_buf.len() > max_len { | ||
| 956 | &app.input_buf[app.input_buf.len() - max_len..] | ||
| 957 | } else { | ||
| 958 | &app.input_buf | ||
| 959 | }; | ||
| 960 | let text = format!(" New issue - Body: {}_ (Esc: skip)", display); | ||
| 961 | let style = Style::default().bg(Color::Green).fg(Color::Black); | ||
| 962 | let para = Paragraph::new(text).style(style); | ||
| 963 | frame.render_widget(para, area); | ||
| 964 | return; | ||
| 965 | } | ||
| 966 | InputMode::Normal => {} | ||
| 967 | } | ||
| 968 | |||
| 969 | // Show status message if present | ||
| 970 | if let Some(ref msg) = app.status_msg { | ||
| 971 | let para = | ||
| 972 | Paragraph::new(format!(" {}", msg)).style(Style::default().bg(Color::Yellow).fg(Color::Black)); | ||
| 973 | frame.render_widget(para, area); | ||
| 974 | return; | ||
| 975 | } | ||
| 976 | |||
| 977 | let mode_hint = match app.mode { | ||
| 978 | ViewMode::CommitList => " Esc:back", | ||
| 979 | ViewMode::CommitDetail => " Esc:back j/k:scroll", | ||
| 980 | ViewMode::Details => " c:events", | ||
| 981 | }; | ||
| 982 | let filter_hint = match app.status_filter { | ||
| 983 | StatusFilter::Open => "a:show all", | ||
| 984 | StatusFilter::All => "a:closed", | ||
| 985 | StatusFilter::Closed => "a:open only", | ||
| 986 | }; | ||
| 987 | let text = format!( | ||
| 988 | " j/k:navigate Tab:pane {}{} /:search n:new issue o:checkout r:refresh q:quit", | ||
| 989 | filter_hint, mode_hint | ||
| 990 | ); | ||
| 991 | let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White)); | ||
| 992 | frame.render_widget(para, area); | ||
| 993 | } | ||
| 994 | |||
| 995 | #[cfg(test)] | ||
| 996 | mod tests { | ||
| 997 | use super::*; | ||
| 998 | use crate::event::Author; | ||
| 999 | |||
| 1000 | fn make_author() -> Author { | ||
| 1001 | Author { | ||
| 1002 | name: "test".into(), | ||
| 1003 | email: "test@test.com".into(), | ||
| 1004 | } | ||
| 1005 | } | ||
| 1006 | |||
| 1007 | fn make_issue(id: &str, title: &str, status: IssueStatus) -> IssueState { | ||
| 1008 | IssueState { | ||
| 1009 | id: id.into(), | ||
| 1010 | title: title.into(), | ||
| 1011 | body: String::new(), | ||
| 1012 | status, | ||
| 1013 | close_reason: None, | ||
| 1014 | closed_by: None, | ||
| 1015 | labels: vec![], | ||
| 1016 | assignees: vec![], | ||
| 1017 | comments: vec![], | ||
| 1018 | created_at: String::new(), | ||
| 1019 | author: make_author(), | ||
| 1020 | } | ||
| 1021 | } | ||
| 1022 | |||
| 1023 | fn make_patch(id: &str, title: &str, status: PatchStatus) -> PatchState { | ||
| 1024 | PatchState { | ||
| 1025 | id: id.into(), | ||
| 1026 | title: title.into(), | ||
| 1027 | body: String::new(), | ||
| 1028 | status, | ||
| 1029 | base_ref: "main".into(), | ||
| 1030 | fixes: None, | ||
| 1031 | branch: format!("feature/{}", id), | ||
| 1032 | comments: vec![], | ||
| 1033 | inline_comments: vec![], | ||
| 1034 | reviews: vec![], | ||
| 1035 | created_at: String::new(), | ||
| 1036 | author: make_author(), | ||
| 1037 | } | ||
| 1038 | } | ||
| 1039 | |||
| 1040 | fn test_app() -> App { | ||
| 1041 | let issues = vec![ | ||
| 1042 | make_issue("i1", "Fix login bug", IssueStatus::Open), | ||
| 1043 | make_issue("i2", "Add dashboard feature", IssueStatus::Open), | ||
| 1044 | make_issue("i3", "Old closed issue", IssueStatus::Closed), | ||
| 1045 | ]; | ||
| 1046 | let patches = vec![ | ||
| 1047 | make_patch("p1", "Login fix patch", PatchStatus::Open), | ||
| 1048 | make_patch("p2", "Dashboard patch", PatchStatus::Closed), | ||
| 1049 | make_patch("p3", "Merged feature", PatchStatus::Merged), | ||
| 1050 | ]; | ||
| 1051 | App::new(issues, patches) | ||
| 1052 | } | ||
| 1053 | |||
| 1054 | // T010: visible_issues filters by search_query (case-insensitive) | ||
| 1055 | #[test] | ||
| 1056 | fn test_visible_issues_text_filter() { | ||
| 1057 | let mut app = test_app(); | ||
| 1058 | app.status_filter = StatusFilter::All; | ||
| 1059 | app.search_query = "login".into(); | ||
| 1060 | let visible = app.visible_issues(); | ||
| 1061 | assert_eq!(visible.len(), 1); | ||
| 1062 | assert_eq!(visible[0].title, "Fix login bug"); | ||
| 1063 | } | ||
| 1064 | |||
| 1065 | // T012: visible_issues returns all status-matching items when search_query is empty | ||
| 1066 | #[test] | ||
| 1067 | fn test_visible_issues_no_text_filter() { | ||
| 1068 | let mut app = test_app(); | ||
| 1069 | app.status_filter = StatusFilter::All; | ||
| 1070 | app.search_query.clear(); | ||
| 1071 | let visible = app.visible_issues(); | ||
| 1072 | assert_eq!(visible.len(), 3); | ||
| 1073 | } | ||
| 1074 | |||
| 1075 | // T019: StatusFilter::next() cycles Open → All → Closed → Open | ||
| 1076 | #[test] | ||
| 1077 | fn test_status_filter_cycle() { | ||
| 1078 | assert_eq!(StatusFilter::Open.next(), StatusFilter::All); | ||
| 1079 | assert_eq!(StatusFilter::All.next(), StatusFilter::Closed); | ||
| 1080 | assert_eq!(StatusFilter::Closed.next(), StatusFilter::Open); | ||
| 1081 | } | ||
| 1082 | |||
| 1083 | // T020: visible_issues returns only closed when status_filter is Closed | ||
| 1084 | #[test] | ||
| 1085 | fn test_visible_issues_closed_filter() { | ||
| 1086 | let mut app = test_app(); | ||
| 1087 | app.status_filter = StatusFilter::Closed; | ||
| 1088 | let visible = app.visible_issues(); | ||
| 1089 | assert_eq!(visible.len(), 1); | ||
| 1090 | assert_eq!(visible[0].title, "Old closed issue"); | ||
| 1091 | } | ||
| 1092 | |||
| 1093 | // T025: combined status + text filter | ||
| 1094 | #[test] | ||
| 1095 | fn test_combined_filters() { | ||
| 1096 | let mut app = test_app(); | ||
| 1097 | app.status_filter = StatusFilter::Open; | ||
| 1098 | app.search_query = "login".into(); | ||
| 1099 | let visible = app.visible_issues(); | ||
| 1100 | assert_eq!(visible.len(), 1); | ||
| 1101 | assert_eq!(visible[0].title, "Fix login bug"); | ||
| 1102 | |||
| 1103 | // Same query with Closed filter should return nothing | ||
| 1104 | app.status_filter = StatusFilter::Closed; | ||
| 1105 | let visible = app.visible_issues(); | ||
| 1106 | assert_eq!(visible.len(), 0); | ||
| 1107 | } | ||
| 1108 | |||
| 1109 | // T026: Escape clears text filter but preserves status_filter | ||
| 1110 | #[test] | ||
| 1111 | fn test_escape_clears_text_preserves_status() { | ||
| 1112 | let mut app = test_app(); | ||
| 1113 | app.status_filter = StatusFilter::Closed; | ||
| 1114 | app.input_mode = InputMode::Search; | ||
| 1115 | app.search_query = "some query".into(); | ||
| 1116 | |||
| 1117 | // Simulate Escape | ||
| 1118 | app.input_mode = InputMode::Normal; | ||
| 1119 | app.search_query.clear(); | ||
| 1120 | |||
| 1121 | assert_eq!(app.status_filter, StatusFilter::Closed); | ||
| 1122 | assert_eq!(app.input_mode, InputMode::Normal); | ||
| 1123 | assert!(app.search_query.is_empty()); | ||
| 1124 | } | ||
| 1125 | |||
| 1126 | #[test] | ||
| 1127 | fn test_create_title_mode_clears_on_escape() { | ||
| 1128 | let mut app = test_app(); | ||
| 1129 | app.input_mode = InputMode::CreateTitle; | ||
| 1130 | app.input_buf = "partial title".into(); | ||
| 1131 | |||
| 1132 | // Simulate Escape | ||
| 1133 | app.input_mode = InputMode::Normal; | ||
| 1134 | app.input_buf.clear(); | ||
| 1135 | |||
| 1136 | assert_eq!(app.input_mode, InputMode::Normal); | ||
| 1137 | assert!(app.input_buf.is_empty()); | ||
| 1138 | } | ||
| 1139 | |||
| 1140 | #[test] | ||
| 1141 | fn test_create_title_transitions_to_body() { | ||
| 1142 | let mut app = test_app(); | ||
| 1143 | app.input_mode = InputMode::CreateTitle; | ||
| 1144 | app.input_buf = "My new issue".into(); | ||
| 1145 | |||
| 1146 | // Simulate Enter with non-empty title | ||
| 1147 | let title = app.input_buf.trim().to_string(); | ||
| 1148 | assert!(!title.is_empty()); | ||
| 1149 | app.create_title = title; | ||
| 1150 | app.input_buf.clear(); | ||
| 1151 | app.input_mode = InputMode::CreateBody; | ||
| 1152 | |||
| 1153 | assert_eq!(app.input_mode, InputMode::CreateBody); | ||
| 1154 | assert_eq!(app.create_title, "My new issue"); | ||
| 1155 | assert!(app.input_buf.is_empty()); | ||
| 1156 | } | ||
| 1157 | |||
| 1158 | #[test] | ||
| 1159 | fn test_empty_title_dismissed() { | ||
| 1160 | let mut app = test_app(); | ||
| 1161 | app.input_mode = InputMode::CreateTitle; | ||
| 1162 | app.input_buf = " ".into(); | ||
| 1163 | |||
| 1164 | // Simulate Enter with whitespace-only title | ||
| 1165 | let title = app.input_buf.trim().to_string(); | ||
| 1166 | if title.is_empty() { | ||
| 1167 | app.input_mode = InputMode::Normal; | ||
| 1168 | app.input_buf.clear(); | ||
| 1169 | } | ||
| 1170 | |||
| 1171 | assert_eq!(app.input_mode, InputMode::Normal); | ||
| 1172 | } | ||
| 1173 | |||
| 1174 | // ── Commit browser test helpers ───────────────────────────────────── | ||
| 1175 | |||
| 1176 | use crate::event::ReviewVerdict; | ||
| 1177 | use ratatui::backend::TestBackend; | ||
| 1178 | use ratatui::buffer::Buffer; | ||
| 1179 | |||
| 1180 | fn test_author() -> Author { | ||
| 1181 | Author { | ||
| 1182 | name: "Test User".to_string(), | ||
| 1183 | email: "test@example.com".to_string(), | ||
| 1184 | } | ||
| 1185 | } | ||
| 1186 | |||
| 1187 | fn make_test_issues(n: usize) -> Vec<IssueState> { | ||
| 1188 | (0..n) | ||
| 1189 | .map(|i| IssueState { | ||
| 1190 | id: format!("{:08x}", i), | ||
| 1191 | title: format!("Issue {}", i), | ||
| 1192 | body: format!("Body for issue {}", i), | ||
| 1193 | status: if i % 2 == 0 { | ||
| 1194 | IssueStatus::Open | ||
| 1195 | } else { | ||
| 1196 | IssueStatus::Closed | ||
| 1197 | }, | ||
| 1198 | close_reason: if i % 2 == 1 { | ||
| 1199 | Some("done".to_string()) | ||
| 1200 | } else { | ||
| 1201 | None | ||
| 1202 | }, | ||
| 1203 | closed_by: None, | ||
| 1204 | labels: vec![], | ||
| 1205 | assignees: vec![], | ||
| 1206 | comments: Vec::new(), | ||
| 1207 | created_at: "2026-01-01T00:00:00Z".to_string(), | ||
| 1208 | author: test_author(), | ||
| 1209 | }) | ||
| 1210 | .collect() | ||
| 1211 | } | ||
| 1212 | |||
| 1213 | fn make_test_patches(n: usize) -> Vec<PatchState> { | ||
| 1214 | (0..n) | ||
| 1215 | .map(|i| PatchState { | ||
| 1216 | id: format!("p{:07x}", i), | ||
| 1217 | title: format!("Patch {}", i), | ||
| 1218 | body: format!("Body for patch {}", i), | ||
| 1219 | status: if i % 2 == 0 { | ||
| 1220 | PatchStatus::Open | ||
| 1221 | } else { | ||
| 1222 | PatchStatus::Closed | ||
| 1223 | }, | ||
| 1224 | base_ref: "main".to_string(), | ||
| 1225 | fixes: None, | ||
| 1226 | branch: format!("feature/p{:07x}", i), | ||
| 1227 | comments: Vec::new(), | ||
| 1228 | inline_comments: Vec::new(), | ||
| 1229 | reviews: Vec::new(), | ||
| 1230 | created_at: "2026-01-01T00:00:00Z".to_string(), | ||
| 1231 | author: test_author(), | ||
| 1232 | }) | ||
| 1233 | .collect() | ||
| 1234 | } | ||
| 1235 | |||
| 1236 | fn make_app(issues: usize, patches: usize) -> App { | ||
| 1237 | App::new(make_test_issues(issues), make_test_patches(patches)) | ||
| 1238 | } | ||
| 1239 | |||
| 1240 | fn render_app(app: &mut App) -> Buffer { | ||
| 1241 | let backend = TestBackend::new(80, 24); | ||
| 1242 | let mut terminal = Terminal::new(backend).unwrap(); | ||
| 1243 | terminal.draw(|frame| ui(frame, app)).unwrap(); | ||
| 1244 | terminal.backend().buffer().clone() | ||
| 1245 | } | ||
| 1246 | |||
| 1247 | fn buffer_to_string(buf: &Buffer) -> String { | ||
| 1248 | let area = buf.area; | ||
| 1249 | let mut s = String::new(); | ||
| 1250 | for y in area.y..area.y + area.height { | ||
| 1251 | for x in area.x..area.x + area.width { | ||
| 1252 | let cell = buf.cell((x, y)).unwrap(); | ||
| 1253 | s.push_str(cell.symbol()); | ||
| 1254 | } | ||
| 1255 | s.push('\n'); | ||
| 1256 | } | ||
| 1257 | s | ||
| 1258 | } | ||
| 1259 | |||
| 1260 | fn assert_buffer_contains(buf: &Buffer, expected: &str) { | ||
| 1261 | let text = buffer_to_string(buf); | ||
| 1262 | assert!( | ||
| 1263 | text.contains(expected), | ||
| 1264 | "expected buffer to contain {:?}, but it was not found in:\n{}", | ||
| 1265 | expected, | ||
| 1266 | text | ||
| 1267 | ); | ||
| 1268 | } | ||
| 1269 | |||
| 1270 | /// Create sample event history for testing commit browser | ||
| 1271 | fn make_test_event_history() -> Vec<(Oid, crate::event::Event)> { | ||
| 1272 | let oid1 = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(); | ||
| 1273 | let oid2 = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(); | ||
| 1274 | let oid3 = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap(); | ||
| 1275 | |||
| 1276 | vec![ | ||
| 1277 | ( | ||
| 1278 | oid1, | ||
| 1279 | crate::event::Event { | ||
| 1280 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 1281 | author: test_author(), | ||
| 1282 | action: Action::IssueOpen { | ||
| 1283 | title: "Test Issue".to_string(), | ||
| 1284 | body: "This is the body".to_string(), | ||
| 1285 | }, | ||
| 1286 | clock: 0, | ||
| 1287 | }, | ||
| 1288 | ), | ||
| 1289 | ( | ||
| 1290 | oid2, | ||
| 1291 | crate::event::Event { | ||
| 1292 | timestamp: "2026-01-02T00:00:00Z".to_string(), | ||
| 1293 | author: Author { | ||
| 1294 | name: "Other User".to_string(), | ||
| 1295 | email: "other@example.com".to_string(), | ||
| 1296 | }, | ||
| 1297 | action: Action::IssueComment { | ||
| 1298 | body: "A comment on the issue".to_string(), | ||
| 1299 | }, | ||
| 1300 | clock: 0, | ||
| 1301 | }, | ||
| 1302 | ), | ||
| 1303 | ( | ||
| 1304 | oid3, | ||
| 1305 | crate::event::Event { | ||
| 1306 | timestamp: "2026-01-03T00:00:00Z".to_string(), | ||
| 1307 | author: test_author(), | ||
| 1308 | action: Action::IssueClose { | ||
| 1309 | reason: Some("fixed".to_string()), | ||
| 1310 | }, | ||
| 1311 | clock: 0, | ||
| 1312 | }, | ||
| 1313 | ), | ||
| 1314 | ] | ||
| 1315 | } | ||
| 1316 | |||
| 1317 | // ── action_type_label tests ────────────────────────────────────────── | ||
| 1318 | |||
| 1319 | #[test] | ||
| 1320 | fn test_action_type_label_issue_open() { | ||
| 1321 | let action = Action::IssueOpen { | ||
| 1322 | title: "t".to_string(), | ||
| 1323 | body: "b".to_string(), | ||
| 1324 | }; | ||
| 1325 | assert_eq!(action_type_label(&action), "Issue Open"); | ||
| 1326 | } | ||
| 1327 | |||
| 1328 | #[test] | ||
| 1329 | fn test_action_type_label_issue_comment() { | ||
| 1330 | let action = Action::IssueComment { | ||
| 1331 | body: "b".to_string(), | ||
| 1332 | }; | ||
| 1333 | assert_eq!(action_type_label(&action), "Issue Comment"); | ||
| 1334 | } | ||
| 1335 | |||
| 1336 | #[test] | ||
| 1337 | fn test_action_type_label_issue_close() { | ||
| 1338 | let action = Action::IssueClose { reason: None }; | ||
| 1339 | assert_eq!(action_type_label(&action), "Issue Close"); | ||
| 1340 | } | ||
| 1341 | |||
| 1342 | #[test] | ||
| 1343 | fn test_action_type_label_issue_reopen() { | ||
| 1344 | assert_eq!(action_type_label(&Action::IssueReopen), "Issue Reopen"); | ||
| 1345 | } | ||
| 1346 | |||
| 1347 | #[test] | ||
| 1348 | fn test_action_type_label_patch_create() { | ||
| 1349 | let action = Action::PatchCreate { | ||
| 1350 | title: "t".to_string(), | ||
| 1351 | body: "b".to_string(), | ||
| 1352 | base_ref: "main".to_string(), | ||
| 1353 | branch: "feature/test".to_string(), | ||
| 1354 | fixes: None, | ||
| 1355 | }; | ||
| 1356 | assert_eq!(action_type_label(&action), "Patch Create"); | ||
| 1357 | } | ||
| 1358 | |||
| 1359 | #[test] | ||
| 1360 | fn test_action_type_label_patch_review() { | ||
| 1361 | let action = Action::PatchReview { | ||
| 1362 | verdict: ReviewVerdict::Approve, | ||
| 1363 | body: "lgtm".to_string(), | ||
| 1364 | }; | ||
| 1365 | assert_eq!(action_type_label(&action), "Patch Review"); | ||
| 1366 | } | ||
| 1367 | |||
| 1368 | #[test] | ||
| 1369 | fn test_action_type_label_inline_comment() { | ||
| 1370 | let action = Action::PatchInlineComment { | ||
| 1371 | file: "src/main.rs".to_string(), | ||
| 1372 | line: 42, | ||
| 1373 | body: "nit".to_string(), | ||
| 1374 | }; | ||
| 1375 | assert_eq!(action_type_label(&action), "Inline Comment"); | ||
| 1376 | } | ||
| 1377 | |||
| 1378 | #[test] | ||
| 1379 | fn test_action_type_label_merge() { | ||
| 1380 | assert_eq!(action_type_label(&Action::Merge), "Merge"); | ||
| 1381 | } | ||
| 1382 | |||
| 1383 | // ── format_event_detail tests ──────────────────────────────────────── | ||
| 1384 | |||
| 1385 | #[test] | ||
| 1386 | fn test_format_event_detail_issue_open() { | ||
| 1387 | let oid = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(); | ||
| 1388 | let event = crate::event::Event { | ||
| 1389 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 1390 | author: test_author(), | ||
| 1391 | action: Action::IssueOpen { | ||
| 1392 | title: "My Issue".to_string(), | ||
| 1393 | body: "Description here".to_string(), | ||
| 1394 | }, | ||
| 1395 | clock: 0, | ||
| 1396 | }; | ||
| 1397 | let detail = format_event_detail(&oid, &event); | ||
| 1398 | assert!(detail.contains("aaaaaaa")); | ||
| 1399 | assert!(detail.contains("Test User <test@example.com>")); | ||
| 1400 | assert!(detail.contains("2026-01-01T00:00:00Z")); | ||
| 1401 | assert!(detail.contains("Issue Open")); | ||
| 1402 | assert!(detail.contains("Title: My Issue")); | ||
| 1403 | assert!(detail.contains("Description here")); | ||
| 1404 | } | ||
| 1405 | |||
| 1406 | #[test] | ||
| 1407 | fn test_format_event_detail_issue_close_with_reason() { | ||
| 1408 | let oid = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(); | ||
| 1409 | let event = crate::event::Event { | ||
| 1410 | timestamp: "2026-02-01T00:00:00Z".to_string(), | ||
| 1411 | author: test_author(), | ||
| 1412 | action: Action::IssueClose { | ||
| 1413 | reason: Some("resolved".to_string()), | ||
| 1414 | }, | ||
| 1415 | clock: 0, | ||
| 1416 | }; | ||
| 1417 | let detail = format_event_detail(&oid, &event); | ||
| 1418 | assert!(detail.contains("Issue Close")); | ||
| 1419 | assert!(detail.contains("Reason: resolved")); | ||
| 1420 | } | ||
| 1421 | |||
| 1422 | #[test] | ||
| 1423 | fn test_format_event_detail_patch_review() { | ||
| 1424 | let oid = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap(); | ||
| 1425 | let event = crate::event::Event { | ||
| 1426 | timestamp: "2026-03-01T00:00:00Z".to_string(), | ||
| 1427 | author: test_author(), | ||
| 1428 | action: Action::PatchReview { | ||
| 1429 | verdict: ReviewVerdict::Approve, | ||
| 1430 | body: "Looks good!".to_string(), | ||
| 1431 | }, | ||
| 1432 | clock: 0, | ||
| 1433 | }; | ||
| 1434 | let detail = format_event_detail(&oid, &event); | ||
| 1435 | assert!(detail.contains("Patch Review")); | ||
| 1436 | assert!(detail.contains("Approve")); | ||
| 1437 | assert!(detail.contains("Looks good!")); | ||
| 1438 | } | ||
| 1439 | |||
| 1440 | #[test] | ||
| 1441 | fn test_format_event_detail_short_oid() { | ||
| 1442 | let oid = Oid::from_str("1234567890abcdef1234567890abcdef12345678").unwrap(); | ||
| 1443 | let event = crate::event::Event { | ||
| 1444 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 1445 | author: test_author(), | ||
| 1446 | action: Action::IssueReopen, | ||
| 1447 | clock: 0, | ||
| 1448 | }; | ||
| 1449 | let detail = format_event_detail(&oid, &event); | ||
| 1450 | assert!(detail.contains("1234567")); | ||
| 1451 | assert!(detail.contains("Commit: 1234567\n")); | ||
| 1452 | } | ||
| 1453 | |||
| 1454 | // ── handle_key tests for 'c' key ───────────────────────────────────── | ||
| 1455 | |||
| 1456 | #[test] | ||
| 1457 | fn test_handle_key_c_in_detail_pane_returns_open_commit_browser() { | ||
| 1458 | let mut app = make_app(3, 0); | ||
| 1459 | app.pane = Pane::Detail; | ||
| 1460 | app.list_state.select(Some(0)); | ||
| 1461 | let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty()); | ||
| 1462 | assert_eq!(result, KeyAction::OpenCommitBrowser); | ||
| 1463 | } | ||
| 1464 | |||
| 1465 | #[test] | ||
| 1466 | fn test_handle_key_c_in_item_list_pane_is_noop() { | ||
| 1467 | let mut app = make_app(3, 0); | ||
| 1468 | app.pane = Pane::ItemList; | ||
| 1469 | app.list_state.select(Some(0)); | ||
| 1470 | let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty()); | ||
| 1471 | assert_eq!(result, KeyAction::Continue); | ||
| 1472 | } | ||
| 1473 | |||
| 1474 | #[test] | ||
| 1475 | fn test_handle_key_c_no_selection_is_noop() { | ||
| 1476 | let mut app = make_app(0, 0); | ||
| 1477 | app.pane = Pane::Detail; | ||
| 1478 | let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty()); | ||
| 1479 | assert_eq!(result, KeyAction::Continue); | ||
| 1480 | } | ||
| 1481 | |||
| 1482 | #[test] | ||
| 1483 | fn test_handle_key_ctrl_c_still_quits() { | ||
| 1484 | let mut app = make_app(3, 0); | ||
| 1485 | let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::CONTROL); | ||
| 1486 | assert_eq!(result, KeyAction::Quit); | ||
| 1487 | } | ||
| 1488 | |||
| 1489 | // ── CommitList navigation tests ────────────────────────────────────── | ||
| 1490 | |||
| 1491 | #[test] | ||
| 1492 | fn test_commit_list_navigate_down() { | ||
| 1493 | let mut app = make_app(3, 0); | ||
| 1494 | app.event_history = make_test_event_history(); | ||
| 1495 | app.event_list_state.select(Some(0)); | ||
| 1496 | app.mode = ViewMode::CommitList; | ||
| 1497 | |||
| 1498 | app.handle_key(KeyCode::Char('j'), KeyModifiers::empty()); | ||
| 1499 | assert_eq!(app.event_list_state.selected(), Some(1)); | ||
| 1500 | } | ||
| 1501 | |||
| 1502 | #[test] | ||
| 1503 | fn test_commit_list_navigate_up() { | ||
| 1504 | let mut app = make_app(3, 0); | ||
| 1505 | app.event_history = make_test_event_history(); | ||
| 1506 | app.event_list_state.select(Some(2)); | ||
| 1507 | app.mode = ViewMode::CommitList; | ||
| 1508 | |||
| 1509 | app.handle_key(KeyCode::Char('k'), KeyModifiers::empty()); | ||
| 1510 | assert_eq!(app.event_list_state.selected(), Some(1)); | ||
| 1511 | } | ||
| 1512 | |||
| 1513 | #[test] | ||
| 1514 | fn test_commit_list_navigate_clamp_bottom() { | ||
| 1515 | let mut app = make_app(3, 0); | ||
| 1516 | app.event_history = make_test_event_history(); | ||
| 1517 | app.event_list_state.select(Some(2)); | ||
| 1518 | app.mode = ViewMode::CommitList; | ||
| 1519 | |||
| 1520 | app.handle_key(KeyCode::Down, KeyModifiers::empty()); | ||
| 1521 | assert_eq!(app.event_list_state.selected(), Some(2)); | ||
| 1522 | } | ||
| 1523 | |||
| 1524 | #[test] | ||
| 1525 | fn test_commit_list_navigate_clamp_top() { | ||
| 1526 | let mut app = make_app(3, 0); | ||
| 1527 | app.event_history = make_test_event_history(); | ||
| 1528 | app.event_list_state.select(Some(0)); | ||
| 1529 | app.mode = ViewMode::CommitList; | ||
| 1530 | |||
| 1531 | app.handle_key(KeyCode::Up, KeyModifiers::empty()); | ||
| 1532 | assert_eq!(app.event_list_state.selected(), Some(0)); | ||
| 1533 | } | ||
| 1534 | |||
| 1535 | #[test] | ||
| 1536 | fn test_commit_list_escape_returns_to_details() { | ||
| 1537 | let mut app = make_app(3, 0); | ||
| 1538 | app.event_history = make_test_event_history(); | ||
| 1539 | app.event_list_state.select(Some(1)); | ||
| 1540 | app.mode = ViewMode::CommitList; | ||
| 1541 | |||
| 1542 | let result = app.handle_key(KeyCode::Esc, KeyModifiers::empty()); | ||
| 1543 | assert_eq!(result, KeyAction::Continue); | ||
| 1544 | assert_eq!(app.mode, ViewMode::Details); | ||
| 1545 | assert!(app.event_history.is_empty()); | ||
| 1546 | assert_eq!(app.event_list_state.selected(), None); | ||
| 1547 | } | ||
| 1548 | |||
| 1549 | #[test] | ||
| 1550 | fn test_commit_list_q_quits() { | ||
| 1551 | let mut app = make_app(3, 0); | ||
| 1552 | app.mode = ViewMode::CommitList; | ||
| 1553 | let result = app.handle_key(KeyCode::Char('q'), KeyModifiers::empty()); | ||
| 1554 | assert_eq!(result, KeyAction::Quit); | ||
| 1555 | } | ||
| 1556 | |||
| 1557 | // ── CommitDetail tests ─────────────────────────────────────────────── | ||
| 1558 | |||
| 1559 | #[test] | ||
| 1560 | fn test_commit_list_enter_opens_detail() { | ||
| 1561 | let mut app = make_app(3, 0); | ||
| 1562 | app.event_history = make_test_event_history(); | ||
| 1563 | app.event_list_state.select(Some(1)); | ||
| 1564 | app.mode = ViewMode::CommitList; | ||
| 1565 | |||
| 1566 | let result = app.handle_key(KeyCode::Enter, KeyModifiers::empty()); | ||
| 1567 | assert_eq!(result, KeyAction::Continue); | ||
| 1568 | assert_eq!(app.mode, ViewMode::CommitDetail); | ||
| 1569 | assert_eq!(app.scroll, 0); | ||
| 1570 | } | ||
| 1571 | |||
| 1572 | #[test] | ||
| 1573 | fn test_commit_list_enter_no_selection_stays() { | ||
| 1574 | let mut app = make_app(3, 0); | ||
| 1575 | app.event_history = make_test_event_history(); | ||
| 1576 | app.event_list_state = ListState::default(); | ||
| 1577 | app.mode = ViewMode::CommitList; | ||
| 1578 | |||
| 1579 | app.handle_key(KeyCode::Enter, KeyModifiers::empty()); | ||
| 1580 | assert_eq!(app.mode, ViewMode::CommitList); | ||
| 1581 | } | ||
| 1582 | |||
| 1583 | #[test] | ||
| 1584 | fn test_commit_detail_escape_returns_to_list() { | ||
| 1585 | let mut app = make_app(3, 0); | ||
| 1586 | app.event_history = make_test_event_history(); | ||
| 1587 | app.event_list_state.select(Some(0)); | ||
| 1588 | app.mode = ViewMode::CommitDetail; | ||
| 1589 | app.scroll = 5; | ||
| 1590 | |||
| 1591 | let result = app.handle_key(KeyCode::Esc, KeyModifiers::empty()); | ||
| 1592 | assert_eq!(result, KeyAction::Continue); | ||
| 1593 | assert_eq!(app.mode, ViewMode::CommitList); | ||
| 1594 | assert_eq!(app.scroll, 0); | ||
| 1595 | assert_eq!(app.event_history.len(), 3); | ||
| 1596 | } | ||
| 1597 | |||
| 1598 | #[test] | ||
| 1599 | fn test_commit_detail_scroll() { | ||
| 1600 | let mut app = make_app(3, 0); | ||
| 1601 | app.mode = ViewMode::CommitDetail; | ||
| 1602 | app.scroll = 0; | ||
| 1603 | |||
| 1604 | app.handle_key(KeyCode::Char('j'), KeyModifiers::empty()); | ||
| 1605 | assert_eq!(app.scroll, 1); | ||
| 1606 | app.handle_key(KeyCode::Char('j'), KeyModifiers::empty()); | ||
| 1607 | assert_eq!(app.scroll, 2); | ||
| 1608 | app.handle_key(KeyCode::Char('k'), KeyModifiers::empty()); | ||
| 1609 | assert_eq!(app.scroll, 1); | ||
| 1610 | } | ||
| 1611 | |||
| 1612 | #[test] | ||
| 1613 | fn test_commit_detail_page_scroll() { | ||
| 1614 | let mut app = make_app(3, 0); | ||
| 1615 | app.mode = ViewMode::CommitDetail; | ||
| 1616 | app.scroll = 0; | ||
| 1617 | |||
| 1618 | app.handle_key(KeyCode::PageDown, KeyModifiers::empty()); | ||
| 1619 | assert_eq!(app.scroll, 20); | ||
| 1620 | app.handle_key(KeyCode::PageUp, KeyModifiers::empty()); | ||
| 1621 | assert_eq!(app.scroll, 0); | ||
| 1622 | } | ||
| 1623 | |||
| 1624 | #[test] | ||
| 1625 | fn test_commit_detail_q_quits() { | ||
| 1626 | let mut app = make_app(3, 0); | ||
| 1627 | app.mode = ViewMode::CommitDetail; | ||
| 1628 | let result = app.handle_key(KeyCode::Char('q'), KeyModifiers::empty()); | ||
| 1629 | assert_eq!(result, KeyAction::Quit); | ||
| 1630 | } | ||
| 1631 | |||
| 1632 | // ── Guard tests ────────────────────────────────────────────────────── | ||
| 1633 | |||
| 1634 | #[test] | ||
| 1635 | fn test_c_ignored_in_commit_list_mode() { | ||
| 1636 | let mut app = make_app(3, 0); | ||
| 1637 | app.mode = ViewMode::CommitList; | ||
| 1638 | let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty()); | ||
| 1639 | assert_eq!(result, KeyAction::Continue); | ||
| 1640 | assert_eq!(app.mode, ViewMode::CommitList); | ||
| 1641 | } | ||
| 1642 | |||
| 1643 | #[test] | ||
| 1644 | fn test_c_ignored_in_commit_detail_mode() { | ||
| 1645 | let mut app = make_app(3, 0); | ||
| 1646 | app.mode = ViewMode::CommitDetail; | ||
| 1647 | let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty()); | ||
| 1648 | assert_eq!(result, KeyAction::Continue); | ||
| 1649 | assert_eq!(app.mode, ViewMode::CommitDetail); | ||
| 1650 | } | ||
| 1651 | |||
| 1652 | // ── handle_key basic tests ─────────────────────────────────────────── | ||
| 1653 | |||
| 1654 | #[test] | ||
| 1655 | fn test_handle_key_quit() { | ||
| 1656 | let mut app = make_app(3, 3); | ||
| 1657 | assert_eq!( | ||
| 1658 | app.handle_key(KeyCode::Char('q'), KeyModifiers::empty()), | ||
| 1659 | KeyAction::Quit | ||
| 1660 | ); | ||
| 1661 | } | ||
| 1662 | |||
| 1663 | #[test] | ||
| 1664 | fn test_handle_key_quit_esc() { | ||
| 1665 | let mut app = make_app(3, 3); | ||
| 1666 | assert_eq!( | ||
| 1667 | app.handle_key(KeyCode::Esc, KeyModifiers::empty()), | ||
| 1668 | KeyAction::Quit | ||
| 1669 | ); | ||
| 1670 | } | ||
| 1671 | |||
| 1672 | #[test] | ||
| 1673 | fn test_handle_key_quit_ctrl_c() { | ||
| 1674 | let mut app = make_app(3, 3); | ||
| 1675 | assert_eq!( | ||
| 1676 | app.handle_key(KeyCode::Char('c'), KeyModifiers::CONTROL), | ||
| 1677 | KeyAction::Quit | ||
| 1678 | ); | ||
| 1679 | } | ||
| 1680 | |||
| 1681 | #[test] | ||
| 1682 | fn test_handle_key_pane_toggle() { | ||
| 1683 | let mut app = make_app(3, 3); | ||
| 1684 | assert_eq!(app.pane, Pane::ItemList); | ||
| 1685 | app.handle_key(KeyCode::Tab, KeyModifiers::empty()); | ||
| 1686 | assert_eq!(app.pane, Pane::Detail); | ||
| 1687 | app.handle_key(KeyCode::Enter, KeyModifiers::empty()); | ||
| 1688 | assert_eq!(app.pane, Pane::ItemList); | ||
| 1689 | } | ||
| 1690 | |||
| 1691 | #[test] | ||
| 1692 | fn test_scroll_in_detail_pane() { | ||
| 1693 | let mut app = make_app(3, 0); | ||
| 1694 | app.list_state.select(Some(0)); | ||
| 1695 | app.pane = Pane::Detail; | ||
| 1696 | app.scroll = 0; | ||
| 1697 | |||
| 1698 | app.handle_key(KeyCode::Char('j'), KeyModifiers::empty()); | ||
| 1699 | assert_eq!(app.scroll, 1); | ||
| 1700 | assert_eq!(app.list_state.selected(), Some(0)); | ||
| 1701 | |||
| 1702 | app.handle_key(KeyCode::Char('k'), KeyModifiers::empty()); | ||
| 1703 | assert_eq!(app.scroll, 0); | ||
| 1704 | } | ||
| 1705 | |||
| 1706 | #[test] | ||
| 1707 | fn test_handle_key_reload() { | ||
| 1708 | let mut app = make_app(3, 3); | ||
| 1709 | assert_eq!( | ||
| 1710 | app.handle_key(KeyCode::Char('r'), KeyModifiers::empty()), | ||
| 1711 | KeyAction::Reload | ||
| 1712 | ); | ||
| 1713 | } | ||
| 1714 | |||
| 1715 | // ── selected_ref_name tests ────────────────────────────────────────── | ||
| 1716 | |||
| 1717 | #[test] | ||
| 1718 | fn test_selected_ref_name_issues() { | ||
| 1719 | let app = make_app(3, 0); | ||
| 1720 | let ref_name = app.selected_ref_name(); | ||
| 1721 | assert_eq!( | ||
| 1722 | ref_name, | ||
| 1723 | Some("refs/collab/issues/00000000".to_string()) | ||
| 1724 | ); | ||
| 1725 | } | ||
| 1726 | |||
| 1727 | #[test] | ||
| 1728 | fn test_selected_ref_name_none_when_empty() { | ||
| 1729 | let app = make_app(0, 0); | ||
| 1730 | assert_eq!(app.selected_ref_name(), None); | ||
| 1731 | } | ||
| 1732 | |||
| 1733 | // ── Render tests ───────────────────────────────────────────────────── | ||
| 1734 | |||
| 1735 | #[test] | ||
| 1736 | fn test_render_issues_tab() { | ||
| 1737 | let mut app = make_app(3, 2); | ||
| 1738 | app.status_filter = StatusFilter::All; | ||
| 1739 | let buf = render_app(&mut app); | ||
| 1740 | assert_buffer_contains(&buf, "Issues"); | ||
| 1741 | assert_buffer_contains(&buf, "00000000"); | ||
| 1742 | assert_buffer_contains(&buf, "00000001"); | ||
| 1743 | assert_buffer_contains(&buf, "00000002"); | ||
| 1744 | assert_buffer_contains(&buf, "Issue 0"); | ||
| 1745 | } | ||
| 1746 | |||
| 1747 | #[test] | ||
| 1748 | fn test_render_empty_state() { | ||
| 1749 | let mut app = make_app(0, 0); | ||
| 1750 | let buf = render_app(&mut app); | ||
| 1751 | assert_buffer_contains(&buf, "No matches for current filter."); | ||
| 1752 | } | ||
| 1753 | |||
| 1754 | #[test] | ||
| 1755 | fn test_render_footer_keys() { | ||
| 1756 | let mut app = make_app(3, 3); | ||
| 1757 | let buf = render_app(&mut app); | ||
| 1758 | assert_buffer_contains(&buf, "j/k:navigate"); | ||
| 1759 | assert_buffer_contains(&buf, "Tab:pane"); | ||
| 1760 | assert_buffer_contains(&buf, "c:events"); | ||
| 1761 | } | ||
| 1762 | |||
| 1763 | #[test] | ||
| 1764 | fn test_render_commit_list() { | ||
| 1765 | let mut app = make_app(3, 0); | ||
| 1766 | app.event_history = make_test_event_history(); | ||
| 1767 | app.event_list_state.select(Some(0)); | ||
| 1768 | app.mode = ViewMode::CommitList; | ||
| 1769 | let buf = render_app(&mut app); | ||
| 1770 | assert_buffer_contains(&buf, "Event History"); | ||
| 1771 | assert_buffer_contains(&buf, "Issue Open"); | ||
| 1772 | assert_buffer_contains(&buf, "Issue Comment"); | ||
| 1773 | assert_buffer_contains(&buf, "Issue Close"); | ||
| 1774 | } | ||
| 1775 | |||
| 1776 | #[test] | ||
| 1777 | fn test_render_commit_detail() { | ||
| 1778 | let mut app = make_app(3, 0); | ||
| 1779 | app.event_history = make_test_event_history(); | ||
| 1780 | app.event_list_state.select(Some(0)); | ||
| 1781 | app.mode = ViewMode::CommitDetail; | ||
| 1782 | let buf = render_app(&mut app); | ||
| 1783 | assert_buffer_contains(&buf, "Event Detail"); | ||
| 1784 | assert_buffer_contains(&buf, "aaaaaaa"); | ||
| 1785 | assert_buffer_contains(&buf, "Test User"); | ||
| 1786 | assert_buffer_contains(&buf, "Issue Open"); | ||
| 1787 | } | ||
| 1788 | |||
| 1789 | #[test] | ||
| 1790 | fn test_render_commit_list_footer() { | ||
| 1791 | let mut app = make_app(3, 0); | ||
| 1792 | app.mode = ViewMode::CommitList; | ||
| 1793 | let buf = render_app(&mut app); | ||
| 1794 | assert_buffer_contains(&buf, "Esc:back"); | ||
| 1795 | } | ||
| 1796 | |||
| 1797 | #[test] | ||
| 1798 | fn test_render_commit_detail_footer() { | ||
| 1799 | let mut app = make_app(3, 0); | ||
| 1800 | app.mode = ViewMode::CommitDetail; | ||
| 1801 | let buf = render_app(&mut app); | ||
| 1802 | assert_buffer_contains(&buf, "Esc:back"); | ||
| 1803 | assert_buffer_contains(&buf, "j/k:scroll"); | ||
| 1804 | } | ||
| 1805 | |||
| 1806 | #[test] | ||
| 1807 | fn test_render_small_terminal() { | ||
| 1808 | let mut app = make_app(3, 3); | ||
| 1809 | let backend = TestBackend::new(20, 10); | ||
| 1810 | let mut terminal = Terminal::new(backend).unwrap(); | ||
| 1811 | terminal.draw(|frame| ui(frame, &mut app)).unwrap(); | ||
| 1812 | } | ||
| 1813 | |||
| 1814 | // ── Integration: full browse flow ──────────────────────────────────── | ||
| 1815 | |||
| 1816 | #[test] | ||
| 1817 | fn test_full_commit_browse_flow() { | ||
| 1818 | let mut app = make_app(3, 0); | ||
| 1819 | app.pane = Pane::Detail; | ||
| 1820 | app.list_state.select(Some(0)); | ||
| 1821 | |||
| 1822 | let action = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty()); | ||
| 1823 | assert_eq!(action, KeyAction::OpenCommitBrowser); | ||
| 1824 | |||
| 1825 | app.event_history = make_test_event_history(); | ||
| 1826 | app.event_list_state.select(Some(0)); | ||
| 1827 | app.mode = ViewMode::CommitList; | ||
| 1828 | app.scroll = 0; | ||
| 1829 | |||
| 1830 | app.handle_key(KeyCode::Char('j'), KeyModifiers::empty()); | ||
| 1831 | assert_eq!(app.event_list_state.selected(), Some(1)); | ||
| 1832 | |||
| 1833 | app.handle_key(KeyCode::Enter, KeyModifiers::empty()); | ||
| 1834 | assert_eq!(app.mode, ViewMode::CommitDetail); | ||
| 1835 | |||
| 1836 | app.handle_key(KeyCode::Char('j'), KeyModifiers::empty()); | ||
| 1837 | assert_eq!(app.scroll, 1); | ||
| 1838 | |||
| 1839 | app.handle_key(KeyCode::Esc, KeyModifiers::empty()); | ||
| 1840 | assert_eq!(app.mode, ViewMode::CommitList); | ||
| 1841 | assert_eq!(app.scroll, 0); | ||
| 1842 | |||
| 1843 | app.handle_key(KeyCode::Esc, KeyModifiers::empty()); | ||
| 1844 | assert_eq!(app.mode, ViewMode::Details); | ||
| 1845 | assert!(app.event_history.is_empty()); | ||
| 1846 | } | ||
| 1847 | |||
| 1848 | // ── Status message render test ─────────────────────────────────────── | ||
| 1849 | |||
| 1850 | #[test] | ||
| 1851 | fn test_render_status_message() { | ||
| 1852 | let mut app = make_app(3, 0); | ||
| 1853 | app.status_msg = Some("Error loading events: ref not found".to_string()); | ||
| 1854 | let buf = render_app(&mut app); | ||
| 1855 | assert_buffer_contains(&buf, "Error loading events"); | ||
| 1856 | } | ||
| 1857 | } | ||
src/tui/events.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,225 @@ | |||
| 1 | use std::io::{self, stdout}; | ||
| 2 | use std::time::Duration; | ||
| 3 | |||
| 4 | use crossterm::event::{self, Event, KeyCode}; | ||
| 5 | use crossterm::terminal::{self, LeaveAlternateScreen}; | ||
| 6 | use crossterm::ExecutableCommand; | ||
| 7 | use git2::Repository; | ||
| 8 | use ratatui::prelude::*; | ||
| 9 | use ratatui::widgets::ListState; | ||
| 10 | |||
| 11 | use crate::error::Error; | ||
| 12 | use crate::issue as issue_mod; | ||
| 13 | |||
| 14 | use super::state::{App, InputMode, KeyAction, ViewMode}; | ||
| 15 | use super::widgets::ui; | ||
| 16 | |||
| 17 | pub(crate) fn run_loop( | ||
| 18 | terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, | ||
| 19 | app: &mut App, | ||
| 20 | repo: &Repository, | ||
| 21 | ) -> Result<(), Error> { | ||
| 22 | loop { | ||
| 23 | terminal.draw(|frame| ui(frame, app))?; | ||
| 24 | |||
| 25 | if event::poll(Duration::from_millis(100))? { | ||
| 26 | if let Event::Key(key) = event::read()? { | ||
| 27 | app.status_msg = None; // clear status on any keypress | ||
| 28 | |||
| 29 | // Input mode intercept — handle keys before normal bindings | ||
| 30 | match app.input_mode { | ||
| 31 | InputMode::Search => { | ||
| 32 | match key.code { | ||
| 33 | KeyCode::Esc => { | ||
| 34 | app.input_mode = InputMode::Normal; | ||
| 35 | app.search_query.clear(); | ||
| 36 | let count = app.visible_count(); | ||
| 37 | app.list_state | ||
| 38 | .select(if count > 0 { Some(0) } else { None }); | ||
| 39 | } | ||
| 40 | KeyCode::Backspace => { | ||
| 41 | app.search_query.pop(); | ||
| 42 | let count = app.visible_count(); | ||
| 43 | app.list_state | ||
| 44 | .select(if count > 0 { Some(0) } else { None }); | ||
| 45 | } | ||
| 46 | KeyCode::Char(c) => { | ||
| 47 | app.search_query.push(c); | ||
| 48 | let count = app.visible_count(); | ||
| 49 | app.list_state | ||
| 50 | .select(if count > 0 { Some(0) } else { None }); | ||
| 51 | } | ||
| 52 | _ => {} | ||
| 53 | } | ||
| 54 | continue; | ||
| 55 | } | ||
| 56 | InputMode::CreateTitle => { | ||
| 57 | match key.code { | ||
| 58 | KeyCode::Esc => { | ||
| 59 | app.input_mode = InputMode::Normal; | ||
| 60 | app.input_buf.clear(); | ||
| 61 | } | ||
| 62 | KeyCode::Enter => { | ||
| 63 | let title = app.input_buf.trim().to_string(); | ||
| 64 | if title.is_empty() { | ||
| 65 | app.input_mode = InputMode::Normal; | ||
| 66 | app.input_buf.clear(); | ||
| 67 | } else { | ||
| 68 | app.create_title = title; | ||
| 69 | app.input_buf.clear(); | ||
| 70 | app.input_mode = InputMode::CreateBody; | ||
| 71 | } | ||
| 72 | } | ||
| 73 | KeyCode::Backspace => { | ||
| 74 | app.input_buf.pop(); | ||
| 75 | } | ||
| 76 | KeyCode::Char(c) => { | ||
| 77 | app.input_buf.push(c); | ||
| 78 | } | ||
| 79 | _ => {} | ||
| 80 | } | ||
| 81 | continue; | ||
| 82 | } | ||
| 83 | InputMode::CreateBody => { | ||
| 84 | match key.code { | ||
| 85 | KeyCode::Esc => { | ||
| 86 | // Submit with title only, no body | ||
| 87 | let title = app.create_title.clone(); | ||
| 88 | match issue_mod::open(repo, &title, "", None) { | ||
| 89 | Ok(id) => { | ||
| 90 | app.reload(repo); | ||
| 91 | app.status_msg = | ||
| 92 | Some(format!("Issue created: {:.8}", id)); | ||
| 93 | } | ||
| 94 | Err(e) => { | ||
| 95 | app.status_msg = | ||
| 96 | Some(format!("Error creating issue: {}", e)); | ||
| 97 | } | ||
| 98 | } | ||
| 99 | app.input_mode = InputMode::Normal; | ||
| 100 | app.input_buf.clear(); | ||
| 101 | app.create_title.clear(); | ||
| 102 | } | ||
| 103 | KeyCode::Enter => { | ||
| 104 | let title = app.create_title.clone(); | ||
| 105 | let body = app.input_buf.clone(); | ||
| 106 | match issue_mod::open(repo, &title, &body, None) { | ||
| 107 | Ok(id) => { | ||
| 108 | app.reload(repo); | ||
| 109 | app.status_msg = | ||
| 110 | Some(format!("Issue created: {:.8}", id)); | ||
| 111 | } | ||
| 112 | Err(e) => { | ||
| 113 | app.status_msg = | ||
| 114 | Some(format!("Error creating issue: {}", e)); | ||
| 115 | } | ||
| 116 | } | ||
| 117 | app.input_mode = InputMode::Normal; | ||
| 118 | app.input_buf.clear(); | ||
| 119 | app.create_title.clear(); | ||
| 120 | } | ||
| 121 | KeyCode::Backspace => { | ||
| 122 | app.input_buf.pop(); | ||
| 123 | } | ||
| 124 | KeyCode::Char(c) => { | ||
| 125 | app.input_buf.push(c); | ||
| 126 | } | ||
| 127 | _ => {} | ||
| 128 | } | ||
| 129 | continue; | ||
| 130 | } | ||
| 131 | InputMode::Normal => {} | ||
| 132 | } | ||
| 133 | |||
| 134 | // Handle keys that need repo access or are run_loop-specific | ||
| 135 | // before delegating to handle_key | ||
| 136 | if app.mode == ViewMode::Details { | ||
| 137 | match key.code { | ||
| 138 | KeyCode::Char('/') => { | ||
| 139 | app.input_mode = InputMode::Search; | ||
| 140 | app.search_query.clear(); | ||
| 141 | continue; | ||
| 142 | } | ||
| 143 | KeyCode::Char('n') => { | ||
| 144 | app.input_mode = InputMode::CreateTitle; | ||
| 145 | app.input_buf.clear(); | ||
| 146 | app.create_title.clear(); | ||
| 147 | continue; | ||
| 148 | } | ||
| 149 | KeyCode::Char('o') => { | ||
| 150 | // Check out the relevant commit for local browsing | ||
| 151 | let checkout_target = { | ||
| 152 | let visible = app.visible_issues(); | ||
| 153 | app.list_state | ||
| 154 | .selected() | ||
| 155 | .and_then(|idx| visible.get(idx)) | ||
| 156 | .and_then(|issue| { | ||
| 157 | // Try linked patch first | ||
| 158 | app.patches | ||
| 159 | .iter() | ||
| 160 | .find(|p| p.fixes.as_deref() == Some(&issue.id)) | ||
| 161 | .map(|p| p.branch.clone()) | ||
| 162 | // Fall back to closing commit | ||
| 163 | .or_else(|| { | ||
| 164 | issue.closed_by.map(|oid| oid.to_string()) | ||
| 165 | }) | ||
| 166 | }) | ||
| 167 | }; | ||
| 168 | if let Some(head) = checkout_target { | ||
| 169 | // Exit TUI, checkout, and return | ||
| 170 | terminal::disable_raw_mode()?; | ||
| 171 | stdout().execute(LeaveAlternateScreen)?; | ||
| 172 | let status = std::process::Command::new("git") | ||
| 173 | .args(["checkout", &head]) | ||
| 174 | .status(); | ||
| 175 | match status { | ||
| 176 | Ok(s) if s.success() => { | ||
| 177 | println!("Checked out commit: {:.8}", head); | ||
| 178 | println!("Use 'git checkout -' to return."); | ||
| 179 | } | ||
| 180 | Ok(s) => { | ||
| 181 | eprintln!("git checkout exited with {}", s); | ||
| 182 | } | ||
| 183 | Err(e) => { | ||
| 184 | eprintln!("Failed to run git checkout: {}", e); | ||
| 185 | } | ||
| 186 | } | ||
| 187 | return Ok(()); | ||
| 188 | } else { | ||
| 189 | app.status_msg = | ||
| 190 | Some("No linked patch to check out".to_string()); | ||
| 191 | } | ||
| 192 | continue; | ||
| 193 | } | ||
| 194 | _ => {} | ||
| 195 | } | ||
| 196 | } | ||
| 197 | |||
| 198 | match app.handle_key(key.code, key.modifiers) { | ||
| 199 | KeyAction::Quit => return Ok(()), | ||
| 200 | KeyAction::Reload => app.reload(repo), | ||
| 201 | KeyAction::OpenCommitBrowser => { | ||
| 202 | if let Some(ref_name) = app.selected_ref_name() { | ||
| 203 | match crate::dag::walk_events(repo, &ref_name) { | ||
| 204 | Ok(events) => { | ||
| 205 | app.event_history = events; | ||
| 206 | app.event_list_state = ListState::default(); | ||
| 207 | if !app.event_history.is_empty() { | ||
| 208 | app.event_list_state.select(Some(0)); | ||
| 209 | } | ||
| 210 | app.mode = ViewMode::CommitList; | ||
| 211 | app.scroll = 0; | ||
| 212 | } | ||
| 213 | Err(e) => { | ||
| 214 | app.status_msg = | ||
| 215 | Some(format!("Error loading events: {}", e)); | ||
| 216 | } | ||
| 217 | } | ||
| 218 | } | ||
| 219 | } | ||
| 220 | KeyAction::Continue => {} | ||
| 221 | } | ||
| 222 | } | ||
| 223 | } | ||
| 224 | } | ||
| 225 | } | ||
src/tui/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,1015 @@ | |||
| 1 | mod events; | ||
| 2 | mod state; | ||
| 3 | mod widgets; | ||
| 4 | |||
| 5 | use std::io::stdout; | ||
| 6 | |||
| 7 | use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; | ||
| 8 | use crossterm::ExecutableCommand; | ||
| 9 | use git2::Repository; | ||
| 10 | use ratatui::prelude::*; | ||
| 11 | |||
| 12 | use crate::error::Error; | ||
| 13 | use crate::state as app_state; | ||
| 14 | |||
| 15 | use self::events::run_loop; | ||
| 16 | use self::state::App; | ||
| 17 | |||
| 18 | pub fn run(repo: &Repository) -> Result<(), Error> { | ||
| 19 | let issues = app_state::list_issues(repo)?; | ||
| 20 | let patches = app_state::list_patches(repo)?; | ||
| 21 | |||
| 22 | let mut app = App::new(issues, patches); | ||
| 23 | |||
| 24 | terminal::enable_raw_mode()?; | ||
| 25 | stdout().execute(EnterAlternateScreen)?; | ||
| 26 | let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; | ||
| 27 | |||
| 28 | let result = run_loop(&mut terminal, &mut app, repo); | ||
| 29 | |||
| 30 | terminal::disable_raw_mode()?; | ||
| 31 | stdout().execute(LeaveAlternateScreen)?; | ||
| 32 | |||
| 33 | result | ||
| 34 | } | ||
| 35 | |||
| 36 | #[cfg(test)] | ||
| 37 | mod tests { | ||
| 38 | use super::state::*; | ||
| 39 | use super::widgets::*; | ||
| 40 | use crate::event::{Action, Author, ReviewVerdict}; | ||
| 41 | use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus}; | ||
| 42 | use git2::Oid; | ||
| 43 | use ratatui::backend::TestBackend; | ||
| 44 | use ratatui::buffer::Buffer; | ||
| 45 | use ratatui::prelude::*; | ||
| 46 | use ratatui::widgets::ListState; | ||
| 47 | |||
| 48 | fn make_author() -> Author { | ||
| 49 | Author { | ||
| 50 | name: "test".into(), | ||
| 51 | email: "test@test.com".into(), | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | fn make_issue(id: &str, title: &str, status: IssueStatus) -> IssueState { | ||
| 56 | IssueState { | ||
| 57 | id: id.into(), | ||
| 58 | title: title.into(), | ||
| 59 | body: String::new(), | ||
| 60 | status, | ||
| 61 | close_reason: None, | ||
| 62 | closed_by: None, | ||
| 63 | labels: vec![], | ||
| 64 | assignees: vec![], | ||
| 65 | comments: vec![], | ||
| 66 | created_at: String::new(), | ||
| 67 | last_updated: String::new(), | ||
| 68 | author: make_author(), | ||
| 69 | relates_to: None, | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | fn make_patch(id: &str, title: &str, status: PatchStatus) -> PatchState { | ||
| 74 | PatchState { | ||
| 75 | id: id.into(), | ||
| 76 | title: title.into(), | ||
| 77 | body: String::new(), | ||
| 78 | status, | ||
| 79 | base_ref: "main".into(), | ||
| 80 | fixes: None, | ||
| 81 | branch: format!("feature/{}", id), | ||
| 82 | comments: vec![], | ||
| 83 | inline_comments: vec![], | ||
| 84 | reviews: vec![], | ||
| 85 | created_at: String::new(), | ||
| 86 | last_updated: String::new(), | ||
| 87 | author: make_author(), | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | fn test_app() -> App { | ||
| 92 | let issues = vec![ | ||
| 93 | make_issue("i1", "Fix login bug", IssueStatus::Open), | ||
| 94 | make_issue("i2", "Add dashboard feature", IssueStatus::Open), | ||
| 95 | make_issue("i3", "Old closed issue", IssueStatus::Closed), | ||
| 96 | ]; | ||
| 97 | let patches = vec![ | ||
| 98 | make_patch("p1", "Login fix patch", PatchStatus::Open), | ||
| 99 | make_patch("p2", "Dashboard patch", PatchStatus::Closed), | ||
| 100 | make_patch("p3", "Merged feature", PatchStatus::Merged), | ||
| 101 | ]; | ||
| 102 | App::new(issues, patches) | ||
| 103 | } | ||
| 104 | |||
| 105 | // T010: visible_issues filters by search_query (case-insensitive) | ||
| 106 | #[test] | ||
| 107 | fn test_visible_issues_text_filter() { | ||
| 108 | let mut app = test_app(); | ||
| 109 | app.status_filter = StatusFilter::All; | ||
| 110 | app.search_query = "login".into(); | ||
| 111 | let visible = app.visible_issues(); | ||
| 112 | assert_eq!(visible.len(), 1); | ||
| 113 | assert_eq!(visible[0].title, "Fix login bug"); | ||
| 114 | } | ||
| 115 | |||
| 116 | // T012: visible_issues returns all status-matching items when search_query is empty | ||
| 117 | #[test] | ||
| 118 | fn test_visible_issues_no_text_filter() { | ||
| 119 | let mut app = test_app(); | ||
| 120 | app.status_filter = StatusFilter::All; | ||
| 121 | app.search_query.clear(); | ||
| 122 | let visible = app.visible_issues(); | ||
| 123 | assert_eq!(visible.len(), 3); | ||
| 124 | } | ||
| 125 | |||
| 126 | // T019: StatusFilter::next() cycles Open -> All -> Closed -> Open | ||
| 127 | #[test] | ||
| 128 | fn test_status_filter_cycle() { | ||
| 129 | assert_eq!(StatusFilter::Open.next(), StatusFilter::All); | ||
| 130 | assert_eq!(StatusFilter::All.next(), StatusFilter::Closed); | ||
| 131 | assert_eq!(StatusFilter::Closed.next(), StatusFilter::Open); | ||
| 132 | } | ||
| 133 | |||
| 134 | // T020: visible_issues returns only closed when status_filter is Closed | ||
| 135 | #[test] | ||
| 136 | fn test_visible_issues_closed_filter() { | ||
| 137 | let mut app = test_app(); | ||
| 138 | app.status_filter = StatusFilter::Closed; | ||
| 139 | let visible = app.visible_issues(); | ||
| 140 | assert_eq!(visible.len(), 1); | ||
| 141 | assert_eq!(visible[0].title, "Old closed issue"); | ||
| 142 | } | ||
| 143 | |||
| 144 | // T025: combined status + text filter | ||
| 145 | #[test] | ||
| 146 | fn test_combined_filters() { | ||
| 147 | let mut app = test_app(); | ||
| 148 | app.status_filter = StatusFilter::Open; | ||
| 149 | app.search_query = "login".into(); | ||
| 150 | let visible = app.visible_issues(); | ||
| 151 | assert_eq!(visible.len(), 1); | ||
| 152 | assert_eq!(visible[0].title, "Fix login bug"); | ||
| 153 | |||
| 154 | // Same query with Closed filter should return nothing | ||
| 155 | app.status_filter = StatusFilter::Closed; | ||
| 156 | let visible = app.visible_issues(); | ||
| 157 | assert_eq!(visible.len(), 0); | ||
| 158 | } | ||
| 159 | |||
| 160 | // T026: Escape clears text filter but preserves status_filter | ||
| 161 | #[test] | ||
| 162 | fn test_escape_clears_text_preserves_status() { | ||
| 163 | let mut app = test_app(); | ||
| 164 | app.status_filter = StatusFilter::Closed; | ||
| 165 | app.input_mode = InputMode::Search; | ||
| 166 | app.search_query = "some query".into(); | ||
| 167 | |||
| 168 | // Simulate Escape | ||
| 169 | app.input_mode = InputMode::Normal; | ||
| 170 | app.search_query.clear(); | ||
| 171 | |||
| 172 | assert_eq!(app.status_filter, StatusFilter::Closed); | ||
| 173 | assert_eq!(app.input_mode, InputMode::Normal); | ||
| 174 | assert!(app.search_query.is_empty()); | ||
| 175 | } | ||
| 176 | |||
| 177 | #[test] | ||
| 178 | fn test_create_title_mode_clears_on_escape() { | ||
| 179 | let mut app = test_app(); | ||
| 180 | app.input_mode = InputMode::CreateTitle; | ||
| 181 | app.input_buf = "partial title".into(); | ||
| 182 | |||
| 183 | // Simulate Escape | ||
| 184 | app.input_mode = InputMode::Normal; | ||
| 185 | app.input_buf.clear(); | ||
| 186 | |||
| 187 | assert_eq!(app.input_mode, InputMode::Normal); | ||
| 188 | assert!(app.input_buf.is_empty()); | ||
| 189 | } | ||
| 190 | |||
| 191 | #[test] | ||
| 192 | fn test_create_title_transitions_to_body() { | ||
| 193 | let mut app = test_app(); | ||
| 194 | app.input_mode = InputMode::CreateTitle; | ||
| 195 | app.input_buf = "My new issue".into(); | ||
| 196 | |||
| 197 | // Simulate Enter with non-empty title | ||
| 198 | let title = app.input_buf.trim().to_string(); | ||
| 199 | assert!(!title.is_empty()); | ||
| 200 | app.create_title = title; | ||
| 201 | app.input_buf.clear(); | ||
| 202 | app.input_mode = InputMode::CreateBody; | ||
| 203 | |||
| 204 | assert_eq!(app.input_mode, InputMode::CreateBody); | ||
| 205 | assert_eq!(app.create_title, "My new issue"); | ||
| 206 | assert!(app.input_buf.is_empty()); | ||
| 207 | } | ||
| 208 | |||
| 209 | #[test] | ||
| 210 | fn test_empty_title_dismissed() { | ||
| 211 | let mut app = test_app(); | ||
| 212 | app.input_mode = InputMode::CreateTitle; | ||
| 213 | app.input_buf = " ".into(); | ||
| 214 | |||
| 215 | // Simulate Enter with whitespace-only title | ||
| 216 | let title = app.input_buf.trim().to_string(); | ||
| 217 | if title.is_empty() { | ||
| 218 | app.input_mode = InputMode::Normal; | ||
| 219 | app.input_buf.clear(); | ||
| 220 | } | ||
| 221 | |||
| 222 | assert_eq!(app.input_mode, InputMode::Normal); | ||
| 223 | } | ||
| 224 | |||
| 225 | // ── Commit browser test helpers ───────────────────────────────────── | ||
| 226 | |||
| 227 | fn test_author() -> Author { | ||
| 228 | Author { | ||
| 229 | name: "Test User".to_string(), | ||
| 230 | email: "test@example.com".to_string(), | ||
| 231 | } | ||
| 232 | } | ||
| 233 | |||
| 234 | fn make_test_issues(n: usize) -> Vec<IssueState> { | ||
| 235 | (0..n) | ||
| 236 | .map(|i| IssueState { | ||
| 237 | id: format!("{:08x}", i), | ||
| 238 | title: format!("Issue {}", i), | ||
| 239 | body: format!("Body for issue {}", i), | ||
| 240 | status: if i % 2 == 0 { | ||
| 241 | IssueStatus::Open | ||
| 242 | } else { | ||
| 243 | IssueStatus::Closed | ||
| 244 | }, | ||
| 245 | close_reason: if i % 2 == 1 { | ||
| 246 | Some("done".to_string()) | ||
| 247 | } else { | ||
| 248 | None | ||
| 249 | }, | ||
| 250 | closed_by: None, | ||
| 251 | labels: vec![], | ||
| 252 | assignees: vec![], | ||
| 253 | comments: Vec::new(), | ||
| 254 | created_at: "2026-01-01T00:00:00Z".to_string(), | ||
| 255 | last_updated: "2026-01-01T00:00:00Z".to_string(), | ||
| 256 | author: test_author(), | ||
| 257 | relates_to: None, | ||
| 258 | }) | ||
| 259 | .collect() | ||
| 260 | } | ||
| 261 | |||
| 262 | fn make_test_patches(n: usize) -> Vec<PatchState> { | ||
| 263 | (0..n) | ||
| 264 | .map(|i| PatchState { | ||
| 265 | id: format!("p{:07x}", i), | ||
| 266 | title: format!("Patch {}", i), | ||
| 267 | body: format!("Body for patch {}", i), | ||
| 268 | status: if i % 2 == 0 { | ||
| 269 | PatchStatus::Open | ||
| 270 | } else { | ||
| 271 | PatchStatus::Closed | ||
| 272 | }, | ||
| 273 | base_ref: "main".to_string(), | ||
| 274 | fixes: None, | ||
| 275 | branch: format!("feature/p{:07x}", i), | ||
| 276 | comments: Vec::new(), | ||
| 277 | inline_comments: Vec::new(), | ||
| 278 | reviews: Vec::new(), | ||
| 279 | created_at: "2026-01-01T00:00:00Z".to_string(), | ||
| 280 | last_updated: "2026-01-01T00:00:00Z".to_string(), | ||
| 281 | author: test_author(), | ||
| 282 | }) | ||
| 283 | .collect() | ||
| 284 | } | ||
| 285 | |||
| 286 | fn make_app(issues: usize, patches: usize) -> App { | ||
| 287 | App::new(make_test_issues(issues), make_test_patches(patches)) | ||
| 288 | } | ||
| 289 | |||
| 290 | fn render_app(app: &mut App) -> Buffer { | ||
| 291 | let backend = TestBackend::new(80, 24); | ||
| 292 | let mut terminal = Terminal::new(backend).unwrap(); | ||
| 293 | terminal.draw(|frame| ui(frame, app)).unwrap(); | ||
| 294 | terminal.backend().buffer().clone() | ||
| 295 | } | ||
| 296 | |||
| 297 | fn buffer_to_string(buf: &Buffer) -> String { | ||
| 298 | let area = buf.area; | ||
| 299 | let mut s = String::new(); | ||
| 300 | for y in area.y..area.y + area.height { | ||
| 301 | for x in area.x..area.x + area.width { | ||
| 302 | let cell = buf.cell((x, y)).unwrap(); | ||
| 303 | s.push_str(cell.symbol()); | ||
| 304 | } | ||
| 305 | s.push('\n'); | ||
| 306 | } | ||
| 307 | s | ||
| 308 | } | ||
| 309 | |||
| 310 | fn assert_buffer_contains(buf: &Buffer, expected: &str) { | ||
| 311 | let text = buffer_to_string(buf); | ||
| 312 | assert!( | ||
| 313 | text.contains(expected), | ||
| 314 | "expected buffer to contain {:?}, but it was not found in:\n{}", | ||
| 315 | expected, | ||
| 316 | text | ||
| 317 | ); | ||
| 318 | } | ||
| 319 | |||
| 320 | /// Create sample event history for testing commit browser | ||
| 321 | fn make_test_event_history() -> Vec<(Oid, crate::event::Event)> { | ||
| 322 | let oid1 = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(); | ||
| 323 | let oid2 = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(); | ||
| 324 | let oid3 = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap(); | ||
| 325 | |||
| 326 | vec![ | ||
| 327 | ( | ||
| 328 | oid1, | ||
| 329 | crate::event::Event { | ||
| 330 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 331 | author: test_author(), | ||
| 332 | action: Action::IssueOpen { | ||
| 333 | title: "Test Issue".to_string(), | ||
| 334 | body: "This is the body".to_string(), | ||
| 335 | relates_to: None, | ||
| 336 | }, | ||
| 337 | clock: 0, | ||
| 338 | }, | ||
| 339 | ), | ||
| 340 | ( | ||
| 341 | oid2, | ||
| 342 | crate::event::Event { | ||
| 343 | timestamp: "2026-01-02T00:00:00Z".to_string(), | ||
| 344 | author: Author { | ||
| 345 | name: "Other User".to_string(), | ||
| 346 | email: "other@example.com".to_string(), | ||
| 347 | }, | ||
| 348 | action: Action::IssueComment { | ||
| 349 | body: "A comment on the issue".to_string(), | ||
| 350 | }, | ||
| 351 | clock: 0, | ||
| 352 | }, | ||
| 353 | ), | ||
| 354 | ( | ||
| 355 | oid3, | ||
| 356 | crate::event::Event { | ||
| 357 | timestamp: "2026-01-03T00:00:00Z".to_string(), | ||
| 358 | author: test_author(), | ||
| 359 | action: Action::IssueClose { | ||
| 360 | reason: Some("fixed".to_string()), | ||
| 361 | }, | ||
| 362 | clock: 0, | ||
| 363 | }, | ||
| 364 | ), | ||
| 365 | ] | ||
| 366 | } | ||
| 367 | |||
| 368 | // ── action_type_label tests ────────────────────────────────────────── | ||
| 369 | |||
| 370 | #[test] | ||
| 371 | fn test_action_type_label_issue_open() { | ||
| 372 | let action = Action::IssueOpen { | ||
| 373 | title: "t".to_string(), | ||
| 374 | body: "b".to_string(), | ||
| 375 | relates_to: None, | ||
| 376 | }; | ||
| 377 | assert_eq!(action_type_label(&action), "Issue Open"); | ||
| 378 | } | ||
| 379 | |||
| 380 | #[test] | ||
| 381 | fn test_action_type_label_issue_comment() { | ||
| 382 | let action = Action::IssueComment { | ||
| 383 | body: "b".to_string(), | ||
| 384 | }; | ||
| 385 | assert_eq!(action_type_label(&action), "Issue Comment"); | ||
| 386 | } | ||
| 387 | |||
| 388 | #[test] | ||
| 389 | fn test_action_type_label_issue_close() { | ||
| 390 | let action = Action::IssueClose { reason: None }; | ||
| 391 | assert_eq!(action_type_label(&action), "Issue Close"); | ||
| 392 | } | ||
| 393 | |||
| 394 | #[test] | ||
| 395 | fn test_action_type_label_issue_reopen() { | ||
| 396 | assert_eq!(action_type_label(&Action::IssueReopen), "Issue Reopen"); | ||
| 397 | } | ||
| 398 | |||
| 399 | #[test] | ||
| 400 | fn test_action_type_label_patch_create() { | ||
| 401 | let action = Action::PatchCreate { | ||
| 402 | title: "t".to_string(), | ||
| 403 | body: "b".to_string(), | ||
| 404 | base_ref: "main".to_string(), | ||
| 405 | branch: "feature/test".to_string(), | ||
| 406 | fixes: None, | ||
| 407 | }; | ||
| 408 | assert_eq!(action_type_label(&action), "Patch Create"); | ||
| 409 | } | ||
| 410 | |||
| 411 | #[test] | ||
| 412 | fn test_action_type_label_patch_review() { | ||
| 413 | let action = Action::PatchReview { | ||
| 414 | verdict: ReviewVerdict::Approve, | ||
| 415 | body: "lgtm".to_string(), | ||
| 416 | }; | ||
| 417 | assert_eq!(action_type_label(&action), "Patch Review"); | ||
| 418 | } | ||
| 419 | |||
| 420 | #[test] | ||
| 421 | fn test_action_type_label_inline_comment() { | ||
| 422 | let action = Action::PatchInlineComment { | ||
| 423 | file: "src/main.rs".to_string(), | ||
| 424 | line: 42, | ||
| 425 | body: "nit".to_string(), | ||
| 426 | }; | ||
| 427 | assert_eq!(action_type_label(&action), "Inline Comment"); | ||
| 428 | } | ||
| 429 | |||
| 430 | #[test] | ||
| 431 | fn test_action_type_label_merge() { | ||
| 432 | assert_eq!(action_type_label(&Action::Merge), "Merge"); | ||
| 433 | } | ||
| 434 | |||
| 435 | // ── format_event_detail tests ──────────────────────────────────────── | ||
| 436 | |||
| 437 | #[test] | ||
| 438 | fn test_format_event_detail_issue_open() { | ||
| 439 | let oid = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(); | ||
| 440 | let event = crate::event::Event { | ||
| 441 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 442 | author: test_author(), | ||
| 443 | action: Action::IssueOpen { | ||
| 444 | title: "My Issue".to_string(), | ||
| 445 | body: "Description here".to_string(), | ||
| 446 | relates_to: None, | ||
| 447 | }, | ||
| 448 | clock: 0, | ||
| 449 | }; | ||
| 450 | let detail = format_event_detail(&oid, &event); | ||
| 451 | assert!(detail.contains("aaaaaaa")); | ||
| 452 | assert!(detail.contains("Test User <test@example.com>")); | ||
| 453 | assert!(detail.contains("2026-01-01T00:00:00Z")); | ||
| 454 | assert!(detail.contains("Issue Open")); | ||
| 455 | assert!(detail.contains("Title: My Issue")); | ||
| 456 | assert!(detail.contains("Description here")); | ||
| 457 | } | ||
| 458 | |||
| 459 | #[test] | ||
| 460 | fn test_format_event_detail_issue_close_with_reason() { | ||
| 461 | let oid = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(); | ||
| 462 | let event = crate::event::Event { | ||
| 463 | timestamp: "2026-02-01T00:00:00Z".to_string(), | ||
| 464 | author: test_author(), | ||
| 465 | action: Action::IssueClose { | ||
| 466 | reason: Some("resolved".to_string()), | ||
| 467 | }, | ||
| 468 | clock: 0, | ||
| 469 | }; | ||
| 470 | let detail = format_event_detail(&oid, &event); | ||
| 471 | assert!(detail.contains("Issue Close")); | ||
| 472 | assert!(detail.contains("Reason: resolved")); | ||
| 473 | } | ||
| 474 | |||
| 475 | #[test] | ||
| 476 | fn test_format_event_detail_patch_review() { | ||
| 477 | let oid = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap(); | ||
| 478 | let event = crate::event::Event { | ||
| 479 | timestamp: "2026-03-01T00:00:00Z".to_string(), | ||
| 480 | author: test_author(), | ||
| 481 | action: Action::PatchReview { | ||
| 482 | verdict: ReviewVerdict::Approve, | ||
| 483 | body: "Looks good!".to_string(), | ||
| 484 | }, | ||
| 485 | clock: 0, | ||
| 486 | }; | ||
| 487 | let detail = format_event_detail(&oid, &event); | ||
| 488 | assert!(detail.contains("Patch Review")); | ||
| 489 | assert!(detail.contains("Approve")); | ||
| 490 | assert!(detail.contains("Looks good!")); | ||
| 491 | } | ||
| 492 | |||
| 493 | #[test] | ||
| 494 | fn test_format_event_detail_short_oid() { | ||
| 495 | let oid = Oid::from_str("1234567890abcdef1234567890abcdef12345678").unwrap(); | ||
| 496 | let event = crate::event::Event { | ||
| 497 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 498 | author: test_author(), | ||
| 499 | action: Action::IssueReopen, | ||
| 500 | clock: 0, | ||
| 501 | }; | ||
| 502 | let detail = format_event_detail(&oid, &event); | ||
| 503 | assert!(detail.contains("1234567")); | ||
| 504 | assert!(detail.contains("Commit: 1234567\n")); | ||
| 505 | } | ||
| 506 | |||
| 507 | // ── handle_key tests for 'c' key ───────────────────────────────────── | ||
| 508 | |||
| 509 | #[test] | ||
| 510 | fn test_handle_key_c_in_detail_pane_returns_open_commit_browser() { | ||
| 511 | let mut app = make_app(3, 0); | ||
| 512 | app.pane = Pane::Detail; | ||
| 513 | app.list_state.select(Some(0)); | ||
| 514 | let result = app.handle_key( | ||
| 515 | crossterm::event::KeyCode::Char('c'), | ||
| 516 | crossterm::event::KeyModifiers::empty(), | ||
| 517 | ); | ||
| 518 | assert_eq!(result, KeyAction::OpenCommitBrowser); | ||
| 519 | } | ||
| 520 | |||
| 521 | #[test] | ||
| 522 | fn test_handle_key_c_in_item_list_pane_is_noop() { | ||
| 523 | let mut app = make_app(3, 0); | ||
| 524 | app.pane = Pane::ItemList; | ||
| 525 | app.list_state.select(Some(0)); | ||
| 526 | let result = app.handle_key( | ||
| 527 | crossterm::event::KeyCode::Char('c'), | ||
| 528 | crossterm::event::KeyModifiers::empty(), | ||
| 529 | ); | ||
| 530 | assert_eq!(result, KeyAction::Continue); | ||
| 531 | } | ||
| 532 | |||
| 533 | #[test] | ||
| 534 | fn test_handle_key_c_no_selection_is_noop() { | ||
| 535 | let mut app = make_app(0, 0); | ||
| 536 | app.pane = Pane::Detail; | ||
| 537 | let result = app.handle_key( | ||
| 538 | crossterm::event::KeyCode::Char('c'), | ||
| 539 | crossterm::event::KeyModifiers::empty(), | ||
| 540 | ); | ||
| 541 | assert_eq!(result, KeyAction::Continue); | ||
| 542 | } | ||
| 543 | |||
| 544 | #[test] | ||
| 545 | fn test_handle_key_ctrl_c_still_quits() { | ||
| 546 | let mut app = make_app(3, 0); | ||
| 547 | let result = app.handle_key( | ||
| 548 | crossterm::event::KeyCode::Char('c'), | ||
| 549 | crossterm::event::KeyModifiers::CONTROL, | ||
| 550 | ); | ||
| 551 | assert_eq!(result, KeyAction::Quit); | ||
| 552 | } | ||
| 553 | |||
| 554 | // ── CommitList navigation tests ────────────────────────────────────── | ||
| 555 | |||
| 556 | #[test] | ||
| 557 | fn test_commit_list_navigate_down() { | ||
| 558 | let mut app = make_app(3, 0); | ||
| 559 | app.event_history = make_test_event_history(); | ||
| 560 | app.event_list_state.select(Some(0)); | ||
| 561 | app.mode = ViewMode::CommitList; | ||
| 562 | |||
| 563 | app.handle_key( | ||
| 564 | crossterm::event::KeyCode::Char('j'), | ||
| 565 | crossterm::event::KeyModifiers::empty(), | ||
| 566 | ); | ||
| 567 | assert_eq!(app.event_list_state.selected(), Some(1)); | ||
| 568 | } | ||
| 569 | |||
| 570 | #[test] | ||
| 571 | fn test_commit_list_navigate_up() { | ||
| 572 | let mut app = make_app(3, 0); | ||
| 573 | app.event_history = make_test_event_history(); | ||
| 574 | app.event_list_state.select(Some(2)); | ||
| 575 | app.mode = ViewMode::CommitList; | ||
| 576 | |||
| 577 | app.handle_key( | ||
| 578 | crossterm::event::KeyCode::Char('k'), | ||
| 579 | crossterm::event::KeyModifiers::empty(), | ||
| 580 | ); | ||
| 581 | assert_eq!(app.event_list_state.selected(), Some(1)); | ||
| 582 | } | ||
| 583 | |||
| 584 | #[test] | ||
| 585 | fn test_commit_list_navigate_clamp_bottom() { | ||
| 586 | let mut app = make_app(3, 0); | ||
| 587 | app.event_history = make_test_event_history(); | ||
| 588 | app.event_list_state.select(Some(2)); | ||
| 589 | app.mode = ViewMode::CommitList; | ||
| 590 | |||
| 591 | app.handle_key( | ||
| 592 | crossterm::event::KeyCode::Down, | ||
| 593 | crossterm::event::KeyModifiers::empty(), | ||
| 594 | ); | ||
| 595 | assert_eq!(app.event_list_state.selected(), Some(2)); | ||
| 596 | } | ||
| 597 | |||
| 598 | #[test] | ||
| 599 | fn test_commit_list_navigate_clamp_top() { | ||
| 600 | let mut app = make_app(3, 0); | ||
| 601 | app.event_history = make_test_event_history(); | ||
| 602 | app.event_list_state.select(Some(0)); | ||
| 603 | app.mode = ViewMode::CommitList; | ||
| 604 | |||
| 605 | app.handle_key( | ||
| 606 | crossterm::event::KeyCode::Up, | ||
| 607 | crossterm::event::KeyModifiers::empty(), | ||
| 608 | ); | ||
| 609 | assert_eq!(app.event_list_state.selected(), Some(0)); | ||
| 610 | } | ||
| 611 | |||
| 612 | #[test] | ||
| 613 | fn test_commit_list_escape_returns_to_details() { | ||
| 614 | let mut app = make_app(3, 0); | ||
| 615 | app.event_history = make_test_event_history(); | ||
| 616 | app.event_list_state.select(Some(1)); | ||
| 617 | app.mode = ViewMode::CommitList; | ||
| 618 | |||
| 619 | let result = app.handle_key( | ||
| 620 | crossterm::event::KeyCode::Esc, | ||
| 621 | crossterm::event::KeyModifiers::empty(), | ||
| 622 | ); | ||
| 623 | assert_eq!(result, KeyAction::Continue); | ||
| 624 | assert_eq!(app.mode, ViewMode::Details); | ||
| 625 | assert!(app.event_history.is_empty()); | ||
| 626 | assert_eq!(app.event_list_state.selected(), None); | ||
| 627 | } | ||
| 628 | |||
| 629 | #[test] | ||
| 630 | fn test_commit_list_q_quits() { | ||
| 631 | let mut app = make_app(3, 0); | ||
| 632 | app.mode = ViewMode::CommitList; | ||
| 633 | let result = app.handle_key( | ||
| 634 | crossterm::event::KeyCode::Char('q'), | ||
| 635 | crossterm::event::KeyModifiers::empty(), | ||
| 636 | ); | ||
| 637 | assert_eq!(result, KeyAction::Quit); | ||
| 638 | } | ||
| 639 | |||
| 640 | // ── CommitDetail tests ─────────────────────────────────────────────── | ||
| 641 | |||
| 642 | #[test] | ||
| 643 | fn test_commit_list_enter_opens_detail() { | ||
| 644 | let mut app = make_app(3, 0); | ||
| 645 | app.event_history = make_test_event_history(); | ||
| 646 | app.event_list_state.select(Some(1)); | ||
| 647 | app.mode = ViewMode::CommitList; | ||
| 648 | |||
| 649 | let result = app.handle_key( | ||
| 650 | crossterm::event::KeyCode::Enter, | ||
| 651 | crossterm::event::KeyModifiers::empty(), | ||
| 652 | ); | ||
| 653 | assert_eq!(result, KeyAction::Continue); | ||
| 654 | assert_eq!(app.mode, ViewMode::CommitDetail); | ||
| 655 | assert_eq!(app.scroll, 0); | ||
| 656 | } | ||
| 657 | |||
| 658 | #[test] | ||
| 659 | fn test_commit_list_enter_no_selection_stays() { | ||
| 660 | let mut app = make_app(3, 0); | ||
| 661 | app.event_history = make_test_event_history(); | ||
| 662 | app.event_list_state = ListState::default(); | ||
| 663 | app.mode = ViewMode::CommitList; | ||
| 664 | |||
| 665 | app.handle_key( | ||
| 666 | crossterm::event::KeyCode::Enter, | ||
| 667 | crossterm::event::KeyModifiers::empty(), | ||
| 668 | ); | ||
| 669 | assert_eq!(app.mode, ViewMode::CommitList); | ||
| 670 | } | ||
| 671 | |||
| 672 | #[test] | ||
| 673 | fn test_commit_detail_escape_returns_to_list() { | ||
| 674 | let mut app = make_app(3, 0); | ||
| 675 | app.event_history = make_test_event_history(); | ||
| 676 | app.event_list_state.select(Some(0)); | ||
| 677 | app.mode = ViewMode::CommitDetail; | ||
| 678 | app.scroll = 5; | ||
| 679 | |||
| 680 | let result = app.handle_key( | ||
| 681 | crossterm::event::KeyCode::Esc, | ||
| 682 | crossterm::event::KeyModifiers::empty(), | ||
| 683 | ); | ||
| 684 | assert_eq!(result, KeyAction::Continue); | ||
| 685 | assert_eq!(app.mode, ViewMode::CommitList); | ||
| 686 | assert_eq!(app.scroll, 0); | ||
| 687 | assert_eq!(app.event_history.len(), 3); | ||
| 688 | } | ||
| 689 | |||
| 690 | #[test] | ||
| 691 | fn test_commit_detail_scroll() { | ||
| 692 | let mut app = make_app(3, 0); | ||
| 693 | app.mode = ViewMode::CommitDetail; | ||
| 694 | app.scroll = 0; | ||
| 695 | |||
| 696 | app.handle_key( | ||
| 697 | crossterm::event::KeyCode::Char('j'), | ||
| 698 | crossterm::event::KeyModifiers::empty(), | ||
| 699 | ); | ||
| 700 | assert_eq!(app.scroll, 1); | ||
| 701 | app.handle_key( | ||
| 702 | crossterm::event::KeyCode::Char('j'), | ||
| 703 | crossterm::event::KeyModifiers::empty(), | ||
| 704 | ); | ||
| 705 | assert_eq!(app.scroll, 2); | ||
| 706 | app.handle_key( | ||
| 707 | crossterm::event::KeyCode::Char('k'), | ||
| 708 | crossterm::event::KeyModifiers::empty(), | ||
| 709 | ); | ||
| 710 | assert_eq!(app.scroll, 1); | ||
| 711 | } | ||
| 712 | |||
| 713 | #[test] | ||
| 714 | fn test_commit_detail_page_scroll() { | ||
| 715 | let mut app = make_app(3, 0); | ||
| 716 | app.mode = ViewMode::CommitDetail; | ||
| 717 | app.scroll = 0; | ||
| 718 | |||
| 719 | app.handle_key( | ||
| 720 | crossterm::event::KeyCode::PageDown, | ||
| 721 | crossterm::event::KeyModifiers::empty(), | ||
| 722 | ); | ||
| 723 | assert_eq!(app.scroll, 20); | ||
| 724 | app.handle_key( | ||
| 725 | crossterm::event::KeyCode::PageUp, | ||
| 726 | crossterm::event::KeyModifiers::empty(), | ||
| 727 | ); | ||
| 728 | assert_eq!(app.scroll, 0); | ||
| 729 | } | ||
| 730 | |||
| 731 | #[test] | ||
| 732 | fn test_commit_detail_q_quits() { | ||
| 733 | let mut app = make_app(3, 0); | ||
| 734 | app.mode = ViewMode::CommitDetail; | ||
| 735 | let result = app.handle_key( | ||
| 736 | crossterm::event::KeyCode::Char('q'), | ||
| 737 | crossterm::event::KeyModifiers::empty(), | ||
| 738 | ); | ||
| 739 | assert_eq!(result, KeyAction::Quit); | ||
| 740 | } | ||
| 741 | |||
| 742 | // ── Guard tests ────────────────────────────────────────────────────── | ||
| 743 | |||
| 744 | #[test] | ||
| 745 | fn test_c_ignored_in_commit_list_mode() { | ||
| 746 | let mut app = make_app(3, 0); | ||
| 747 | app.mode = ViewMode::CommitList; | ||
| 748 | let result = app.handle_key( | ||
| 749 | crossterm::event::KeyCode::Char('c'), | ||
| 750 | crossterm::event::KeyModifiers::empty(), | ||
| 751 | ); | ||
| 752 | assert_eq!(result, KeyAction::Continue); | ||
| 753 | assert_eq!(app.mode, ViewMode::CommitList); | ||
| 754 | } | ||
| 755 | |||
| 756 | #[test] | ||
| 757 | fn test_c_ignored_in_commit_detail_mode() { | ||
| 758 | let mut app = make_app(3, 0); | ||
| 759 | app.mode = ViewMode::CommitDetail; | ||
| 760 | let result = app.handle_key( | ||
| 761 | crossterm::event::KeyCode::Char('c'), | ||
| 762 | crossterm::event::KeyModifiers::empty(), | ||
| 763 | ); | ||
| 764 | assert_eq!(result, KeyAction::Continue); | ||
| 765 | assert_eq!(app.mode, ViewMode::CommitDetail); | ||
| 766 | } | ||
| 767 | |||
| 768 | // ── handle_key basic tests ─────────────────────────────────────────── | ||
| 769 | |||
| 770 | #[test] | ||
| 771 | fn test_handle_key_quit() { | ||
| 772 | let mut app = make_app(3, 3); | ||
| 773 | assert_eq!( | ||
| 774 | app.handle_key( | ||
| 775 | crossterm::event::KeyCode::Char('q'), | ||
| 776 | crossterm::event::KeyModifiers::empty() | ||
| 777 | ), | ||
| 778 | KeyAction::Quit | ||
| 779 | ); | ||
| 780 | } | ||
| 781 | |||
| 782 | #[test] | ||
| 783 | fn test_handle_key_quit_esc() { | ||
| 784 | let mut app = make_app(3, 3); | ||
| 785 | assert_eq!( | ||
| 786 | app.handle_key( | ||
| 787 | crossterm::event::KeyCode::Esc, | ||
| 788 | crossterm::event::KeyModifiers::empty() | ||
| 789 | ), | ||
| 790 | KeyAction::Quit | ||
| 791 | ); | ||
| 792 | } | ||
| 793 | |||
| 794 | #[test] | ||
| 795 | fn test_handle_key_quit_ctrl_c() { | ||
| 796 | let mut app = make_app(3, 3); | ||
| 797 | assert_eq!( | ||
| 798 | app.handle_key( | ||
| 799 | crossterm::event::KeyCode::Char('c'), | ||
| 800 | crossterm::event::KeyModifiers::CONTROL | ||
| 801 | ), | ||
| 802 | KeyAction::Quit | ||
| 803 | ); | ||
| 804 | } | ||
| 805 | |||
| 806 | #[test] | ||
| 807 | fn test_handle_key_pane_toggle() { | ||
| 808 | let mut app = make_app(3, 3); | ||
| 809 | assert_eq!(app.pane, Pane::ItemList); | ||
| 810 | app.handle_key( | ||
| 811 | crossterm::event::KeyCode::Tab, | ||
| 812 | crossterm::event::KeyModifiers::empty(), | ||
| 813 | ); | ||
| 814 | assert_eq!(app.pane, Pane::Detail); | ||
| 815 | app.handle_key( | ||
| 816 | crossterm::event::KeyCode::Enter, | ||
| 817 | crossterm::event::KeyModifiers::empty(), | ||
| 818 | ); | ||
| 819 | assert_eq!(app.pane, Pane::ItemList); | ||
| 820 | } | ||
| 821 | |||
| 822 | #[test] | ||
| 823 | fn test_scroll_in_detail_pane() { | ||
| 824 | let mut app = make_app(3, 0); | ||
| 825 | app.list_state.select(Some(0)); | ||
| 826 | app.pane = Pane::Detail; | ||
| 827 | app.scroll = 0; | ||
| 828 | |||
| 829 | app.handle_key( | ||
| 830 | crossterm::event::KeyCode::Char('j'), | ||
| 831 | crossterm::event::KeyModifiers::empty(), | ||
| 832 | ); | ||
| 833 | assert_eq!(app.scroll, 1); | ||
| 834 | assert_eq!(app.list_state.selected(), Some(0)); | ||
| 835 | |||
| 836 | app.handle_key( | ||
| 837 | crossterm::event::KeyCode::Char('k'), | ||
| 838 | crossterm::event::KeyModifiers::empty(), | ||
| 839 | ); | ||
| 840 | assert_eq!(app.scroll, 0); | ||
| 841 | } | ||
| 842 | |||
| 843 | #[test] | ||
| 844 | fn test_handle_key_reload() { | ||
| 845 | let mut app = make_app(3, 3); | ||
| 846 | assert_eq!( | ||
| 847 | app.handle_key( | ||
| 848 | crossterm::event::KeyCode::Char('r'), | ||
| 849 | crossterm::event::KeyModifiers::empty() | ||
| 850 | ), | ||
| 851 | KeyAction::Reload | ||
| 852 | ); | ||
| 853 | } | ||
| 854 | |||
| 855 | // ── selected_ref_name tests ────────────────────────────────────────── | ||
| 856 | |||
| 857 | #[test] | ||
| 858 | fn test_selected_ref_name_issues() { | ||
| 859 | let app = make_app(3, 0); | ||
| 860 | let ref_name = app.selected_ref_name(); | ||
| 861 | assert_eq!( | ||
| 862 | ref_name, | ||
| 863 | Some("refs/collab/issues/00000000".to_string()) | ||
| 864 | ); | ||
| 865 | } | ||
| 866 | |||
| 867 | #[test] | ||
| 868 | fn test_selected_ref_name_none_when_empty() { | ||
| 869 | let app = make_app(0, 0); | ||
| 870 | assert_eq!(app.selected_ref_name(), None); | ||
| 871 | } | ||
| 872 | |||
| 873 | // ── Render tests ───────────────────────────────────────────────────── | ||
| 874 | |||
| 875 | #[test] | ||
| 876 | fn test_render_issues_tab() { | ||
| 877 | let mut app = make_app(3, 2); | ||
| 878 | app.status_filter = StatusFilter::All; | ||
| 879 | let buf = render_app(&mut app); | ||
| 880 | assert_buffer_contains(&buf, "Issues"); | ||
| 881 | assert_buffer_contains(&buf, "00000000"); | ||
| 882 | assert_buffer_contains(&buf, "00000001"); | ||
| 883 | assert_buffer_contains(&buf, "00000002"); | ||
| 884 | assert_buffer_contains(&buf, "Issue 0"); | ||
| 885 | } | ||
| 886 | |||
| 887 | #[test] | ||
| 888 | fn test_render_empty_state() { | ||
| 889 | let mut app = make_app(0, 0); | ||
| 890 | let buf = render_app(&mut app); | ||
| 891 | assert_buffer_contains(&buf, "No matches for current filter."); | ||
| 892 | } | ||
| 893 | |||
| 894 | #[test] | ||
| 895 | fn test_render_footer_keys() { | ||
| 896 | let mut app = make_app(3, 3); | ||
| 897 | let buf = render_app(&mut app); | ||
| 898 | assert_buffer_contains(&buf, "j/k:navigate"); | ||
| 899 | assert_buffer_contains(&buf, "Tab:pane"); | ||
| 900 | assert_buffer_contains(&buf, "c:events"); | ||
| 901 | } | ||
| 902 | |||
| 903 | #[test] | ||
| 904 | fn test_render_commit_list() { | ||
| 905 | let mut app = make_app(3, 0); | ||
| 906 | app.event_history = make_test_event_history(); | ||
| 907 | app.event_list_state.select(Some(0)); | ||
| 908 | app.mode = ViewMode::CommitList; | ||
| 909 | let buf = render_app(&mut app); | ||
| 910 | assert_buffer_contains(&buf, "Event History"); | ||
| 911 | assert_buffer_contains(&buf, "Issue Open"); | ||
| 912 | assert_buffer_contains(&buf, "Issue Comment"); | ||
| 913 | assert_buffer_contains(&buf, "Issue Close"); | ||
| 914 | } | ||
| 915 | |||
| 916 | #[test] | ||
| 917 | fn test_render_commit_detail() { | ||
| 918 | let mut app = make_app(3, 0); | ||
| 919 | app.event_history = make_test_event_history(); | ||
| 920 | app.event_list_state.select(Some(0)); | ||
| 921 | app.mode = ViewMode::CommitDetail; | ||
| 922 | let buf = render_app(&mut app); | ||
| 923 | assert_buffer_contains(&buf, "Event Detail"); | ||
| 924 | assert_buffer_contains(&buf, "aaaaaaa"); | ||
| 925 | assert_buffer_contains(&buf, "Test User"); | ||
| 926 | assert_buffer_contains(&buf, "Issue Open"); | ||
| 927 | } | ||
| 928 | |||
| 929 | #[test] | ||
| 930 | fn test_render_commit_list_footer() { | ||
| 931 | let mut app = make_app(3, 0); | ||
| 932 | app.mode = ViewMode::CommitList; | ||
| 933 | let buf = render_app(&mut app); | ||
| 934 | assert_buffer_contains(&buf, "Esc:back"); | ||
| 935 | } | ||
| 936 | |||
| 937 | #[test] | ||
| 938 | fn test_render_commit_detail_footer() { | ||
| 939 | let mut app = make_app(3, 0); | ||
| 940 | app.mode = ViewMode::CommitDetail; | ||
| 941 | let buf = render_app(&mut app); | ||
| 942 | assert_buffer_contains(&buf, "Esc:back"); | ||
| 943 | assert_buffer_contains(&buf, "j/k:scroll"); | ||
| 944 | } | ||
| 945 | |||
| 946 | #[test] | ||
| 947 | fn test_render_small_terminal() { | ||
| 948 | let mut app = make_app(3, 3); | ||
| 949 | let backend = TestBackend::new(20, 10); | ||
| 950 | let mut terminal = Terminal::new(backend).unwrap(); | ||
| 951 | terminal.draw(|frame| ui(frame, &mut app)).unwrap(); | ||
| 952 | } | ||
| 953 | |||
| 954 | // ── Integration: full browse flow ──────────────────────────────────── | ||
| 955 | |||
| 956 | #[test] | ||
| 957 | fn test_full_commit_browse_flow() { | ||
| 958 | let mut app = make_app(3, 0); | ||
| 959 | app.pane = Pane::Detail; | ||
| 960 | app.list_state.select(Some(0)); | ||
| 961 | |||
| 962 | let action = app.handle_key( | ||
| 963 | crossterm::event::KeyCode::Char('c'), | ||
| 964 | crossterm::event::KeyModifiers::empty(), | ||
| 965 | ); | ||
| 966 | assert_eq!(action, KeyAction::OpenCommitBrowser); | ||
| 967 | |||
| 968 | app.event_history = make_test_event_history(); | ||
| 969 | app.event_list_state.select(Some(0)); | ||
| 970 | app.mode = ViewMode::CommitList; | ||
| 971 | app.scroll = 0; | ||
| 972 | |||
| 973 | app.handle_key( | ||
| 974 | crossterm::event::KeyCode::Char('j'), | ||
| 975 | crossterm::event::KeyModifiers::empty(), | ||
| 976 | ); | ||
| 977 | assert_eq!(app.event_list_state.selected(), Some(1)); | ||
| 978 | |||
| 979 | app.handle_key( | ||
| 980 | crossterm::event::KeyCode::Enter, | ||
| 981 | crossterm::event::KeyModifiers::empty(), | ||
| 982 | ); | ||
| 983 | assert_eq!(app.mode, ViewMode::CommitDetail); | ||
| 984 | |||
| 985 | app.handle_key( | ||
| 986 | crossterm::event::KeyCode::Char('j'), | ||
| 987 | crossterm::event::KeyModifiers::empty(), | ||
| 988 | ); | ||
| 989 | assert_eq!(app.scroll, 1); | ||
| 990 | |||
| 991 | app.handle_key( | ||
| 992 | crossterm::event::KeyCode::Esc, | ||
| 993 | crossterm::event::KeyModifiers::empty(), | ||
| 994 | ); | ||
| 995 | assert_eq!(app.mode, ViewMode::CommitList); | ||
| 996 | assert_eq!(app.scroll, 0); | ||
| 997 | |||
| 998 | app.handle_key( | ||
| 999 | crossterm::event::KeyCode::Esc, | ||
| 1000 | crossterm::event::KeyModifiers::empty(), | ||
| 1001 | ); | ||
| 1002 | assert_eq!(app.mode, ViewMode::Details); | ||
| 1003 | assert!(app.event_history.is_empty()); | ||
| 1004 | } | ||
| 1005 | |||
| 1006 | // ── Status message render test ─────────────────────────────────────── | ||
| 1007 | |||
| 1008 | #[test] | ||
| 1009 | fn test_render_status_message() { | ||
| 1010 | let mut app = make_app(3, 0); | ||
| 1011 | app.status_msg = Some("Error loading events: ref not found".to_string()); | ||
| 1012 | let buf = render_app(&mut app); | ||
| 1013 | assert_buffer_contains(&buf, "Error loading events"); | ||
| 1014 | } | ||
| 1015 | } | ||
src/tui/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,301 @@ | |||
| 1 | use crossterm::event::{KeyCode, KeyModifiers}; | ||
| 2 | use git2::{Oid, Repository}; | ||
| 3 | use ratatui::widgets::ListState; | ||
| 4 | |||
| 5 | use crate::state::{self, IssueState, IssueStatus, PatchState}; | ||
| 6 | |||
| 7 | #[derive(Debug, PartialEq)] | ||
| 8 | pub(crate) enum Pane { | ||
| 9 | ItemList, | ||
| 10 | Detail, | ||
| 11 | } | ||
| 12 | |||
| 13 | #[derive(Debug, PartialEq)] | ||
| 14 | pub(crate) enum ViewMode { | ||
| 15 | Details, | ||
| 16 | CommitList, | ||
| 17 | CommitDetail, | ||
| 18 | } | ||
| 19 | |||
| 20 | #[derive(Debug, PartialEq)] | ||
| 21 | pub(crate) enum KeyAction { | ||
| 22 | Continue, | ||
| 23 | Quit, | ||
| 24 | Reload, | ||
| 25 | OpenCommitBrowser, | ||
| 26 | } | ||
| 27 | |||
| 28 | #[derive(Debug, PartialEq, Clone, Copy)] | ||
| 29 | pub(crate) enum StatusFilter { | ||
| 30 | Open, | ||
| 31 | Closed, | ||
| 32 | All, | ||
| 33 | } | ||
| 34 | |||
| 35 | impl StatusFilter { | ||
| 36 | pub(crate) fn next(self) -> Self { | ||
| 37 | match self { | ||
| 38 | StatusFilter::Open => StatusFilter::All, | ||
| 39 | StatusFilter::All => StatusFilter::Closed, | ||
| 40 | StatusFilter::Closed => StatusFilter::Open, | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | pub(crate) fn label(self) -> &'static str { | ||
| 45 | match self { | ||
| 46 | StatusFilter::Open => "open", | ||
| 47 | StatusFilter::Closed => "closed", | ||
| 48 | StatusFilter::All => "all", | ||
| 49 | } | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | #[derive(Debug, PartialEq)] | ||
| 54 | pub(crate) enum InputMode { | ||
| 55 | Normal, | ||
| 56 | Search, | ||
| 57 | CreateTitle, | ||
| 58 | CreateBody, | ||
| 59 | } | ||
| 60 | |||
| 61 | pub(crate) struct App { | ||
| 62 | pub(crate) issues: Vec<IssueState>, | ||
| 63 | pub(crate) patches: Vec<PatchState>, | ||
| 64 | pub(crate) list_state: ListState, | ||
| 65 | pub(crate) scroll: u16, | ||
| 66 | pub(crate) pane: Pane, | ||
| 67 | pub(crate) mode: ViewMode, | ||
| 68 | pub(crate) status_filter: StatusFilter, | ||
| 69 | pub(crate) search_query: String, | ||
| 70 | pub(crate) input_mode: InputMode, | ||
| 71 | pub(crate) input_buf: String, | ||
| 72 | pub(crate) create_title: String, | ||
| 73 | pub(crate) status_msg: Option<String>, | ||
| 74 | pub(crate) event_history: Vec<(Oid, crate::event::Event)>, | ||
| 75 | pub(crate) event_list_state: ListState, | ||
| 76 | } | ||
| 77 | |||
| 78 | impl App { | ||
| 79 | pub(crate) fn new(issues: Vec<IssueState>, patches: Vec<PatchState>) -> Self { | ||
| 80 | let mut list_state = ListState::default(); | ||
| 81 | if !issues.is_empty() { | ||
| 82 | list_state.select(Some(0)); | ||
| 83 | } | ||
| 84 | Self { | ||
| 85 | issues, | ||
| 86 | patches, | ||
| 87 | list_state, | ||
| 88 | scroll: 0, | ||
| 89 | pane: Pane::ItemList, | ||
| 90 | mode: ViewMode::Details, | ||
| 91 | status_filter: StatusFilter::Open, | ||
| 92 | search_query: String::new(), | ||
| 93 | input_mode: InputMode::Normal, | ||
| 94 | input_buf: String::new(), | ||
| 95 | create_title: String::new(), | ||
| 96 | status_msg: None, | ||
| 97 | event_history: Vec::new(), | ||
| 98 | event_list_state: ListState::default(), | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | pub(crate) fn matches_search(&self, title: &str) -> bool { | ||
| 103 | if self.search_query.is_empty() { | ||
| 104 | return true; | ||
| 105 | } | ||
| 106 | title | ||
| 107 | .to_lowercase() | ||
| 108 | .contains(&self.search_query.to_lowercase()) | ||
| 109 | } | ||
| 110 | |||
| 111 | pub(crate) fn visible_issues(&self) -> Vec<&IssueState> { | ||
| 112 | self.issues | ||
| 113 | .iter() | ||
| 114 | .filter(|i| match self.status_filter { | ||
| 115 | StatusFilter::Open => i.status == IssueStatus::Open, | ||
| 116 | StatusFilter::Closed => i.status == IssueStatus::Closed, | ||
| 117 | StatusFilter::All => true, | ||
| 118 | }) | ||
| 119 | .filter(|i| self.matches_search(&i.title)) | ||
| 120 | .collect() | ||
| 121 | } | ||
| 122 | |||
| 123 | pub(crate) fn visible_count(&self) -> usize { | ||
| 124 | self.visible_issues().len() | ||
| 125 | } | ||
| 126 | |||
| 127 | pub(crate) fn move_selection(&mut self, delta: i32) { | ||
| 128 | let len = self.visible_count(); | ||
| 129 | if len == 0 { | ||
| 130 | return; | ||
| 131 | } | ||
| 132 | let current = self.list_state.selected().unwrap_or(0); | ||
| 133 | let new = if delta > 0 { | ||
| 134 | (current + delta as usize).min(len - 1) | ||
| 135 | } else { | ||
| 136 | current.saturating_sub((-delta) as usize) | ||
| 137 | }; | ||
| 138 | self.list_state.select(Some(new)); | ||
| 139 | self.scroll = 0; | ||
| 140 | } | ||
| 141 | |||
| 142 | pub(crate) fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction { | ||
| 143 | // Handle CommitDetail mode first | ||
| 144 | if self.mode == ViewMode::CommitDetail { | ||
| 145 | match code { | ||
| 146 | KeyCode::Esc => { | ||
| 147 | self.mode = ViewMode::CommitList; | ||
| 148 | self.scroll = 0; | ||
| 149 | return KeyAction::Continue; | ||
| 150 | } | ||
| 151 | KeyCode::Char('q') => return KeyAction::Quit, | ||
| 152 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { | ||
| 153 | return KeyAction::Quit; | ||
| 154 | } | ||
| 155 | KeyCode::Char('j') | KeyCode::Down => { | ||
| 156 | self.scroll = self.scroll.saturating_add(1); | ||
| 157 | return KeyAction::Continue; | ||
| 158 | } | ||
| 159 | KeyCode::Char('k') | KeyCode::Up => { | ||
| 160 | self.scroll = self.scroll.saturating_sub(1); | ||
| 161 | return KeyAction::Continue; | ||
| 162 | } | ||
| 163 | KeyCode::PageDown => { | ||
| 164 | self.scroll = self.scroll.saturating_add(20); | ||
| 165 | return KeyAction::Continue; | ||
| 166 | } | ||
| 167 | KeyCode::PageUp => { | ||
| 168 | self.scroll = self.scroll.saturating_sub(20); | ||
| 169 | return KeyAction::Continue; | ||
| 170 | } | ||
| 171 | _ => return KeyAction::Continue, | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 175 | // Handle CommitList mode | ||
| 176 | if self.mode == ViewMode::CommitList { | ||
| 177 | match code { | ||
| 178 | KeyCode::Esc => { | ||
| 179 | self.event_history.clear(); | ||
| 180 | self.event_list_state = ListState::default(); | ||
| 181 | self.mode = ViewMode::Details; | ||
| 182 | self.scroll = 0; | ||
| 183 | return KeyAction::Continue; | ||
| 184 | } | ||
| 185 | KeyCode::Char('q') => return KeyAction::Quit, | ||
| 186 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { | ||
| 187 | return KeyAction::Quit; | ||
| 188 | } | ||
| 189 | KeyCode::Char('j') | KeyCode::Down => { | ||
| 190 | let len = self.event_history.len(); | ||
| 191 | if len > 0 { | ||
| 192 | let current = self.event_list_state.selected().unwrap_or(0); | ||
| 193 | let new = (current + 1).min(len - 1); | ||
| 194 | self.event_list_state.select(Some(new)); | ||
| 195 | } | ||
| 196 | return KeyAction::Continue; | ||
| 197 | } | ||
| 198 | KeyCode::Char('k') | KeyCode::Up => { | ||
| 199 | if !self.event_history.is_empty() { | ||
| 200 | let current = self.event_list_state.selected().unwrap_or(0); | ||
| 201 | let new = current.saturating_sub(1); | ||
| 202 | self.event_list_state.select(Some(new)); | ||
| 203 | } | ||
| 204 | return KeyAction::Continue; | ||
| 205 | } | ||
| 206 | KeyCode::Enter => { | ||
| 207 | if self.event_list_state.selected().is_some() { | ||
| 208 | self.mode = ViewMode::CommitDetail; | ||
| 209 | self.scroll = 0; | ||
| 210 | } | ||
| 211 | return KeyAction::Continue; | ||
| 212 | } | ||
| 213 | _ => return KeyAction::Continue, | ||
| 214 | } | ||
| 215 | } | ||
| 216 | |||
| 217 | // Normal Details mode handling | ||
| 218 | match code { | ||
| 219 | KeyCode::Char('q') | KeyCode::Esc => KeyAction::Quit, | ||
| 220 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => KeyAction::Quit, | ||
| 221 | KeyCode::Char('c') => { | ||
| 222 | // Open commit browser: only when in detail pane with an item selected | ||
| 223 | if self.pane == Pane::Detail && self.list_state.selected().is_some() { | ||
| 224 | KeyAction::OpenCommitBrowser | ||
| 225 | } else { | ||
| 226 | KeyAction::Continue | ||
| 227 | } | ||
| 228 | } | ||
| 229 | KeyCode::Char('j') | KeyCode::Down => { | ||
| 230 | if self.pane == Pane::ItemList { | ||
| 231 | self.move_selection(1); | ||
| 232 | } else { | ||
| 233 | self.scroll = self.scroll.saturating_add(1); | ||
| 234 | } | ||
| 235 | KeyAction::Continue | ||
| 236 | } | ||
| 237 | KeyCode::Char('k') | KeyCode::Up => { | ||
| 238 | if self.pane == Pane::ItemList { | ||
| 239 | self.move_selection(-1); | ||
| 240 | } else { | ||
| 241 | self.scroll = self.scroll.saturating_sub(1); | ||
| 242 | } | ||
| 243 | KeyAction::Continue | ||
| 244 | } | ||
| 245 | KeyCode::PageDown => { | ||
| 246 | self.scroll = self.scroll.saturating_add(20); | ||
| 247 | KeyAction::Continue | ||
| 248 | } | ||
| 249 | KeyCode::PageUp => { | ||
| 250 | self.scroll = self.scroll.saturating_sub(20); | ||
| 251 | KeyAction::Continue | ||
| 252 | } | ||
| 253 | KeyCode::Tab | KeyCode::Enter => { | ||
| 254 | self.pane = match self.pane { | ||
| 255 | Pane::ItemList => Pane::Detail, | ||
| 256 | Pane::Detail => Pane::ItemList, | ||
| 257 | }; | ||
| 258 | KeyAction::Continue | ||
| 259 | } | ||
| 260 | KeyCode::Char('a') => { | ||
| 261 | self.status_filter = self.status_filter.next(); | ||
| 262 | let count = self.visible_count(); | ||
| 263 | self.list_state | ||
| 264 | .select(if count > 0 { Some(0) } else { None }); | ||
| 265 | KeyAction::Continue | ||
| 266 | } | ||
| 267 | KeyCode::Char('r') => KeyAction::Reload, | ||
| 268 | _ => KeyAction::Continue, | ||
| 269 | } | ||
| 270 | } | ||
| 271 | |||
| 272 | pub(crate) fn selected_item_id(&self) -> Option<String> { | ||
| 273 | let idx = self.list_state.selected()?; | ||
| 274 | let visible = self.visible_issues(); | ||
| 275 | visible.get(idx).map(|i| i.id.clone()) | ||
| 276 | } | ||
| 277 | |||
| 278 | pub(crate) fn selected_ref_name(&self) -> Option<String> { | ||
| 279 | let id = self.selected_item_id()?; | ||
| 280 | Some(format!("refs/collab/issues/{}", id)) | ||
| 281 | } | ||
| 282 | |||
| 283 | pub(crate) fn reload(&mut self, repo: &Repository) { | ||
| 284 | if let Ok(issues) = state::list_issues(repo) { | ||
| 285 | self.issues = issues; | ||
| 286 | } | ||
| 287 | if let Ok(patches) = state::list_patches(repo) { | ||
| 288 | self.patches = patches; | ||
| 289 | } | ||
| 290 | let visible_len = self.visible_count(); | ||
| 291 | if let Some(sel) = self.list_state.selected() { | ||
| 292 | if sel >= visible_len { | ||
| 293 | self.list_state.select(if visible_len > 0 { | ||
| 294 | Some(visible_len - 1) | ||
| 295 | } else { | ||
| 296 | None | ||
| 297 | }); | ||
| 298 | } | ||
| 299 | } | ||
| 300 | } | ||
| 301 | } | ||
src/tui/widgets.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,463 @@ | |||
| 1 | use git2::Oid; | ||
| 2 | use ratatui::prelude::*; | ||
| 3 | use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; | ||
| 4 | |||
| 5 | use crate::event::Action; | ||
| 6 | use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus}; | ||
| 7 | |||
| 8 | use super::state::{App, InputMode, Pane, StatusFilter, ViewMode}; | ||
| 9 | |||
| 10 | pub(crate) fn action_type_label(action: &Action) -> &str { | ||
| 11 | match action { | ||
| 12 | Action::IssueOpen { .. } => "Issue Open", | ||
| 13 | Action::IssueComment { .. } => "Issue Comment", | ||
| 14 | Action::IssueClose { .. } => "Issue Close", | ||
| 15 | Action::IssueReopen => "Issue Reopen", | ||
| 16 | Action::PatchCreate { .. } => "Patch Create", | ||
| 17 | Action::PatchRevise { .. } => "Patch Revise", | ||
| 18 | Action::PatchReview { .. } => "Patch Review", | ||
| 19 | Action::PatchComment { .. } => "Patch Comment", | ||
| 20 | Action::PatchInlineComment { .. } => "Inline Comment", | ||
| 21 | Action::PatchClose { .. } => "Patch Close", | ||
| 22 | Action::PatchMerge => "Patch Merge", | ||
| 23 | Action::Merge => "Merge", | ||
| 24 | Action::IssueEdit { .. } => "Issue Edit", | ||
| 25 | Action::IssueLabel { .. } => "Issue Label", | ||
| 26 | Action::IssueUnlabel { .. } => "Issue Unlabel", | ||
| 27 | Action::IssueAssign { .. } => "Issue Assign", | ||
| 28 | Action::IssueUnassign { .. } => "Issue Unassign", | ||
| 29 | } | ||
| 30 | } | ||
| 31 | |||
| 32 | pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> String { | ||
| 33 | let short_oid = &oid.to_string()[..7]; | ||
| 34 | let action_label = action_type_label(&event.action); | ||
| 35 | |||
| 36 | let mut detail = format!( | ||
| 37 | "Commit: {}\nAuthor: {} <{}>\nDate: {}\nType: {}\n", | ||
| 38 | short_oid, event.author.name, event.author.email, event.timestamp, action_label, | ||
| 39 | ); | ||
| 40 | |||
| 41 | // Action-specific payload | ||
| 42 | match &event.action { | ||
| 43 | Action::IssueOpen { title, body, .. } => { | ||
| 44 | detail.push_str(&format!("\nTitle: {}\n", title)); | ||
| 45 | if !body.is_empty() { | ||
| 46 | detail.push_str(&format!("\n{}\n", body)); | ||
| 47 | } | ||
| 48 | } | ||
| 49 | Action::IssueComment { body } | Action::PatchComment { body } => { | ||
| 50 | detail.push_str(&format!("\n{}\n", body)); | ||
| 51 | } | ||
| 52 | Action::IssueClose { reason } | Action::PatchClose { reason } => { | ||
| 53 | if let Some(r) = reason { | ||
| 54 | detail.push_str(&format!("\nReason: {}\n", r)); | ||
| 55 | } | ||
| 56 | } | ||
| 57 | Action::PatchCreate { | ||
| 58 | title, | ||
| 59 | body, | ||
| 60 | base_ref, | ||
| 61 | branch, | ||
| 62 | .. | ||
| 63 | } => { | ||
| 64 | detail.push_str(&format!("\nTitle: {}\n", title)); | ||
| 65 | detail.push_str(&format!("Base: {}\n", base_ref)); | ||
| 66 | detail.push_str(&format!("Branch: {}\n", branch)); | ||
| 67 | if !body.is_empty() { | ||
| 68 | detail.push_str(&format!("\n{}\n", body)); | ||
| 69 | } | ||
| 70 | } | ||
| 71 | Action::PatchRevise { body } => { | ||
| 72 | if let Some(b) = body { | ||
| 73 | if !b.is_empty() { | ||
| 74 | detail.push_str(&format!("\n{}\n", b)); | ||
| 75 | } | ||
| 76 | } | ||
| 77 | } | ||
| 78 | Action::PatchReview { verdict, body } => { | ||
| 79 | detail.push_str(&format!("\nVerdict: {:?}\n", verdict)); | ||
| 80 | if !body.is_empty() { | ||
| 81 | detail.push_str(&format!("\n{}\n", body)); | ||
| 82 | } | ||
| 83 | } | ||
| 84 | Action::PatchInlineComment { file, line, body } => { | ||
| 85 | detail.push_str(&format!("\nFile: {}:{}\n", file, line)); | ||
| 86 | if !body.is_empty() { | ||
| 87 | detail.push_str(&format!("\n{}\n", body)); | ||
| 88 | } | ||
| 89 | } | ||
| 90 | Action::IssueEdit { title, body } => { | ||
| 91 | if let Some(t) = title { | ||
| 92 | detail.push_str(&format!("\nNew Title: {}\n", t)); | ||
| 93 | } | ||
| 94 | if let Some(b) = body { | ||
| 95 | if !b.is_empty() { | ||
| 96 | detail.push_str(&format!("\nNew Body: {}\n", b)); | ||
| 97 | } | ||
| 98 | } | ||
| 99 | } | ||
| 100 | Action::IssueLabel { label } => { | ||
| 101 | detail.push_str(&format!("\nLabel: {}\n", label)); | ||
| 102 | } | ||
| 103 | Action::IssueUnlabel { label } => { | ||
| 104 | detail.push_str(&format!("\nRemoved Label: {}\n", label)); | ||
| 105 | } | ||
| 106 | Action::IssueAssign { assignee } => { | ||
| 107 | detail.push_str(&format!("\nAssignee: {}\n", assignee)); | ||
| 108 | } | ||
| 109 | Action::IssueUnassign { assignee } => { | ||
| 110 | detail.push_str(&format!("\nRemoved Assignee: {}\n", assignee)); | ||
| 111 | } | ||
| 112 | Action::IssueReopen | Action::PatchMerge | Action::Merge => {} | ||
| 113 | } | ||
| 114 | |||
| 115 | detail | ||
| 116 | } | ||
| 117 | |||
| 118 | pub(crate) fn ui(frame: &mut Frame, app: &mut App) { | ||
| 119 | let chunks = Layout::default() | ||
| 120 | .direction(Direction::Vertical) | ||
| 121 | .constraints([ | ||
| 122 | Constraint::Min(1), | ||
| 123 | Constraint::Length(1), | ||
| 124 | ]) | ||
| 125 | .split(frame.area()); | ||
| 126 | |||
| 127 | let main_area = chunks[0]; | ||
| 128 | let footer_area = chunks[1]; | ||
| 129 | |||
| 130 | let panes = Layout::default() | ||
| 131 | .direction(Direction::Horizontal) | ||
| 132 | .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) | ||
| 133 | .split(main_area); | ||
| 134 | |||
| 135 | render_list(frame, app, panes[0]); | ||
| 136 | render_detail(frame, app, panes[1]); | ||
| 137 | render_footer(frame, app, footer_area); | ||
| 138 | } | ||
| 139 | |||
| 140 | fn render_list(frame: &mut Frame, app: &mut App, area: Rect) { | ||
| 141 | let border_style = if app.pane == Pane::ItemList { | ||
| 142 | Style::default().fg(Color::Yellow) | ||
| 143 | } else { | ||
| 144 | Style::default().fg(Color::DarkGray) | ||
| 145 | }; | ||
| 146 | |||
| 147 | let visible = app.visible_issues(); | ||
| 148 | let items: Vec<ListItem> = visible | ||
| 149 | .iter() | ||
| 150 | .map(|i| { | ||
| 151 | let status = match i.status { | ||
| 152 | IssueStatus::Open => "open", | ||
| 153 | IssueStatus::Closed => "closed", | ||
| 154 | }; | ||
| 155 | let style = match i.status { | ||
| 156 | IssueStatus::Open => Style::default().fg(Color::Green), | ||
| 157 | IssueStatus::Closed => Style::default().fg(Color::Red), | ||
| 158 | }; | ||
| 159 | ListItem::new(format!("{:.8} {:6} {}", i.id, status, i.title)).style(style) | ||
| 160 | }) | ||
| 161 | .collect(); | ||
| 162 | |||
| 163 | let title = format!("Issues ({})", app.status_filter.label()); | ||
| 164 | |||
| 165 | let list = List::new(items) | ||
| 166 | .block( | ||
| 167 | Block::default() | ||
| 168 | .borders(Borders::ALL) | ||
| 169 | .title(title) | ||
| 170 | .border_style(border_style), | ||
| 171 | ) | ||
| 172 | .highlight_style( | ||
| 173 | Style::default() | ||
| 174 | .bg(Color::DarkGray) | ||
| 175 | .add_modifier(Modifier::BOLD), | ||
| 176 | ) | ||
| 177 | .highlight_symbol("> "); | ||
| 178 | |||
| 179 | frame.render_stateful_widget(list, area, &mut app.list_state); | ||
| 180 | } | ||
| 181 | |||
| 182 | fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { | ||
| 183 | let border_style = if app.pane == Pane::Detail { | ||
| 184 | Style::default().fg(Color::Yellow) | ||
| 185 | } else { | ||
| 186 | Style::default().fg(Color::DarkGray) | ||
| 187 | }; | ||
| 188 | |||
| 189 | // Handle commit browser modes | ||
| 190 | if app.mode == ViewMode::CommitList { | ||
| 191 | let items: Vec<ListItem> = app | ||
| 192 | .event_history | ||
| 193 | .iter() | ||
| 194 | .map(|(_oid, evt)| { | ||
| 195 | let label = action_type_label(&evt.action); | ||
| 196 | ListItem::new(format!( | ||
| 197 | "{} | {} | {}", | ||
| 198 | label, evt.author.name, evt.timestamp | ||
| 199 | )) | ||
| 200 | }) | ||
| 201 | .collect(); | ||
| 202 | |||
| 203 | let list = List::new(items) | ||
| 204 | .block( | ||
| 205 | Block::default() | ||
| 206 | .borders(Borders::ALL) | ||
| 207 | .title("Event History") | ||
| 208 | .border_style(border_style), | ||
| 209 | ) | ||
| 210 | .highlight_style( | ||
| 211 | Style::default() | ||
| 212 | .bg(Color::DarkGray) | ||
| 213 | .add_modifier(Modifier::BOLD), | ||
| 214 | ) | ||
| 215 | .highlight_symbol("> "); | ||
| 216 | |||
| 217 | frame.render_stateful_widget(list, area, &mut app.event_list_state); | ||
| 218 | return; | ||
| 219 | } | ||
| 220 | |||
| 221 | if app.mode == ViewMode::CommitDetail { | ||
| 222 | let content = if let Some(idx) = app.event_list_state.selected() { | ||
| 223 | if let Some((oid, evt)) = app.event_history.get(idx) { | ||
| 224 | format_event_detail(oid, evt) | ||
| 225 | } else { | ||
| 226 | "No event selected.".to_string() | ||
| 227 | } | ||
| 228 | } else { | ||
| 229 | "No event selected.".to_string() | ||
| 230 | }; | ||
| 231 | |||
| 232 | let block = Block::default() | ||
| 233 | .borders(Borders::ALL) | ||
| 234 | .title("Event Detail") | ||
| 235 | .border_style(border_style); | ||
| 236 | |||
| 237 | let para = Paragraph::new(content) | ||
| 238 | .block(block) | ||
| 239 | .wrap(Wrap { trim: false }) | ||
| 240 | .scroll((app.scroll, 0)); | ||
| 241 | |||
| 242 | frame.render_widget(para, area); | ||
| 243 | return; | ||
| 244 | } | ||
| 245 | |||
| 246 | let visible = app.visible_issues(); | ||
| 247 | let selected_idx = app.list_state.selected().unwrap_or(0); | ||
| 248 | let content: Text = match visible.get(selected_idx) { | ||
| 249 | Some(issue) => build_issue_detail(issue, &app.patches), | ||
| 250 | None => Text::raw("No matches for current filter."), | ||
| 251 | }; | ||
| 252 | |||
| 253 | let block = Block::default() | ||
| 254 | .borders(Borders::ALL) | ||
| 255 | .title("Issue Details") | ||
| 256 | .border_style(border_style); | ||
| 257 | |||
| 258 | let para = Paragraph::new(content) | ||
| 259 | .block(block) | ||
| 260 | .wrap(Wrap { trim: false }) | ||
| 261 | .scroll((app.scroll, 0)); | ||
| 262 | |||
| 263 | frame.render_widget(para, area); | ||
| 264 | } | ||
| 265 | |||
| 266 | fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'static> { | ||
| 267 | let status = match issue.status { | ||
| 268 | IssueStatus::Open => "open", | ||
| 269 | IssueStatus::Closed => "closed", | ||
| 270 | }; | ||
| 271 | |||
| 272 | let mut lines: Vec<Line> = vec![ | ||
| 273 | Line::from(vec![ | ||
| 274 | Span::styled("Issue ", Style::default().add_modifier(Modifier::BOLD)), | ||
| 275 | Span::styled( | ||
| 276 | format!("{:.8}", issue.id), | ||
| 277 | Style::default() | ||
| 278 | .fg(Color::Yellow) | ||
| 279 | .add_modifier(Modifier::BOLD), | ||
| 280 | ), | ||
| 281 | Span::raw(format!(" [{}]", status)), | ||
| 282 | ]), | ||
| 283 | Line::from(vec![ | ||
| 284 | Span::styled("Title: ", Style::default().fg(Color::DarkGray)), | ||
| 285 | Span::raw(issue.title.clone()), | ||
| 286 | ]), | ||
| 287 | Line::from(vec![ | ||
| 288 | Span::styled("Author: ", Style::default().fg(Color::DarkGray)), | ||
| 289 | Span::raw(format!("{} <{}>", issue.author.name, issue.author.email)), | ||
| 290 | ]), | ||
| 291 | Line::from(vec![ | ||
| 292 | Span::styled("Created: ", Style::default().fg(Color::DarkGray)), | ||
| 293 | Span::raw(issue.created_at.clone()), | ||
| 294 | ]), | ||
| 295 | ]; | ||
| 296 | |||
| 297 | if !issue.labels.is_empty() { | ||
| 298 | lines.push(Line::from(vec![ | ||
| 299 | Span::styled("Labels: ", Style::default().fg(Color::DarkGray)), | ||
| 300 | Span::raw(issue.labels.join(", ")), | ||
| 301 | ])); | ||
| 302 | } | ||
| 303 | |||
| 304 | if !issue.assignees.is_empty() { | ||
| 305 | lines.push(Line::from(vec![ | ||
| 306 | Span::styled("Assign: ", Style::default().fg(Color::DarkGray)), | ||
| 307 | Span::raw(issue.assignees.join(", ")), | ||
| 308 | ])); | ||
| 309 | } | ||
| 310 | |||
| 311 | if let Some(ref reason) = issue.close_reason { | ||
| 312 | lines.push(Line::from(vec![ | ||
| 313 | Span::styled("Closed: ", Style::default().fg(Color::Red)), | ||
| 314 | Span::raw(reason.clone()), | ||
| 315 | ])); | ||
| 316 | } | ||
| 317 | |||
| 318 | if let Some(ref oid) = issue.closed_by { | ||
| 319 | lines.push(Line::from(vec![ | ||
| 320 | Span::styled("Commit: ", Style::default().fg(Color::DarkGray)), | ||
| 321 | Span::styled( | ||
| 322 | format!("{:.8}", oid), | ||
| 323 | Style::default().fg(Color::Cyan), | ||
| 324 | ), | ||
| 325 | ])); | ||
| 326 | } | ||
| 327 | |||
| 328 | // Show patches that reference this issue via --fixes | ||
| 329 | let fixing_patches: Vec<&PatchState> = patches | ||
| 330 | .iter() | ||
| 331 | .filter(|p| p.fixes.as_deref() == Some(&issue.id)) | ||
| 332 | .collect(); | ||
| 333 | if !fixing_patches.is_empty() { | ||
| 334 | lines.push(Line::raw("")); | ||
| 335 | lines.push(Line::styled( | ||
| 336 | "--- Linked Patches ---", | ||
| 337 | Style::default() | ||
| 338 | .fg(Color::Magenta) | ||
| 339 | .add_modifier(Modifier::BOLD), | ||
| 340 | )); | ||
| 341 | for p in &fixing_patches { | ||
| 342 | let status = match p.status { | ||
| 343 | PatchStatus::Open => ("open", Color::Green), | ||
| 344 | PatchStatus::Closed => ("closed", Color::Red), | ||
| 345 | PatchStatus::Merged => ("merged", Color::Cyan), | ||
| 346 | }; | ||
| 347 | lines.push(Line::from(vec![ | ||
| 348 | Span::styled( | ||
| 349 | format!("{:.8}", p.id), | ||
| 350 | Style::default().fg(Color::Yellow), | ||
| 351 | ), | ||
| 352 | Span::raw(" "), | ||
| 353 | Span::styled(status.0, Style::default().fg(status.1)), | ||
| 354 | Span::raw(format!(" {}", p.title)), | ||
| 355 | ])); | ||
| 356 | } | ||
| 357 | } | ||
| 358 | |||
| 359 | if !issue.body.is_empty() { | ||
| 360 | lines.push(Line::raw("")); | ||
| 361 | for l in issue.body.lines() { | ||
| 362 | lines.push(Line::raw(l.to_string())); | ||
| 363 | } | ||
| 364 | } | ||
| 365 | |||
| 366 | if !issue.comments.is_empty() { | ||
| 367 | lines.push(Line::raw("")); | ||
| 368 | lines.push(Line::styled( | ||
| 369 | "--- Comments ---", | ||
| 370 | Style::default() | ||
| 371 | .fg(Color::Blue) | ||
| 372 | .add_modifier(Modifier::BOLD), | ||
| 373 | )); | ||
| 374 | for c in &issue.comments { | ||
| 375 | lines.push(Line::raw("")); | ||
| 376 | lines.push(Line::from(vec![ | ||
| 377 | Span::styled( | ||
| 378 | c.author.name.clone(), | ||
| 379 | Style::default().add_modifier(Modifier::BOLD), | ||
| 380 | ), | ||
| 381 | Span::styled( | ||
| 382 | format!(" ({})", c.timestamp), | ||
| 383 | Style::default().fg(Color::DarkGray), | ||
| 384 | ), | ||
| 385 | ])); | ||
| 386 | for l in c.body.lines() { | ||
| 387 | lines.push(Line::raw(format!(" {}", l))); | ||
| 388 | } | ||
| 389 | } | ||
| 390 | } | ||
| 391 | |||
| 392 | Text::from(lines) | ||
| 393 | } | ||
| 394 | |||
| 395 | fn render_footer(frame: &mut Frame, app: &App, area: Rect) { | ||
| 396 | match app.input_mode { | ||
| 397 | InputMode::Search => { | ||
| 398 | let max_query_len = (area.width as usize).saturating_sub(12); | ||
| 399 | let display_query = if app.search_query.len() > max_query_len { | ||
| 400 | &app.search_query[app.search_query.len() - max_query_len..] | ||
| 401 | } else { | ||
| 402 | &app.search_query | ||
| 403 | }; | ||
| 404 | let text = format!(" Search: {}_", display_query); | ||
| 405 | let style = Style::default().bg(Color::Blue).fg(Color::White); | ||
| 406 | let para = Paragraph::new(text).style(style); | ||
| 407 | frame.render_widget(para, area); | ||
| 408 | return; | ||
| 409 | } | ||
| 410 | InputMode::CreateTitle => { | ||
| 411 | let max_len = (area.width as usize).saturating_sub(22); | ||
| 412 | let display = if app.input_buf.len() > max_len { | ||
| 413 | &app.input_buf[app.input_buf.len() - max_len..] | ||
| 414 | } else { | ||
| 415 | &app.input_buf | ||
| 416 | }; | ||
| 417 | let text = format!(" New issue - Title: {}_", display); | ||
| 418 | let style = Style::default().bg(Color::Green).fg(Color::Black); | ||
| 419 | let para = Paragraph::new(text).style(style); | ||
| 420 | frame.render_widget(para, area); | ||
| 421 | return; | ||
| 422 | } | ||
| 423 | InputMode::CreateBody => { | ||
| 424 | let max_len = (area.width as usize).saturating_sub(21); | ||
| 425 | let display = if app.input_buf.len() > max_len { | ||
| 426 | &app.input_buf[app.input_buf.len() - max_len..] | ||
| 427 | } else { | ||
| 428 | &app.input_buf | ||
| 429 | }; | ||
| 430 | let text = format!(" New issue - Body: {}_ (Esc: skip)", display); | ||
| 431 | let style = Style::default().bg(Color::Green).fg(Color::Black); | ||
| 432 | let para = Paragraph::new(text).style(style); | ||
| 433 | frame.render_widget(para, area); | ||
| 434 | return; | ||
| 435 | } | ||
| 436 | InputMode::Normal => {} | ||
| 437 | } | ||
| 438 | |||
| 439 | // Show status message if present | ||
| 440 | if let Some(ref msg) = app.status_msg { | ||
| 441 | let para = | ||
| 442 | Paragraph::new(format!(" {}", msg)).style(Style::default().bg(Color::Yellow).fg(Color::Black)); | ||
| 443 | frame.render_widget(para, area); | ||
| 444 | return; | ||
| 445 | } | ||
| 446 | |||
| 447 | let mode_hint = match app.mode { | ||
| 448 | ViewMode::CommitList => " Esc:back", | ||
| 449 | ViewMode::CommitDetail => " Esc:back j/k:scroll", | ||
| 450 | ViewMode::Details => " c:events", | ||
| 451 | }; | ||
| 452 | let filter_hint = match app.status_filter { | ||
| 453 | StatusFilter::Open => "a:show all", | ||
| 454 | StatusFilter::All => "a:closed", | ||
| 455 | StatusFilter::Closed => "a:open only", | ||
| 456 | }; | ||
| 457 | let text = format!( | ||
| 458 | " j/k:navigate Tab:pane {}{} /:search n:new issue o:checkout r:refresh q:quit", | ||
| 459 | filter_hint, mode_hint | ||
| 460 | ); | ||
| 461 | let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White)); | ||
| 462 | frame.render_widget(para, area); | ||
| 463 | } | ||
tests/adversarial_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -80,6 +80,7 @@ fn valid_event_json() -> String { | |||
| 80 | action: Action::IssueOpen { | 80 | action: Action::IssueOpen { |
| 81 | title: "Test".to_string(), | 81 | title: "Test".to_string(), |
| 82 | body: "Body".to_string(), | 82 | body: "Body".to_string(), |
| 83 | relates_to: None, | ||
| 83 | }, | 84 | }, |
| 84 | clock: 1, | 85 | clock: 1, |
| 85 | }) | 86 | }) |
| @@ -274,6 +275,7 @@ fn dag_with_one_corrupted_commit_in_middle() { | |||
| 274 | action: Action::IssueOpen { | 275 | action: Action::IssueOpen { |
| 275 | title: "Test".to_string(), | 276 | title: "Test".to_string(), |
| 276 | body: "Body".to_string(), | 277 | body: "Body".to_string(), |
| 278 | relates_to: None, | ||
| 277 | }, | 279 | }, |
| 278 | clock: 0, | 280 | clock: 0, |
| 279 | }; | 281 | }; |
| @@ -353,6 +355,7 @@ fn blob_at_limit_succeeds() { | |||
| 353 | action: Action::IssueOpen { | 355 | action: Action::IssueOpen { |
| 354 | title: "Test".to_string(), | 356 | title: "Test".to_string(), |
| 355 | body: body_padding, | 357 | body: body_padding, |
| 358 | relates_to: None, | ||
| 356 | }, | 359 | }, |
| 357 | clock: 1, | 360 | clock: 1, |
| 358 | }; | 361 | }; |
| @@ -464,6 +467,7 @@ fn malformed_remote_event_no_local_state_change() { | |||
| 464 | action: Action::IssueOpen { | 467 | action: Action::IssueOpen { |
| 465 | title: "Original".to_string(), | 468 | title: "Original".to_string(), |
| 466 | body: "Body".to_string(), | 469 | body: "Body".to_string(), |
| 470 | relates_to: None, | ||
| 467 | }, | 471 | }, |
| 468 | clock: 0, | 472 | clock: 0, |
| 469 | }; | 473 | }; |
| @@ -527,7 +531,7 @@ fn arb_author() -> impl Strategy<Value = Author> { | |||
| 527 | /// Generate an arbitrary Action variant | 531 | /// Generate an arbitrary Action variant |
| 528 | fn arb_action() -> impl Strategy<Value = Action> { | 532 | fn arb_action() -> impl Strategy<Value = Action> { |
| 529 | prop_oneof![ | 533 | prop_oneof![ |
| 530 | (".*", ".*").prop_map(|(title, body)| Action::IssueOpen { title, body }), | 534 | (".*", ".*").prop_map(|(title, body)| Action::IssueOpen { title, body, relates_to: None }), |
| 531 | ".*".prop_map(|body| Action::IssueComment { body }), | 535 | ".*".prop_map(|body| Action::IssueComment { body }), |
| 532 | proptest::option::of(".*").prop_map(|reason| Action::IssueClose { reason }), | 536 | proptest::option::of(".*").prop_map(|reason| Action::IssueClose { reason }), |
| 533 | Just(Action::IssueReopen), | 537 | Just(Action::IssueReopen), |
tests/archive_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,230 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use tempfile::TempDir; | ||
| 4 | |||
| 5 | use git_collab::dag; | ||
| 6 | use git_collab::event::{Action, Event}; | ||
| 7 | use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; | ||
| 8 | |||
| 9 | use common::{alice, close_issue, create_patch, init_repo, now, open_issue, test_signing_key}; | ||
| 10 | |||
| 11 | // --------------------------------------------------------------------------- | ||
| 12 | // Archive on close: issues | ||
| 13 | // --------------------------------------------------------------------------- | ||
| 14 | |||
| 15 | #[test] | ||
| 16 | fn test_close_issue_moves_ref_to_archive() { | ||
| 17 | let tmp = TempDir::new().unwrap(); | ||
| 18 | let repo = init_repo(tmp.path(), &alice()); | ||
| 19 | |||
| 20 | let (ref_name, id) = open_issue(&repo, &alice(), "To be archived"); | ||
| 21 | |||
| 22 | // Close + archive | ||
| 23 | close_issue(&repo, &ref_name, &alice()); | ||
| 24 | state::archive_issue_ref(&repo, &id).unwrap(); | ||
| 25 | |||
| 26 | // Old ref should be gone | ||
| 27 | assert!(repo.refname_to_id(&ref_name).is_err()); | ||
| 28 | |||
| 29 | // New archive ref should exist | ||
| 30 | let archive_ref = format!("refs/collab/archive/issues/{}", id); | ||
| 31 | assert!(repo.refname_to_id(&archive_ref).is_ok()); | ||
| 32 | |||
| 33 | // Should still be able to materialize state from archive ref | ||
| 34 | let issue = IssueState::from_ref(&repo, &archive_ref, &id).unwrap(); | ||
| 35 | assert_eq!(issue.status, IssueStatus::Closed); | ||
| 36 | assert_eq!(issue.title, "To be archived"); | ||
| 37 | } | ||
| 38 | |||
| 39 | #[test] | ||
| 40 | fn test_list_issues_excludes_archived() { | ||
| 41 | let tmp = TempDir::new().unwrap(); | ||
| 42 | let repo = init_repo(tmp.path(), &alice()); | ||
| 43 | |||
| 44 | let (_ref1, _id1) = open_issue(&repo, &alice(), "Active issue"); | ||
| 45 | let (ref2, id2) = open_issue(&repo, &alice(), "Archived issue"); | ||
| 46 | close_issue(&repo, &ref2, &alice()); | ||
| 47 | state::archive_issue_ref(&repo, &id2).unwrap(); | ||
| 48 | |||
| 49 | // Default list should only show active | ||
| 50 | let issues = state::list_issues(&repo).unwrap(); | ||
| 51 | assert_eq!(issues.len(), 1); | ||
| 52 | assert_eq!(issues[0].title, "Active issue"); | ||
| 53 | } | ||
| 54 | |||
| 55 | #[test] | ||
| 56 | fn test_list_issues_with_archived_shows_both() { | ||
| 57 | let tmp = TempDir::new().unwrap(); | ||
| 58 | let repo = init_repo(tmp.path(), &alice()); | ||
| 59 | |||
| 60 | let (_ref1, _id1) = open_issue(&repo, &alice(), "Active issue"); | ||
| 61 | let (ref2, id2) = open_issue(&repo, &alice(), "Archived issue"); | ||
| 62 | close_issue(&repo, &ref2, &alice()); | ||
| 63 | state::archive_issue_ref(&repo, &id2).unwrap(); | ||
| 64 | |||
| 65 | // With archived flag should show both | ||
| 66 | let issues = state::list_issues_with_archived(&repo).unwrap(); | ||
| 67 | assert_eq!(issues.len(), 2); | ||
| 68 | } | ||
| 69 | |||
| 70 | #[test] | ||
| 71 | fn test_resolve_issue_ref_finds_archived() { | ||
| 72 | let tmp = TempDir::new().unwrap(); | ||
| 73 | let repo = init_repo(tmp.path(), &alice()); | ||
| 74 | |||
| 75 | let (ref_name, id) = open_issue(&repo, &alice(), "Will be archived"); | ||
| 76 | close_issue(&repo, &ref_name, &alice()); | ||
| 77 | state::archive_issue_ref(&repo, &id).unwrap(); | ||
| 78 | |||
| 79 | // resolve_issue_ref should find it in archive | ||
| 80 | let (resolved_ref, resolved_id) = state::resolve_issue_ref(&repo, &id[..8]).unwrap(); | ||
| 81 | assert_eq!(resolved_id, id); | ||
| 82 | assert!(resolved_ref.contains("archive")); | ||
| 83 | } | ||
| 84 | |||
| 85 | // --------------------------------------------------------------------------- | ||
| 86 | // Archive on close: patches | ||
| 87 | // --------------------------------------------------------------------------- | ||
| 88 | |||
| 89 | #[test] | ||
| 90 | fn test_close_patch_moves_ref_to_archive() { | ||
| 91 | let tmp = TempDir::new().unwrap(); | ||
| 92 | let repo = init_repo(tmp.path(), &alice()); | ||
| 93 | |||
| 94 | let (ref_name, id) = create_patch(&repo, &alice(), "To be archived"); | ||
| 95 | |||
| 96 | // Close + archive | ||
| 97 | let sk = test_signing_key(); | ||
| 98 | let event = Event { | ||
| 99 | timestamp: now(), | ||
| 100 | author: alice(), | ||
| 101 | action: Action::PatchClose { reason: None }, | ||
| 102 | clock: 0, | ||
| 103 | }; | ||
| 104 | dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); | ||
| 105 | state::archive_patch_ref(&repo, &id).unwrap(); | ||
| 106 | |||
| 107 | // Old ref should be gone | ||
| 108 | assert!(repo.refname_to_id(&ref_name).is_err()); | ||
| 109 | |||
| 110 | // New archive ref should exist | ||
| 111 | let archive_ref = format!("refs/collab/archive/patches/{}", id); | ||
| 112 | assert!(repo.refname_to_id(&archive_ref).is_ok()); | ||
| 113 | |||
| 114 | // Should still be able to materialize state from archive ref | ||
| 115 | let patch = PatchState::from_ref(&repo, &archive_ref, &id).unwrap(); | ||
| 116 | assert_eq!(patch.status, PatchStatus::Closed); | ||
| 117 | } | ||
| 118 | |||
| 119 | #[test] | ||
| 120 | fn test_list_patches_excludes_archived() { | ||
| 121 | let tmp = TempDir::new().unwrap(); | ||
| 122 | let repo = init_repo(tmp.path(), &alice()); | ||
| 123 | |||
| 124 | let (_ref1, _id1) = create_patch(&repo, &alice(), "Active patch"); | ||
| 125 | let (ref2, id2) = create_patch(&repo, &alice(), "Archived patch"); | ||
| 126 | |||
| 127 | let sk = test_signing_key(); | ||
| 128 | let event = Event { | ||
| 129 | timestamp: now(), | ||
| 130 | author: alice(), | ||
| 131 | action: Action::PatchClose { reason: None }, | ||
| 132 | clock: 0, | ||
| 133 | }; | ||
| 134 | dag::append_event(&repo, &ref2, &event, &sk).unwrap(); | ||
| 135 | state::archive_patch_ref(&repo, &id2).unwrap(); | ||
| 136 | |||
| 137 | let patches = state::list_patches(&repo).unwrap(); | ||
| 138 | assert_eq!(patches.len(), 1); | ||
| 139 | assert_eq!(patches[0].title, "Active patch"); | ||
| 140 | } | ||
| 141 | |||
| 142 | #[test] | ||
| 143 | fn test_list_patches_with_archived_shows_both() { | ||
| 144 | let tmp = TempDir::new().unwrap(); | ||
| 145 | let repo = init_repo(tmp.path(), &alice()); | ||
| 146 | |||
| 147 | let (_ref1, _id1) = create_patch(&repo, &alice(), "Active patch"); | ||
| 148 | let (ref2, id2) = create_patch(&repo, &alice(), "Archived patch"); | ||
| 149 | |||
| 150 | let sk = test_signing_key(); | ||
| 151 | let event = Event { | ||
| 152 | timestamp: now(), | ||
| 153 | author: alice(), | ||
| 154 | action: Action::PatchClose { reason: None }, | ||
| 155 | clock: 0, | ||
| 156 | }; | ||
| 157 | dag::append_event(&repo, &ref2, &event, &sk).unwrap(); | ||
| 158 | state::archive_patch_ref(&repo, &id2).unwrap(); | ||
| 159 | |||
| 160 | let patches = state::list_patches_with_archived(&repo).unwrap(); | ||
| 161 | assert_eq!(patches.len(), 2); | ||
| 162 | } | ||
| 163 | |||
| 164 | #[test] | ||
| 165 | fn test_resolve_patch_ref_finds_archived() { | ||
| 166 | let tmp = TempDir::new().unwrap(); | ||
| 167 | let repo = init_repo(tmp.path(), &alice()); | ||
| 168 | |||
| 169 | let (ref_name, id) = create_patch(&repo, &alice(), "Will archive"); | ||
| 170 | |||
| 171 | let sk = test_signing_key(); | ||
| 172 | let event = Event { | ||
| 173 | timestamp: now(), | ||
| 174 | author: alice(), | ||
| 175 | action: Action::PatchClose { reason: None }, | ||
| 176 | clock: 0, | ||
| 177 | }; | ||
| 178 | dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); | ||
| 179 | state::archive_patch_ref(&repo, &id).unwrap(); | ||
| 180 | |||
| 181 | let (resolved_ref, resolved_id) = state::resolve_patch_ref(&repo, &id[..8]).unwrap(); | ||
| 182 | assert_eq!(resolved_id, id); | ||
| 183 | assert!(resolved_ref.contains("archive")); | ||
| 184 | } | ||
| 185 | |||
| 186 | // --------------------------------------------------------------------------- | ||
| 187 | // --relates-to on issue open | ||
| 188 | // --------------------------------------------------------------------------- | ||
| 189 | |||
| 190 | #[test] | ||
| 191 | fn test_issue_open_with_relates_to() { | ||
| 192 | let tmp = TempDir::new().unwrap(); | ||
| 193 | let repo = init_repo(tmp.path(), &alice()); | ||
| 194 | |||
| 195 | // First create an issue to relate to | ||
| 196 | let (_ref1, id1) = open_issue(&repo, &alice(), "Parent issue"); | ||
| 197 | |||
| 198 | // Open an issue with relates_to | ||
| 199 | let sk = test_signing_key(); | ||
| 200 | let event = Event { | ||
| 201 | timestamp: now(), | ||
| 202 | author: alice(), | ||
| 203 | action: Action::IssueOpen { | ||
| 204 | title: "Child issue".to_string(), | ||
| 205 | body: "".to_string(), | ||
| 206 | relates_to: Some(id1.clone()), | ||
| 207 | }, | ||
| 208 | clock: 0, | ||
| 209 | }; | ||
| 210 | let oid = dag::create_root_event(&repo, &event, &sk).unwrap(); | ||
| 211 | let id = oid.to_string(); | ||
| 212 | let ref_name = format!("refs/collab/issues/{}", id); | ||
| 213 | repo.reference(&ref_name, oid, false, "test open with relates_to") | ||
| 214 | .unwrap(); | ||
| 215 | |||
| 216 | let issue = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 217 | assert_eq!(issue.title, "Child issue"); | ||
| 218 | assert_eq!(issue.relates_to.as_deref(), Some(id1.as_str())); | ||
| 219 | } | ||
| 220 | |||
| 221 | #[test] | ||
| 222 | fn test_issue_open_without_relates_to() { | ||
| 223 | let tmp = TempDir::new().unwrap(); | ||
| 224 | let repo = init_repo(tmp.path(), &alice()); | ||
| 225 | |||
| 226 | let (ref_name, id) = open_issue(&repo, &alice(), "Solo issue"); | ||
| 227 | |||
| 228 | let issue = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 229 | assert_eq!(issue.relates_to, None); | ||
| 230 | } | ||
tests/cache_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,191 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use common::{alice, bob, add_comment, close_issue, init_repo, open_issue, create_patch, add_review}; | ||
| 4 | use git_collab::cache; | ||
| 5 | use git_collab::event::ReviewVerdict; | ||
| 6 | use git_collab::state::{IssueState, IssueStatus, PatchState, PatchStatus}; | ||
| 7 | use tempfile::TempDir; | ||
| 8 | |||
| 9 | #[test] | ||
| 10 | fn cache_miss_walks_dag_and_caches() { | ||
| 11 | let dir = TempDir::new().unwrap(); | ||
| 12 | let repo = init_repo(dir.path(), &alice()); | ||
| 13 | let (ref_name, id) = open_issue(&repo, &alice(), "Cache test issue"); | ||
| 14 | |||
| 15 | // No cache exists yet — should return None | ||
| 16 | let cached: Option<IssueState> = cache::get_cached_state(&repo, &ref_name); | ||
| 17 | assert!(cached.is_none()); | ||
| 18 | |||
| 19 | // from_ref should walk the DAG and populate the cache | ||
| 20 | let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 21 | assert_eq!(state.title, "Cache test issue"); | ||
| 22 | assert_eq!(state.status, IssueStatus::Open); | ||
| 23 | |||
| 24 | // Now the cache should have an entry | ||
| 25 | let cached: Option<IssueState> = cache::get_cached_state(&repo, &ref_name); | ||
| 26 | assert!(cached.is_some()); | ||
| 27 | let cached = cached.unwrap(); | ||
| 28 | assert_eq!(cached.title, "Cache test issue"); | ||
| 29 | assert_eq!(cached.id, id); | ||
| 30 | } | ||
| 31 | |||
| 32 | #[test] | ||
| 33 | fn cache_hit_returns_cached_state() { | ||
| 34 | let dir = TempDir::new().unwrap(); | ||
| 35 | let repo = init_repo(dir.path(), &alice()); | ||
| 36 | let (ref_name, id) = open_issue(&repo, &alice(), "Cached issue"); | ||
| 37 | |||
| 38 | // Populate cache | ||
| 39 | let state1 = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 40 | |||
| 41 | // Second call should hit cache and return same result | ||
| 42 | let state2 = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 43 | assert_eq!(state1.title, state2.title); | ||
| 44 | assert_eq!(state1.id, state2.id); | ||
| 45 | assert_eq!(state1.status, state2.status); | ||
| 46 | } | ||
| 47 | |||
| 48 | #[test] | ||
| 49 | fn cache_invalidation_on_new_event() { | ||
| 50 | let dir = TempDir::new().unwrap(); | ||
| 51 | let repo = init_repo(dir.path(), &alice()); | ||
| 52 | let (ref_name, id) = open_issue(&repo, &alice(), "Invalidation test"); | ||
| 53 | |||
| 54 | // Populate cache | ||
| 55 | let state1 = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 56 | assert_eq!(state1.comments.len(), 0); | ||
| 57 | |||
| 58 | // Append a comment — this changes the ref tip OID | ||
| 59 | add_comment(&repo, &ref_name, &bob(), "Hello!"); | ||
| 60 | |||
| 61 | // Cache should be invalidated (tip changed), DAG should be re-walked | ||
| 62 | let state2 = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 63 | assert_eq!(state2.comments.len(), 1); | ||
| 64 | assert_eq!(state2.comments[0].body, "Hello!"); | ||
| 65 | } | ||
| 66 | |||
| 67 | #[test] | ||
| 68 | fn cache_invalidation_on_close() { | ||
| 69 | let dir = TempDir::new().unwrap(); | ||
| 70 | let repo = init_repo(dir.path(), &alice()); | ||
| 71 | let (ref_name, id) = open_issue(&repo, &alice(), "Close test"); | ||
| 72 | |||
| 73 | let state1 = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 74 | assert_eq!(state1.status, IssueStatus::Open); | ||
| 75 | |||
| 76 | close_issue(&repo, &ref_name, &alice()); | ||
| 77 | |||
| 78 | let state2 = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 79 | assert_eq!(state2.status, IssueStatus::Closed); | ||
| 80 | } | ||
| 81 | |||
| 82 | #[test] | ||
| 83 | fn no_cache_dir_falls_back_gracefully() { | ||
| 84 | let dir = TempDir::new().unwrap(); | ||
| 85 | let repo = init_repo(dir.path(), &alice()); | ||
| 86 | let (ref_name, id) = open_issue(&repo, &alice(), "No cache dir"); | ||
| 87 | |||
| 88 | // Ensure no cache directory exists (it shouldn't by default) | ||
| 89 | let cache_path = repo.path().join("collab").join("cache"); | ||
| 90 | if cache_path.exists() { | ||
| 91 | std::fs::remove_dir_all(&cache_path).unwrap(); | ||
| 92 | } | ||
| 93 | |||
| 94 | // Should still work via DAG walk | ||
| 95 | let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 96 | assert_eq!(state.title, "No cache dir"); | ||
| 97 | } | ||
| 98 | |||
| 99 | #[test] | ||
| 100 | fn corrupted_cache_falls_back_to_dag_walk() { | ||
| 101 | let dir = TempDir::new().unwrap(); | ||
| 102 | let repo = init_repo(dir.path(), &alice()); | ||
| 103 | let (ref_name, id) = open_issue(&repo, &alice(), "Corrupted cache"); | ||
| 104 | |||
| 105 | // Populate cache | ||
| 106 | IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 107 | |||
| 108 | // Corrupt the cache file | ||
| 109 | let cache_dir = repo.path().join("collab").join("cache"); | ||
| 110 | let sanitized = ref_name.replace('/', "_"); | ||
| 111 | let cache_file = cache_dir.join(sanitized); | ||
| 112 | std::fs::write(&cache_file, "not valid json").unwrap(); | ||
| 113 | |||
| 114 | // Should fall back gracefully | ||
| 115 | let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 116 | assert_eq!(state.title, "Corrupted cache"); | ||
| 117 | } | ||
| 118 | |||
| 119 | #[test] | ||
| 120 | fn patch_cache_miss_and_hit() { | ||
| 121 | let dir = TempDir::new().unwrap(); | ||
| 122 | let repo = init_repo(dir.path(), &alice()); | ||
| 123 | let (ref_name, id) = create_patch(&repo, &alice(), "Cache patch"); | ||
| 124 | |||
| 125 | // No cache | ||
| 126 | let cached: Option<PatchState> = cache::get_cached_state(&repo, &ref_name); | ||
| 127 | assert!(cached.is_none()); | ||
| 128 | |||
| 129 | // from_ref populates cache | ||
| 130 | let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 131 | assert_eq!(state.title, "Cache patch"); | ||
| 132 | assert_eq!(state.status, PatchStatus::Open); | ||
| 133 | |||
| 134 | // Cache hit | ||
| 135 | let cached: Option<PatchState> = cache::get_cached_state(&repo, &ref_name); | ||
| 136 | assert!(cached.is_some()); | ||
| 137 | assert_eq!(cached.unwrap().title, "Cache patch"); | ||
| 138 | } | ||
| 139 | |||
| 140 | #[test] | ||
| 141 | fn patch_cache_invalidation_on_review() { | ||
| 142 | let dir = TempDir::new().unwrap(); | ||
| 143 | let repo = init_repo(dir.path(), &alice()); | ||
| 144 | let (ref_name, id) = create_patch(&repo, &alice(), "Review patch"); | ||
| 145 | |||
| 146 | let state1 = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 147 | assert_eq!(state1.reviews.len(), 0); | ||
| 148 | |||
| 149 | add_review(&repo, &ref_name, &bob(), ReviewVerdict::Approve); | ||
| 150 | |||
| 151 | let state2 = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 152 | assert_eq!(state2.reviews.len(), 1); | ||
| 153 | } | ||
| 154 | |||
| 155 | #[test] | ||
| 156 | fn cache_invalidate_removes_entry() { | ||
| 157 | let dir = TempDir::new().unwrap(); | ||
| 158 | let repo = init_repo(dir.path(), &alice()); | ||
| 159 | let (ref_name, id) = open_issue(&repo, &alice(), "Invalidate test"); | ||
| 160 | |||
| 161 | // Populate cache | ||
| 162 | IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 163 | assert!(cache::get_cached_state::<IssueState>(&repo, &ref_name).is_some()); | ||
| 164 | |||
| 165 | // Explicitly invalidate | ||
| 166 | cache::invalidate(&repo, &ref_name); | ||
| 167 | assert!(cache::get_cached_state::<IssueState>(&repo, &ref_name).is_none()); | ||
| 168 | } | ||
| 169 | |||
| 170 | #[test] | ||
| 171 | fn list_issues_uses_cache() { | ||
| 172 | let dir = TempDir::new().unwrap(); | ||
| 173 | let repo = init_repo(dir.path(), &alice()); | ||
| 174 | open_issue(&repo, &alice(), "List issue 1"); | ||
| 175 | open_issue(&repo, &alice(), "List issue 2"); | ||
| 176 | |||
| 177 | // First call walks DAGs and populates cache | ||
| 178 | let issues1 = git_collab::state::list_issues(&repo).unwrap(); | ||
| 179 | assert_eq!(issues1.len(), 2); | ||
| 180 | |||
| 181 | // Second call should use cache | ||
| 182 | let issues2 = git_collab::state::list_issues(&repo).unwrap(); | ||
| 183 | assert_eq!(issues2.len(), 2); | ||
| 184 | |||
| 185 | // Titles should match (order may differ) | ||
| 186 | let mut titles1: Vec<_> = issues1.iter().map(|i| &i.title).collect(); | ||
| 187 | let mut titles2: Vec<_> = issues2.iter().map(|i| &i.title).collect(); | ||
| 188 | titles1.sort(); | ||
| 189 | titles2.sort(); | ||
| 190 | assert_eq!(titles1, titles2); | ||
| 191 | } | ||
tests/cli_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -44,7 +44,8 @@ fn test_issue_list_filters_closed() { | |||
| 44 | assert!(out.contains("Open bug")); | 44 | assert!(out.contains("Open bug")); |
| 45 | assert!(!out.contains("Closed bug")); | 45 | assert!(!out.contains("Closed bug")); |
| 46 | 46 | ||
| 47 | let out = repo.run_ok(&["issue", "list", "--all"]); | 47 | // Closed issues are archived, so --all alone won't show them; need --archived |
| 48 | let out = repo.run_ok(&["issue", "list", "--all", "--archived"]); | ||
| 48 | assert!(out.contains("Open bug")); | 49 | assert!(out.contains("Open bug")); |
| 49 | assert!(out.contains("Closed bug")); | 50 | assert!(out.contains("Closed bug")); |
| 50 | assert!(out.contains("closed")); | 51 | assert!(out.contains("closed")); |
| @@ -88,19 +89,6 @@ fn test_issue_close_without_reason() { | |||
| 88 | } | 89 | } |
| 89 | 90 | ||
| 90 | #[test] | 91 | #[test] |
| 91 | fn test_issue_reopen() { | ||
| 92 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 93 | let id = repo.issue_open("Reopen me"); | ||
| 94 | repo.run_ok(&["issue", "close", &id]); | ||
| 95 | |||
| 96 | let out = repo.run_ok(&["issue", "reopen", &id]); | ||
| 97 | assert!(out.contains("Issue reopened")); | ||
| 98 | |||
| 99 | let out = repo.run_ok(&["issue", "show", &id]); | ||
| 100 | assert!(out.contains("[open]")); | ||
| 101 | } | ||
| 102 | |||
| 103 | #[test] | ||
| 104 | fn test_issue_prefix_resolution() { | 92 | fn test_issue_prefix_resolution() { |
| 105 | let repo = TestRepo::new("Alice", "alice@example.com"); | 93 | let repo = TestRepo::new("Alice", "alice@example.com"); |
| 106 | let id = repo.issue_open("Prefix test"); | 94 | let id = repo.issue_open("Prefix test"); |
| @@ -327,7 +315,8 @@ fn test_patch_list_filters_by_status() { | |||
| 327 | assert!(out.contains("Open patch")); | 315 | assert!(out.contains("Open patch")); |
| 328 | assert!(!out.contains("Closed patch")); | 316 | assert!(!out.contains("Closed patch")); |
| 329 | 317 | ||
| 330 | let out = repo.run_ok(&["patch", "list", "--all"]); | 318 | // Closed patches are archived, so --all alone won't show them; need --archived |
| 319 | let out = repo.run_ok(&["patch", "list", "--all", "--archived"]); | ||
| 331 | assert!(out.contains("Open patch")); | 320 | assert!(out.contains("Open patch")); |
| 332 | assert!(out.contains("Closed patch")); | 321 | assert!(out.contains("Closed patch")); |
| 333 | } | 322 | } |
| @@ -692,21 +681,19 @@ fn test_full_issue_lifecycle() { | |||
| 692 | assert!(out.contains("Stack trace attached")); | 681 | assert!(out.contains("Stack trace attached")); |
| 693 | assert!(out.contains("Reproduced on Chrome")); | 682 | assert!(out.contains("Reproduced on Chrome")); |
| 694 | 683 | ||
| 695 | // Close with reason | 684 | // Close with reason (this archives the issue) |
| 696 | repo.run_ok(&["issue", "close", &id, "-r", "Fixed in commit abc123"]); | 685 | repo.run_ok(&["issue", "close", &id, "-r", "Fixed in commit abc123"]); |
| 697 | let out = repo.run_ok(&["issue", "show", &id]); | 686 | let out = repo.run_ok(&["issue", "show", &id]); |
| 698 | assert!(out.contains("[closed]")); | 687 | assert!(out.contains("[closed]")); |
| 699 | assert!(out.contains("Fixed in commit abc123")); | 688 | assert!(out.contains("Fixed in commit abc123")); |
| 700 | 689 | ||
| 701 | // Reopen | 690 | // Issue should be archived and not in default list |
| 702 | repo.run_ok(&["issue", "reopen", &id]); | ||
| 703 | let out = repo.run_ok(&["issue", "show", &id]); | ||
| 704 | assert!(out.contains("[open]")); | ||
| 705 | |||
| 706 | // Close again (no reason) | ||
| 707 | repo.run_ok(&["issue", "close", &id]); | ||
| 708 | let out = repo.run_ok(&["issue", "list"]); | 691 | let out = repo.run_ok(&["issue", "list"]); |
| 709 | assert!(!out.contains("Login page crashes") || out.contains("No issues")); | 692 | assert!(!out.contains("Login page crashes") || out.contains("No issues")); |
| 693 | |||
| 694 | // But visible with --archived | ||
| 695 | let out = repo.run_ok(&["issue", "list", "--all", "--archived"]); | ||
| 696 | assert!(out.contains("Login page crashes")); | ||
| 710 | } | 697 | } |
| 711 | 698 | ||
| 712 | #[test] | 699 | #[test] |
| @@ -864,3 +851,77 @@ fn test_init_key_creates_key_files() { | |||
| 864 | ); | 851 | ); |
| 865 | assert!(stdout.contains("Signing key generated")); | 852 | assert!(stdout.contains("Signing key generated")); |
| 866 | } | 853 | } |
| 854 | |||
| 855 | // =========================================================================== | ||
| 856 | // Issue delete | ||
| 857 | // =========================================================================== | ||
| 858 | |||
| 859 | #[test] | ||
| 860 | fn test_issue_delete_removes_from_list() { | ||
| 861 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 862 | let id = repo.issue_open("Bug to delete"); | ||
| 863 | |||
| 864 | // Verify it shows up | ||
| 865 | let out = repo.run_ok(&["issue", "list"]); | ||
| 866 | assert!(out.contains("Bug to delete")); | ||
| 867 | |||
| 868 | // Delete it | ||
| 869 | let out = repo.run_ok(&["issue", "delete", &id]); | ||
| 870 | assert!(out.contains("Deleted issue")); | ||
| 871 | |||
| 872 | // Verify it's gone | ||
| 873 | let out = repo.run_ok(&["issue", "list", "--all"]); | ||
| 874 | assert!(!out.contains("Bug to delete")); | ||
| 875 | } | ||
| 876 | |||
| 877 | #[test] | ||
| 878 | fn test_issue_delete_nonexistent_errors() { | ||
| 879 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 880 | let err = repo.run_err(&["issue", "delete", "deadbeef"]); | ||
| 881 | assert!(!err.is_empty()); | ||
| 882 | } | ||
| 883 | |||
| 884 | #[test] | ||
| 885 | fn test_issue_delete_then_show_errors() { | ||
| 886 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 887 | let id = repo.issue_open("Ephemeral"); | ||
| 888 | repo.run_ok(&["issue", "delete", &id]); | ||
| 889 | repo.run_err(&["issue", "show", &id]); | ||
| 890 | } | ||
| 891 | |||
| 892 | // =========================================================================== | ||
| 893 | // Patch delete | ||
| 894 | // =========================================================================== | ||
| 895 | |||
| 896 | #[test] | ||
| 897 | fn test_patch_delete_removes_from_list() { | ||
| 898 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 899 | let id = repo.patch_create("Patch to delete"); | ||
| 900 | |||
| 901 | // Verify it shows up | ||
| 902 | let out = repo.run_ok(&["patch", "list"]); | ||
| 903 | assert!(out.contains("Patch to delete")); | ||
| 904 | |||
| 905 | // Delete it | ||
| 906 | let out = repo.run_ok(&["patch", "delete", &id]); | ||
| 907 | assert!(out.contains("Deleted patch")); | ||
| 908 | |||
| 909 | // Verify it's gone | ||
| 910 | let out = repo.run_ok(&["patch", "list", "--all"]); | ||
| 911 | assert!(!out.contains("Patch to delete")); | ||
| 912 | } | ||
| 913 | |||
| 914 | #[test] | ||
| 915 | fn test_patch_delete_nonexistent_errors() { | ||
| 916 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 917 | let err = repo.run_err(&["patch", "delete", "deadbeef"]); | ||
| 918 | assert!(!err.is_empty()); | ||
| 919 | } | ||
| 920 | |||
| 921 | #[test] | ||
| 922 | fn test_patch_delete_then_show_errors() { | ||
| 923 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 924 | let id = repo.patch_create("Ephemeral patch"); | ||
| 925 | repo.run_ok(&["patch", "delete", &id]); | ||
| 926 | repo.run_err(&["patch", "show", &id]); | ||
| 927 | } | ||
tests/collab_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -510,6 +510,7 @@ fn test_signed_event_in_dag() { | |||
| 510 | action: Action::IssueOpen { | 510 | action: Action::IssueOpen { |
| 511 | title: "Signed issue".to_string(), | 511 | title: "Signed issue".to_string(), |
| 512 | body: "".to_string(), | 512 | body: "".to_string(), |
| 513 | relates_to: None, | ||
| 513 | }, | 514 | }, |
| 514 | clock: 0, | 515 | clock: 0, |
| 515 | }; | 516 | }; |
| @@ -934,9 +935,9 @@ fn test_merge_branch_based_patch() { | |||
| 934 | // Merge the patch | 935 | // Merge the patch |
| 935 | patch::merge(&repo, &id).unwrap(); | 936 | patch::merge(&repo, &id).unwrap(); |
| 936 | 937 | ||
| 937 | // Check that the patch is now merged | 938 | // Check that the patch is now merged (archived after merge) |
| 938 | let patch_ref = format!("refs/collab/patches/{}", id); | 939 | let archive_ref = format!("refs/collab/archive/patches/{}", id); |
| 939 | let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); | 940 | let state = PatchState::from_ref(&repo, &archive_ref, &id).unwrap(); |
| 940 | assert_eq!(state.status, PatchStatus::Merged); | 941 | assert_eq!(state.status, PatchStatus::Merged); |
| 941 | 942 | ||
| 942 | // Check that the base branch now has the feature commit | 943 | // Check that the base branch now has the feature commit |
| @@ -1106,7 +1107,7 @@ fn capture_issue_list( | |||
| 1106 | offset: Option<usize>, | 1107 | offset: Option<usize>, |
| 1107 | ) -> String { | 1108 | ) -> String { |
| 1108 | let mut buf = Vec::new(); | 1109 | let mut buf = Vec::new(); |
| 1109 | issue::list_to_writer(repo, show_closed, limit, offset, &mut buf).unwrap(); | 1110 | issue::list_to_writer(repo, show_closed, false, limit, offset, git_collab::cli::SortMode::Recent, &mut buf).unwrap(); |
| 1110 | String::from_utf8(buf).unwrap() | 1111 | String::from_utf8(buf).unwrap() |
| 1111 | } | 1112 | } |
| 1112 | 1113 | ||
| @@ -1118,7 +1119,7 @@ fn capture_patch_list( | |||
| 1118 | offset: Option<usize>, | 1119 | offset: Option<usize>, |
| 1119 | ) -> String { | 1120 | ) -> String { |
| 1120 | let mut buf = Vec::new(); | 1121 | let mut buf = Vec::new(); |
| 1121 | patch::list_to_writer(repo, show_closed, limit, offset, &mut buf).unwrap(); | 1122 | patch::list_to_writer(repo, show_closed, false, limit, offset, git_collab::cli::SortMode::Recent, &mut buf).unwrap(); |
| 1122 | String::from_utf8(buf).unwrap() | 1123 | String::from_utf8(buf).unwrap() |
| 1123 | } | 1124 | } |
| 1124 | 1125 | ||
| @@ -1331,7 +1332,7 @@ fn test_issue_list_json_output() { | |||
| 1331 | open_issue(&repo, &alice(), "Issue one"); | 1332 | open_issue(&repo, &alice(), "Issue one"); |
| 1332 | open_issue(&repo, &bob(), "Issue two"); | 1333 | open_issue(&repo, &bob(), "Issue two"); |
| 1333 | 1334 | ||
| 1334 | let json_str = git_collab::issue::list_json(&repo, false).unwrap(); | 1335 | let json_str = git_collab::issue::list_json(&repo, false, false, git_collab::cli::SortMode::Recent).unwrap(); |
| 1335 | let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); | 1336 | let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); |
| 1336 | let arr = value.as_array().unwrap(); | 1337 | let arr = value.as_array().unwrap(); |
| 1337 | assert_eq!(arr.len(), 2); | 1338 | assert_eq!(arr.len(), 2); |
| @@ -1351,12 +1352,12 @@ fn test_issue_list_json_filters_closed() { | |||
| 1351 | close_issue(&repo, &ref2, &alice()); | 1352 | close_issue(&repo, &ref2, &alice()); |
| 1352 | 1353 | ||
| 1353 | // Without --all, only open issues | 1354 | // Without --all, only open issues |
| 1354 | let json_str = git_collab::issue::list_json(&repo, false).unwrap(); | 1355 | let json_str = git_collab::issue::list_json(&repo, false, false, git_collab::cli::SortMode::Recent).unwrap(); |
| 1355 | let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); | 1356 | let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); |
| 1356 | assert_eq!(value.as_array().unwrap().len(), 1); | 1357 | assert_eq!(value.as_array().unwrap().len(), 1); |
| 1357 | 1358 | ||
| 1358 | // With --all, both | 1359 | // With --all, both |
| 1359 | let json_str = git_collab::issue::list_json(&repo, true).unwrap(); | 1360 | let json_str = git_collab::issue::list_json(&repo, true, false, git_collab::cli::SortMode::Recent).unwrap(); |
| 1360 | let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); | 1361 | let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); |
| 1361 | assert_eq!(value.as_array().unwrap().len(), 2); | 1362 | assert_eq!(value.as_array().unwrap().len(), 2); |
| 1362 | } | 1363 | } |
| @@ -1383,7 +1384,7 @@ fn test_patch_list_json_output() { | |||
| 1383 | create_patch(&repo, &alice(), "Patch one"); | 1384 | create_patch(&repo, &alice(), "Patch one"); |
| 1384 | create_patch(&repo, &bob(), "Patch two"); | 1385 | create_patch(&repo, &bob(), "Patch two"); |
| 1385 | 1386 | ||
| 1386 | let json_str = git_collab::patch::list_json(&repo, false).unwrap(); | 1387 | let json_str = git_collab::patch::list_json(&repo, false, false, git_collab::cli::SortMode::Recent).unwrap(); |
| 1387 | let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); | 1388 | let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); |
| 1388 | let arr = value.as_array().unwrap(); | 1389 | let arr = value.as_array().unwrap(); |
| 1389 | assert_eq!(arr.len(), 2); | 1390 | assert_eq!(arr.len(), 2); |
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -65,6 +65,7 @@ pub fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, S | |||
| 65 | action: Action::IssueOpen { | 65 | action: Action::IssueOpen { |
| 66 | title: title.to_string(), | 66 | title: title.to_string(), |
| 67 | body: "".to_string(), | 67 | body: "".to_string(), |
| 68 | relates_to: None, | ||
| 68 | }, | 69 | }, |
| 69 | clock: 0, | 70 | clock: 0, |
| 70 | }; | 71 | }; |
tests/crdt_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -60,6 +60,7 @@ fn create_root_event_sets_clock_to_1() { | |||
| 60 | action: Action::IssueOpen { | 60 | action: Action::IssueOpen { |
| 61 | title: "test".to_string(), | 61 | title: "test".to_string(), |
| 62 | body: "body".to_string(), | 62 | body: "body".to_string(), |
| 63 | relates_to: None, | ||
| 63 | }, | 64 | }, |
| 64 | clock: 0, // caller passes 0, DAG should override to 1 | 65 | clock: 0, // caller passes 0, DAG should override to 1 |
| 65 | }; | 66 | }; |
| @@ -80,6 +81,7 @@ fn append_event_increments_clock() { | |||
| 80 | action: Action::IssueOpen { | 81 | action: Action::IssueOpen { |
| 81 | title: "test".to_string(), | 82 | title: "test".to_string(), |
| 82 | body: "body".to_string(), | 83 | body: "body".to_string(), |
| 84 | relates_to: None, | ||
| 83 | }, | 85 | }, |
| 84 | clock: 0, | 86 | clock: 0, |
| 85 | }; | 87 | }; |
| @@ -125,6 +127,7 @@ fn max_clock_returns_highest_clock_in_dag() { | |||
| 125 | action: Action::IssueOpen { | 127 | action: Action::IssueOpen { |
| 126 | title: "test".to_string(), | 128 | title: "test".to_string(), |
| 127 | body: "body".to_string(), | 129 | body: "body".to_string(), |
| 130 | relates_to: None, | ||
| 128 | }, | 131 | }, |
| 129 | clock: 0, | 132 | clock: 0, |
| 130 | }; | 133 | }; |
| @@ -162,6 +165,7 @@ fn reconcile_merge_clock_is_max_of_both_branches_plus_one() { | |||
| 162 | action: Action::IssueOpen { | 165 | action: Action::IssueOpen { |
| 163 | title: "test".to_string(), | 166 | title: "test".to_string(), |
| 164 | body: "body".to_string(), | 167 | body: "body".to_string(), |
| 168 | relates_to: None, | ||
| 165 | }, | 169 | }, |
| 166 | clock: 0, | 170 | clock: 0, |
| 167 | }; | 171 | }; |
| @@ -227,6 +231,7 @@ fn concurrent_issue_close_reopen_higher_clock_wins() { | |||
| 227 | action: Action::IssueOpen { | 231 | action: Action::IssueOpen { |
| 228 | title: "test".to_string(), | 232 | title: "test".to_string(), |
| 229 | body: "body".to_string(), | 233 | body: "body".to_string(), |
| 234 | relates_to: None, | ||
| 230 | }, | 235 | }, |
| 231 | clock: 0, | 236 | clock: 0, |
| 232 | }; | 237 | }; |
| @@ -285,6 +290,7 @@ fn concurrent_issue_same_clock_oid_breaks_tie() { | |||
| 285 | action: Action::IssueOpen { | 290 | action: Action::IssueOpen { |
| 286 | title: "test".to_string(), | 291 | title: "test".to_string(), |
| 287 | body: "body".to_string(), | 292 | body: "body".to_string(), |
| 293 | relates_to: None, | ||
| 288 | }, | 294 | }, |
| 289 | clock: 0, | 295 | clock: 0, |
| 290 | }; | 296 | }; |
| @@ -416,6 +422,7 @@ fn migrate_clocks_assigns_sequential_clocks() { | |||
| 416 | action: Action::IssueOpen { | 422 | action: Action::IssueOpen { |
| 417 | title: "test".to_string(), | 423 | title: "test".to_string(), |
| 418 | body: "body".to_string(), | 424 | body: "body".to_string(), |
| 425 | relates_to: None, | ||
| 419 | }, | 426 | }, |
| 420 | clock: 0, | 427 | clock: 0, |
| 421 | }; | 428 | }; |
| @@ -462,6 +469,7 @@ fn migrate_clocks_on_zero_clock_events() { | |||
| 462 | action: Action::IssueOpen { | 469 | action: Action::IssueOpen { |
| 463 | title: "test".to_string(), | 470 | title: "test".to_string(), |
| 464 | body: "body".to_string(), | 471 | body: "body".to_string(), |
| 472 | relates_to: None, | ||
| 465 | }, | 473 | }, |
| 466 | clock: 0, | 474 | clock: 0, |
| 467 | }; | 475 | }; |
| @@ -507,6 +515,7 @@ fn clock_field_survives_serialization_round_trip() { | |||
| 507 | action: Action::IssueOpen { | 515 | action: Action::IssueOpen { |
| 508 | title: "test".to_string(), | 516 | title: "test".to_string(), |
| 509 | body: "body".to_string(), | 517 | body: "body".to_string(), |
| 518 | relates_to: None, | ||
| 510 | }, | 519 | }, |
| 511 | clock: 42, | 520 | clock: 42, |
| 512 | }; | 521 | }; |
| @@ -530,6 +539,7 @@ fn clock_field_in_dag_round_trip() { | |||
| 530 | action: Action::IssueOpen { | 539 | action: Action::IssueOpen { |
| 531 | title: "test".to_string(), | 540 | title: "test".to_string(), |
| 532 | body: "body".to_string(), | 541 | body: "body".to_string(), |
| 542 | relates_to: None, | ||
| 533 | }, | 543 | }, |
| 534 | clock: 0, // Caller passes 0 | 544 | clock: 0, // Caller passes 0 |
| 535 | }; | 545 | }; |
tests/editor_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,354 @@ | |||
| 1 | //! End-to-end tests for the editor integration module. | ||
| 2 | //! | ||
| 3 | //! These tests exercise the public API of `git_collab::editor`, including | ||
| 4 | //! real process spawning (using `true`/`false` as stand-in editors) and | ||
| 5 | //! the `find_comment_at_scroll` helper used by the TUI. | ||
| 6 | //! | ||
| 7 | //! Tests that mutate environment variables (EDITOR, VISUAL) must not run | ||
| 8 | //! in parallel with each other. We serialise them via a process-wide mutex. | ||
| 9 | |||
| 10 | use std::io::Write; | ||
| 11 | use std::sync::Mutex; | ||
| 12 | |||
| 13 | use git_collab::editor::{find_comment_at_scroll, open_editor_at, resolve_editor}; | ||
| 14 | |||
| 15 | /// Guard to serialise tests that mutate EDITOR / VISUAL env vars. | ||
| 16 | static ENV_LOCK: Mutex<()> = Mutex::new(()); | ||
| 17 | |||
| 18 | // =========================================================================== | ||
| 19 | // resolve_editor — end-to-end (reads real env vars) | ||
| 20 | // =========================================================================== | ||
| 21 | |||
| 22 | #[test] | ||
| 23 | fn e2e_resolve_editor_returns_visual_when_set() { | ||
| 24 | let _guard = ENV_LOCK.lock().unwrap(); | ||
| 25 | let old_visual = std::env::var("VISUAL").ok(); | ||
| 26 | let old_editor = std::env::var("EDITOR").ok(); | ||
| 27 | |||
| 28 | std::env::set_var("VISUAL", "nvim"); | ||
| 29 | std::env::set_var("EDITOR", "vi"); | ||
| 30 | |||
| 31 | let result = resolve_editor(); | ||
| 32 | assert_eq!(result, Some("nvim".to_string())); | ||
| 33 | |||
| 34 | // Restore | ||
| 35 | restore_env("VISUAL", old_visual); | ||
| 36 | restore_env("EDITOR", old_editor); | ||
| 37 | } | ||
| 38 | |||
| 39 | #[test] | ||
| 40 | fn e2e_resolve_editor_falls_back_to_editor_when_visual_unset() { | ||
| 41 | let _guard = ENV_LOCK.lock().unwrap(); | ||
| 42 | let old_visual = std::env::var("VISUAL").ok(); | ||
| 43 | let old_editor = std::env::var("EDITOR").ok(); | ||
| 44 | |||
| 45 | std::env::remove_var("VISUAL"); | ||
| 46 | std::env::set_var("EDITOR", "nano"); | ||
| 47 | |||
| 48 | let result = resolve_editor(); | ||
| 49 | assert_eq!(result, Some("nano".to_string())); | ||
| 50 | |||
| 51 | restore_env("VISUAL", old_visual); | ||
| 52 | restore_env("EDITOR", old_editor); | ||
| 53 | } | ||
| 54 | |||
| 55 | #[test] | ||
| 56 | fn e2e_resolve_editor_none_when_both_unset() { | ||
| 57 | let _guard = ENV_LOCK.lock().unwrap(); | ||
| 58 | let old_visual = std::env::var("VISUAL").ok(); | ||
| 59 | let old_editor = std::env::var("EDITOR").ok(); | ||
| 60 | |||
| 61 | std::env::remove_var("VISUAL"); | ||
| 62 | std::env::remove_var("EDITOR"); | ||
| 63 | |||
| 64 | let result = resolve_editor(); | ||
| 65 | assert_eq!(result, None); | ||
| 66 | |||
| 67 | restore_env("VISUAL", old_visual); | ||
| 68 | restore_env("EDITOR", old_editor); | ||
| 69 | } | ||
| 70 | |||
| 71 | #[test] | ||
| 72 | fn e2e_resolve_editor_skips_empty_visual() { | ||
| 73 | let _guard = ENV_LOCK.lock().unwrap(); | ||
| 74 | let old_visual = std::env::var("VISUAL").ok(); | ||
| 75 | let old_editor = std::env::var("EDITOR").ok(); | ||
| 76 | |||
| 77 | std::env::set_var("VISUAL", ""); | ||
| 78 | std::env::set_var("EDITOR", "emacs"); | ||
| 79 | |||
| 80 | let result = resolve_editor(); | ||
| 81 | assert_eq!(result, Some("emacs".to_string())); | ||
| 82 | |||
| 83 | restore_env("VISUAL", old_visual); | ||
| 84 | restore_env("EDITOR", old_editor); | ||
| 85 | } | ||
| 86 | |||
| 87 | // =========================================================================== | ||
| 88 | // open_editor_at — end-to-end (spawns real processes) | ||
| 89 | // =========================================================================== | ||
| 90 | |||
| 91 | #[test] | ||
| 92 | fn e2e_open_editor_at_succeeds_with_true() { | ||
| 93 | let _guard = ENV_LOCK.lock().unwrap(); | ||
| 94 | let old_visual = std::env::var("VISUAL").ok(); | ||
| 95 | let old_editor = std::env::var("EDITOR").ok(); | ||
| 96 | |||
| 97 | // `true` exits 0 and ignores all arguments | ||
| 98 | std::env::set_var("EDITOR", "true"); | ||
| 99 | std::env::remove_var("VISUAL"); | ||
| 100 | |||
| 101 | let mut tmp = tempfile::NamedTempFile::new().unwrap(); | ||
| 102 | writeln!(tmp, "some content").unwrap(); | ||
| 103 | let path = tmp.path().to_str().unwrap(); | ||
| 104 | |||
| 105 | let result = open_editor_at(path, 10); | ||
| 106 | assert!(result.is_ok(), "Expected Ok, got {:?}", result); | ||
| 107 | |||
| 108 | restore_env("VISUAL", old_visual); | ||
| 109 | restore_env("EDITOR", old_editor); | ||
| 110 | } | ||
| 111 | |||
| 112 | #[test] | ||
| 113 | fn e2e_open_editor_at_reports_nonzero_exit() { | ||
| 114 | let _guard = ENV_LOCK.lock().unwrap(); | ||
| 115 | let old_visual = std::env::var("VISUAL").ok(); | ||
| 116 | let old_editor = std::env::var("EDITOR").ok(); | ||
| 117 | |||
| 118 | // `false` exits 1 | ||
| 119 | std::env::set_var("EDITOR", "false"); | ||
| 120 | std::env::remove_var("VISUAL"); | ||
| 121 | |||
| 122 | let mut tmp = tempfile::NamedTempFile::new().unwrap(); | ||
| 123 | writeln!(tmp, "some content").unwrap(); | ||
| 124 | let path = tmp.path().to_str().unwrap(); | ||
| 125 | |||
| 126 | let result = open_editor_at(path, 1); | ||
| 127 | assert!(result.is_err()); | ||
| 128 | let msg = format!("{}", result.unwrap_err()); | ||
| 129 | assert!(msg.contains("Editor exited with status"), "got: {}", msg); | ||
| 130 | |||
| 131 | restore_env("VISUAL", old_visual); | ||
| 132 | restore_env("EDITOR", old_editor); | ||
| 133 | } | ||
| 134 | |||
| 135 | #[test] | ||
| 136 | fn e2e_open_editor_at_errors_when_no_editor_configured() { | ||
| 137 | let _guard = ENV_LOCK.lock().unwrap(); | ||
| 138 | let old_visual = std::env::var("VISUAL").ok(); | ||
| 139 | let old_editor = std::env::var("EDITOR").ok(); | ||
| 140 | |||
| 141 | std::env::remove_var("VISUAL"); | ||
| 142 | std::env::remove_var("EDITOR"); | ||
| 143 | |||
| 144 | let mut tmp = tempfile::NamedTempFile::new().unwrap(); | ||
| 145 | writeln!(tmp, "content").unwrap(); | ||
| 146 | let path = tmp.path().to_str().unwrap(); | ||
| 147 | |||
| 148 | let result = open_editor_at(path, 1); | ||
| 149 | assert!(result.is_err()); | ||
| 150 | let msg = format!("{}", result.unwrap_err()); | ||
| 151 | assert!( | ||
| 152 | msg.contains("No editor configured"), | ||
| 153 | "Expected 'No editor configured', got: {}", | ||
| 154 | msg | ||
| 155 | ); | ||
| 156 | |||
| 157 | restore_env("VISUAL", old_visual); | ||
| 158 | restore_env("EDITOR", old_editor); | ||
| 159 | } | ||
| 160 | |||
| 161 | #[test] | ||
| 162 | fn e2e_open_editor_at_file_not_found() { | ||
| 163 | let _guard = ENV_LOCK.lock().unwrap(); | ||
| 164 | let old_visual = std::env::var("VISUAL").ok(); | ||
| 165 | let old_editor = std::env::var("EDITOR").ok(); | ||
| 166 | |||
| 167 | std::env::set_var("EDITOR", "true"); | ||
| 168 | std::env::remove_var("VISUAL"); | ||
| 169 | |||
| 170 | let result = open_editor_at("/tmp/nonexistent_editor_e2e_test_file.txt", 1); | ||
| 171 | assert!(result.is_err()); | ||
| 172 | let msg = format!("{}", result.unwrap_err()); | ||
| 173 | assert!(msg.contains("File not found"), "got: {}", msg); | ||
| 174 | |||
| 175 | restore_env("VISUAL", old_visual); | ||
| 176 | restore_env("EDITOR", old_editor); | ||
| 177 | } | ||
| 178 | |||
| 179 | #[test] | ||
| 180 | fn e2e_open_editor_at_bad_binary() { | ||
| 181 | let _guard = ENV_LOCK.lock().unwrap(); | ||
| 182 | let old_visual = std::env::var("VISUAL").ok(); | ||
| 183 | let old_editor = std::env::var("EDITOR").ok(); | ||
| 184 | |||
| 185 | std::env::set_var("EDITOR", "nonexistent_binary_xyz_999"); | ||
| 186 | std::env::remove_var("VISUAL"); | ||
| 187 | |||
| 188 | let mut tmp = tempfile::NamedTempFile::new().unwrap(); | ||
| 189 | writeln!(tmp, "content").unwrap(); | ||
| 190 | let path = tmp.path().to_str().unwrap(); | ||
| 191 | |||
| 192 | let result = open_editor_at(path, 1); | ||
| 193 | assert!(result.is_err()); | ||
| 194 | let msg = format!("{}", result.unwrap_err()); | ||
| 195 | assert!( | ||
| 196 | msg.contains("Failed to launch editor"), | ||
| 197 | "Expected launch failure, got: {}", | ||
| 198 | msg | ||
| 199 | ); | ||
| 200 | |||
| 201 | restore_env("VISUAL", old_visual); | ||
| 202 | restore_env("EDITOR", old_editor); | ||
| 203 | } | ||
| 204 | |||
| 205 | #[test] | ||
| 206 | fn e2e_open_editor_at_with_multi_word_editor() { | ||
| 207 | let _guard = ENV_LOCK.lock().unwrap(); | ||
| 208 | let old_visual = std::env::var("VISUAL").ok(); | ||
| 209 | let old_editor = std::env::var("EDITOR").ok(); | ||
| 210 | |||
| 211 | // Simulate `code --wait` style editor; `true` ignores extra args | ||
| 212 | std::env::set_var("EDITOR", "true --wait"); | ||
| 213 | std::env::remove_var("VISUAL"); | ||
| 214 | |||
| 215 | let mut tmp = tempfile::NamedTempFile::new().unwrap(); | ||
| 216 | writeln!(tmp, "content").unwrap(); | ||
| 217 | let path = tmp.path().to_str().unwrap(); | ||
| 218 | |||
| 219 | let result = open_editor_at(path, 42); | ||
| 220 | assert!(result.is_ok(), "Expected Ok with multi-word editor, got {:?}", result); | ||
| 221 | |||
| 222 | restore_env("VISUAL", old_visual); | ||
| 223 | restore_env("EDITOR", old_editor); | ||
| 224 | } | ||
| 225 | |||
| 226 | #[test] | ||
| 227 | fn e2e_open_editor_at_visual_takes_precedence_over_editor() { | ||
| 228 | let _guard = ENV_LOCK.lock().unwrap(); | ||
| 229 | let old_visual = std::env::var("VISUAL").ok(); | ||
| 230 | let old_editor = std::env::var("EDITOR").ok(); | ||
| 231 | |||
| 232 | // VISUAL=true should be used instead of EDITOR=false | ||
| 233 | // If EDITOR were used, the test would fail because `false` exits 1 | ||
| 234 | std::env::set_var("VISUAL", "true"); | ||
| 235 | std::env::set_var("EDITOR", "false"); | ||
| 236 | |||
| 237 | let mut tmp = tempfile::NamedTempFile::new().unwrap(); | ||
| 238 | writeln!(tmp, "content").unwrap(); | ||
| 239 | let path = tmp.path().to_str().unwrap(); | ||
| 240 | |||
| 241 | let result = open_editor_at(path, 1); | ||
| 242 | assert!( | ||
| 243 | result.is_ok(), | ||
| 244 | "VISUAL=true should take precedence over EDITOR=false, got {:?}", | ||
| 245 | result | ||
| 246 | ); | ||
| 247 | |||
| 248 | restore_env("VISUAL", old_visual); | ||
| 249 | restore_env("EDITOR", old_editor); | ||
| 250 | } | ||
| 251 | |||
| 252 | // =========================================================================== | ||
| 253 | // find_comment_at_scroll — realistic scenarios | ||
| 254 | // =========================================================================== | ||
| 255 | |||
| 256 | #[test] | ||
| 257 | fn e2e_find_comment_at_scroll_empty_list() { | ||
| 258 | let comments: Vec<(String, u32, usize)> = vec![]; | ||
| 259 | assert_eq!(find_comment_at_scroll(&comments, 50), None); | ||
| 260 | } | ||
| 261 | |||
| 262 | #[test] | ||
| 263 | fn e2e_find_comment_at_scroll_single_exact() { | ||
| 264 | let comments = vec![("src/main.rs".to_string(), 10, 20)]; | ||
| 265 | let result = find_comment_at_scroll(&comments, 20); | ||
| 266 | assert_eq!(result, Some(("src/main.rs".to_string(), 10))); | ||
| 267 | } | ||
| 268 | |||
| 269 | #[test] | ||
| 270 | fn e2e_find_comment_at_scroll_single_above() { | ||
| 271 | let comments = vec![("src/lib.rs".to_string(), 5, 10)]; | ||
| 272 | let result = find_comment_at_scroll(&comments, 50); | ||
| 273 | assert_eq!(result, Some(("src/lib.rs".to_string(), 5))); | ||
| 274 | } | ||
| 275 | |||
| 276 | #[test] | ||
| 277 | fn e2e_find_comment_at_scroll_single_below() { | ||
| 278 | let comments = vec![("src/lib.rs".to_string(), 5, 100)]; | ||
| 279 | let result = find_comment_at_scroll(&comments, 50); | ||
| 280 | assert_eq!(result, None); | ||
| 281 | } | ||
| 282 | |||
| 283 | #[test] | ||
| 284 | fn e2e_find_comment_at_scroll_picks_closest_above_from_many() { | ||
| 285 | // Simulate a diff with inline comments scattered across files | ||
| 286 | let comments = vec![ | ||
| 287 | ("src/editor.rs".to_string(), 12, 5), | ||
| 288 | ("src/editor.rs".to_string(), 30, 15), | ||
| 289 | ("src/tui.rs".to_string(), 100, 40), | ||
| 290 | ("src/tui.rs".to_string(), 150, 60), | ||
| 291 | ("src/main.rs".to_string(), 1, 80), | ||
| 292 | ]; | ||
| 293 | |||
| 294 | // Scroll at 45 — closest at-or-above is rendered_pos=40 (src/tui.rs:100) | ||
| 295 | let result = find_comment_at_scroll(&comments, 45); | ||
| 296 | assert_eq!(result, Some(("src/tui.rs".to_string(), 100))); | ||
| 297 | } | ||
| 298 | |||
| 299 | #[test] | ||
| 300 | fn e2e_find_comment_at_scroll_at_boundary_zero() { | ||
| 301 | let comments = vec![ | ||
| 302 | ("header.rs".to_string(), 1, 0), | ||
| 303 | ("body.rs".to_string(), 10, 20), | ||
| 304 | ]; | ||
| 305 | let result = find_comment_at_scroll(&comments, 0); | ||
| 306 | assert_eq!(result, Some(("header.rs".to_string(), 1))); | ||
| 307 | } | ||
| 308 | |||
| 309 | #[test] | ||
| 310 | fn e2e_find_comment_at_scroll_all_at_same_position() { | ||
| 311 | // Multiple comments at the same rendered position — first wins | ||
| 312 | let comments = vec![ | ||
| 313 | ("a.rs".to_string(), 1, 10), | ||
| 314 | ("b.rs".to_string(), 2, 10), | ||
| 315 | ("c.rs".to_string(), 3, 10), | ||
| 316 | ]; | ||
| 317 | let result = find_comment_at_scroll(&comments, 10); | ||
| 318 | assert_eq!(result, Some(("a.rs".to_string(), 1))); | ||
| 319 | } | ||
| 320 | |||
| 321 | #[test] | ||
| 322 | fn e2e_find_comment_at_scroll_large_scroll_position() { | ||
| 323 | let comments = vec![ | ||
| 324 | ("early.rs".to_string(), 1, 10), | ||
| 325 | ("late.rs".to_string(), 999, 500), | ||
| 326 | ]; | ||
| 327 | // Scroll way past all comments — should return the last one | ||
| 328 | let result = find_comment_at_scroll(&comments, u16::MAX); | ||
| 329 | assert_eq!(result, Some(("late.rs".to_string(), 999))); | ||
| 330 | } | ||
| 331 | |||
| 332 | #[test] | ||
| 333 | fn e2e_find_comment_at_scroll_unsorted_input() { | ||
| 334 | // Comments may not arrive sorted by rendered position | ||
| 335 | let comments = vec![ | ||
| 336 | ("c.rs".to_string(), 30, 50), | ||
| 337 | ("a.rs".to_string(), 10, 5), | ||
| 338 | ("b.rs".to_string(), 20, 25), | ||
| 339 | ]; | ||
| 340 | // Scroll at 30 — closest at-or-above is rendered_pos=25 | ||
| 341 | let result = find_comment_at_scroll(&comments, 30); | ||
| 342 | assert_eq!(result, Some(("b.rs".to_string(), 20))); | ||
| 343 | } | ||
| 344 | |||
| 345 | // =========================================================================== | ||
| 346 | // Helpers | ||
| 347 | // =========================================================================== | ||
| 348 | |||
| 349 | fn restore_env(var: &str, old: Option<String>) { | ||
| 350 | match old { | ||
| 351 | Some(val) => std::env::set_var(var, val), | ||
| 352 | None => std::env::remove_var(var), | ||
| 353 | } | ||
| 354 | } | ||
tests/identity_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,137 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use common::TestRepo; | ||
| 4 | |||
| 5 | // =========================================================================== | ||
| 6 | // Whoami command | ||
| 7 | // =========================================================================== | ||
| 8 | |||
| 9 | #[test] | ||
| 10 | fn test_whoami_shows_name_and_email() { | ||
| 11 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 12 | let out = repo.run_ok(&["whoami"]); | ||
| 13 | assert!(out.contains("Alice"), "should show user name"); | ||
| 14 | assert!(out.contains("alice@example.com"), "should show user email"); | ||
| 15 | } | ||
| 16 | |||
| 17 | #[test] | ||
| 18 | fn test_whoami_shows_signing_key() { | ||
| 19 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 20 | let out = repo.run_ok(&["whoami"]); | ||
| 21 | // Signing key is set up by TestRepo::new, so we should see a public key | ||
| 22 | assert!( | ||
| 23 | out.contains("Signing key:"), | ||
| 24 | "should show signing key label: {}", | ||
| 25 | out | ||
| 26 | ); | ||
| 27 | } | ||
| 28 | |||
| 29 | #[test] | ||
| 30 | fn test_whoami_no_aliases_initially() { | ||
| 31 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 32 | let out = repo.run_ok(&["whoami"]); | ||
| 33 | assert!( | ||
| 34 | out.contains("Aliases: (none)"), | ||
| 35 | "should show no aliases: {}", | ||
| 36 | out | ||
| 37 | ); | ||
| 38 | } | ||
| 39 | |||
| 40 | // =========================================================================== | ||
| 41 | // Identity alias add | ||
| 42 | // =========================================================================== | ||
| 43 | |||
| 44 | #[test] | ||
| 45 | fn test_identity_alias_add() { | ||
| 46 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 47 | let out = repo.run_ok(&["identity", "alias", "alice@work.com"]); | ||
| 48 | assert!(out.contains("alice@work.com"), "should confirm alias added"); | ||
| 49 | } | ||
| 50 | |||
| 51 | #[test] | ||
| 52 | fn test_identity_alias_shows_in_whoami() { | ||
| 53 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 54 | repo.run_ok(&["identity", "alias", "alice@work.com"]); | ||
| 55 | let out = repo.run_ok(&["whoami"]); | ||
| 56 | assert!( | ||
| 57 | out.contains("alice@work.com"), | ||
| 58 | "whoami should show alias: {}", | ||
| 59 | out | ||
| 60 | ); | ||
| 61 | } | ||
| 62 | |||
| 63 | #[test] | ||
| 64 | fn test_identity_alias_add_multiple() { | ||
| 65 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 66 | repo.run_ok(&["identity", "alias", "alice@work.com"]); | ||
| 67 | repo.run_ok(&["identity", "alias", "alice@personal.org"]); | ||
| 68 | let out = repo.run_ok(&["identity", "list"]); | ||
| 69 | assert!(out.contains("alice@work.com"), "should list first alias"); | ||
| 70 | assert!(out.contains("alice@personal.org"), "should list second alias"); | ||
| 71 | } | ||
| 72 | |||
| 73 | #[test] | ||
| 74 | fn test_identity_alias_duplicate_rejected() { | ||
| 75 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 76 | repo.run_ok(&["identity", "alias", "alice@work.com"]); | ||
| 77 | let out = repo.run_ok(&["identity", "alias", "alice@work.com"]); | ||
| 78 | assert!( | ||
| 79 | out.contains("already"), | ||
| 80 | "should indicate alias already exists: {}", | ||
| 81 | out | ||
| 82 | ); | ||
| 83 | } | ||
| 84 | |||
| 85 | #[test] | ||
| 86 | fn test_identity_alias_cannot_add_primary_email() { | ||
| 87 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 88 | let err = repo.run_err(&["identity", "alias", "alice@example.com"]); | ||
| 89 | assert!( | ||
| 90 | err.contains("primary email"), | ||
| 91 | "should reject aliasing primary email: {}", | ||
| 92 | err | ||
| 93 | ); | ||
| 94 | } | ||
| 95 | |||
| 96 | // =========================================================================== | ||
| 97 | // Identity list | ||
| 98 | // =========================================================================== | ||
| 99 | |||
| 100 | #[test] | ||
| 101 | fn test_identity_list_shows_primary_and_aliases() { | ||
| 102 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 103 | repo.run_ok(&["identity", "alias", "alice@work.com"]); | ||
| 104 | let out = repo.run_ok(&["identity", "list"]); | ||
| 105 | assert!(out.contains("alice@example.com"), "should show primary email"); | ||
| 106 | assert!(out.contains("alice@work.com"), "should show alias"); | ||
| 107 | } | ||
| 108 | |||
| 109 | // =========================================================================== | ||
| 110 | // Identity alias remove | ||
| 111 | // =========================================================================== | ||
| 112 | |||
| 113 | #[test] | ||
| 114 | fn test_identity_alias_remove() { | ||
| 115 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 116 | repo.run_ok(&["identity", "alias", "alice@work.com"]); | ||
| 117 | let out = repo.run_ok(&["identity", "unalias", "alice@work.com"]); | ||
| 118 | assert!(out.contains("alice@work.com"), "should confirm removal"); | ||
| 119 | |||
| 120 | let list_out = repo.run_ok(&["identity", "list"]); | ||
| 121 | assert!( | ||
| 122 | !list_out.contains("alice@work.com"), | ||
| 123 | "alias should be gone: {}", | ||
| 124 | list_out | ||
| 125 | ); | ||
| 126 | } | ||
| 127 | |||
| 128 | #[test] | ||
| 129 | fn test_identity_alias_remove_nonexistent_errors() { | ||
| 130 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 131 | let err = repo.run_err(&["identity", "unalias", "nobody@example.com"]); | ||
| 132 | assert!( | ||
| 133 | err.contains("not found"), | ||
| 134 | "should error on nonexistent alias: {}", | ||
| 135 | err | ||
| 136 | ); | ||
| 137 | } | ||
tests/signing_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -15,6 +15,7 @@ fn make_event() -> Event { | |||
| 15 | action: Action::IssueOpen { | 15 | action: Action::IssueOpen { |
| 16 | title: "Test issue".to_string(), | 16 | title: "Test issue".to_string(), |
| 17 | body: "This is a test".to_string(), | 17 | body: "This is a test".to_string(), |
| 18 | relates_to: None, | ||
| 18 | }, | 19 | }, |
| 19 | clock: 0, | 20 | clock: 0, |
| 20 | } | 21 | } |
tests/sort_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,358 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use common::{alice, bob, TestRepo}; | ||
| 4 | use ed25519_dalek::SigningKey; | ||
| 5 | use git2::Repository; | ||
| 6 | use rand_core::OsRng; | ||
| 7 | use tempfile::TempDir; | ||
| 8 | |||
| 9 | use git_collab::cli::SortMode; | ||
| 10 | use git_collab::dag; | ||
| 11 | use git_collab::event::{Action, Author, Event}; | ||
| 12 | use git_collab::state::{self, IssueState, PatchState}; | ||
| 13 | |||
| 14 | fn test_signing_key() -> SigningKey { | ||
| 15 | SigningKey::generate(&mut OsRng) | ||
| 16 | } | ||
| 17 | |||
| 18 | fn init_repo(dir: &std::path::Path, author: &Author) -> Repository { | ||
| 19 | let repo = Repository::init(dir).expect("init repo"); | ||
| 20 | { | ||
| 21 | let mut config = repo.config().unwrap(); | ||
| 22 | config.set_str("user.name", &author.name).unwrap(); | ||
| 23 | config.set_str("user.email", &author.email).unwrap(); | ||
| 24 | } | ||
| 25 | repo | ||
| 26 | } | ||
| 27 | |||
| 28 | /// Create an issue with a specific timestamp. Returns (ref_name, id). | ||
| 29 | fn open_issue_at( | ||
| 30 | repo: &Repository, | ||
| 31 | author: &Author, | ||
| 32 | title: &str, | ||
| 33 | timestamp: &str, | ||
| 34 | ) -> (String, String) { | ||
| 35 | let sk = test_signing_key(); | ||
| 36 | let event = Event { | ||
| 37 | timestamp: timestamp.to_string(), | ||
| 38 | author: author.clone(), | ||
| 39 | action: Action::IssueOpen { | ||
| 40 | title: title.to_string(), | ||
| 41 | body: "".to_string(), | ||
| 42 | relates_to: None, | ||
| 43 | }, | ||
| 44 | clock: 0, | ||
| 45 | }; | ||
| 46 | let oid = dag::create_root_event(repo, &event, &sk).unwrap(); | ||
| 47 | let id = oid.to_string(); | ||
| 48 | let ref_name = format!("refs/collab/issues/{}", id); | ||
| 49 | repo.reference(&ref_name, oid, false, "test open").unwrap(); | ||
| 50 | (ref_name, id) | ||
| 51 | } | ||
| 52 | |||
| 53 | /// Add a comment with a specific timestamp to an issue. | ||
| 54 | fn add_comment_at( | ||
| 55 | repo: &Repository, | ||
| 56 | ref_name: &str, | ||
| 57 | author: &Author, | ||
| 58 | body: &str, | ||
| 59 | timestamp: &str, | ||
| 60 | ) { | ||
| 61 | let sk = test_signing_key(); | ||
| 62 | let event = Event { | ||
| 63 | timestamp: timestamp.to_string(), | ||
| 64 | author: author.clone(), | ||
| 65 | action: Action::IssueComment { | ||
| 66 | body: body.to_string(), | ||
| 67 | }, | ||
| 68 | clock: 0, | ||
| 69 | }; | ||
| 70 | dag::append_event(repo, ref_name, &event, &sk).unwrap(); | ||
| 71 | } | ||
| 72 | |||
| 73 | /// Create a patch with a specific timestamp. Returns (ref_name, id). | ||
| 74 | fn create_patch_at( | ||
| 75 | repo: &Repository, | ||
| 76 | author: &Author, | ||
| 77 | title: &str, | ||
| 78 | timestamp: &str, | ||
| 79 | ) -> (String, String) { | ||
| 80 | let sk = test_signing_key(); | ||
| 81 | let event = Event { | ||
| 82 | timestamp: timestamp.to_string(), | ||
| 83 | author: author.clone(), | ||
| 84 | action: Action::PatchCreate { | ||
| 85 | title: title.to_string(), | ||
| 86 | body: "".to_string(), | ||
| 87 | base_ref: "main".to_string(), | ||
| 88 | branch: format!("branch-{}", title.replace(' ', "-")), | ||
| 89 | fixes: None, | ||
| 90 | }, | ||
| 91 | clock: 0, | ||
| 92 | }; | ||
| 93 | let oid = dag::create_root_event(repo, &event, &sk).unwrap(); | ||
| 94 | let id = oid.to_string(); | ||
| 95 | let ref_name = format!("refs/collab/patches/{}", id); | ||
| 96 | repo.reference(&ref_name, oid, false, "test patch").unwrap(); | ||
| 97 | (ref_name, id) | ||
| 98 | } | ||
| 99 | |||
| 100 | /// Add a comment to a patch with a specific timestamp. | ||
| 101 | fn add_patch_comment_at( | ||
| 102 | repo: &Repository, | ||
| 103 | ref_name: &str, | ||
| 104 | author: &Author, | ||
| 105 | body: &str, | ||
| 106 | timestamp: &str, | ||
| 107 | ) { | ||
| 108 | let sk = test_signing_key(); | ||
| 109 | let event = Event { | ||
| 110 | timestamp: timestamp.to_string(), | ||
| 111 | author: author.clone(), | ||
| 112 | action: Action::PatchComment { | ||
| 113 | body: body.to_string(), | ||
| 114 | }, | ||
| 115 | clock: 0, | ||
| 116 | }; | ||
| 117 | dag::append_event(repo, ref_name, &event, &sk).unwrap(); | ||
| 118 | } | ||
| 119 | |||
| 120 | // ---- Issue sorting tests ---- | ||
| 121 | |||
| 122 | #[test] | ||
| 123 | fn test_issue_last_updated_populated() { | ||
| 124 | let dir = TempDir::new().unwrap(); | ||
| 125 | let repo = init_repo(dir.path(), &alice()); | ||
| 126 | |||
| 127 | let (ref_name, id) = open_issue_at(&repo, &alice(), "Test issue", "2025-01-01T00:00:00Z"); | ||
| 128 | add_comment_at( | ||
| 129 | &repo, | ||
| 130 | &ref_name, | ||
| 131 | &bob(), | ||
| 132 | "later comment", | ||
| 133 | "2025-06-15T12:00:00Z", | ||
| 134 | ); | ||
| 135 | |||
| 136 | let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 137 | assert_eq!(state.last_updated, "2025-06-15T12:00:00Z"); | ||
| 138 | assert_eq!(state.created_at, "2025-01-01T00:00:00Z"); | ||
| 139 | } | ||
| 140 | |||
| 141 | #[test] | ||
| 142 | fn test_issue_default_sort_by_recency() { | ||
| 143 | let dir = TempDir::new().unwrap(); | ||
| 144 | let repo = init_repo(dir.path(), &alice()); | ||
| 145 | |||
| 146 | // Issue A: created early, updated recently | ||
| 147 | let (ref_a, _) = open_issue_at(&repo, &alice(), "Alpha issue", "2025-01-01T00:00:00Z"); | ||
| 148 | add_comment_at( | ||
| 149 | &repo, | ||
| 150 | &ref_a, | ||
| 151 | &bob(), | ||
| 152 | "recent comment", | ||
| 153 | "2025-12-01T00:00:00Z", | ||
| 154 | ); | ||
| 155 | |||
| 156 | // Issue B: created later, never updated after creation | ||
| 157 | let (_, _) = open_issue_at(&repo, &alice(), "Beta issue", "2025-06-01T00:00:00Z"); | ||
| 158 | |||
| 159 | let issues = state::list_issues(&repo).unwrap(); | ||
| 160 | assert_eq!(issues.len(), 2); | ||
| 161 | |||
| 162 | // Default sort = recent: issue A (last_updated=2025-12) should come first | ||
| 163 | let entries = git_collab::issue::list(&repo, true, false, None, None, SortMode::Recent).unwrap(); | ||
| 164 | assert_eq!(entries.len(), 2); | ||
| 165 | assert_eq!(entries[0].issue.title, "Alpha issue"); | ||
| 166 | assert_eq!(entries[1].issue.title, "Beta issue"); | ||
| 167 | } | ||
| 168 | |||
| 169 | #[test] | ||
| 170 | fn test_issue_sort_by_created() { | ||
| 171 | let dir = TempDir::new().unwrap(); | ||
| 172 | let repo = init_repo(dir.path(), &alice()); | ||
| 173 | |||
| 174 | // Issue A: created early, updated recently | ||
| 175 | let (ref_a, _) = open_issue_at(&repo, &alice(), "Alpha issue", "2025-01-01T00:00:00Z"); | ||
| 176 | add_comment_at( | ||
| 177 | &repo, | ||
| 178 | &ref_a, | ||
| 179 | &bob(), | ||
| 180 | "recent comment", | ||
| 181 | "2025-12-01T00:00:00Z", | ||
| 182 | ); | ||
| 183 | |||
| 184 | // Issue B: created later | ||
| 185 | let (_, _) = open_issue_at(&repo, &alice(), "Beta issue", "2025-06-01T00:00:00Z"); | ||
| 186 | |||
| 187 | // Sort by created: B (2025-06) comes first (descending) | ||
| 188 | let entries = git_collab::issue::list(&repo, true, false, None, None, SortMode::Created).unwrap(); | ||
| 189 | assert_eq!(entries.len(), 2); | ||
| 190 | assert_eq!(entries[0].issue.title, "Beta issue"); | ||
| 191 | assert_eq!(entries[1].issue.title, "Alpha issue"); | ||
| 192 | } | ||
| 193 | |||
| 194 | #[test] | ||
| 195 | fn test_issue_sort_alpha() { | ||
| 196 | let dir = TempDir::new().unwrap(); | ||
| 197 | let repo = init_repo(dir.path(), &alice()); | ||
| 198 | |||
| 199 | open_issue_at(&repo, &alice(), "Zebra issue", "2025-01-01T00:00:00Z"); | ||
| 200 | open_issue_at(&repo, &alice(), "Apple issue", "2025-06-01T00:00:00Z"); | ||
| 201 | open_issue_at(&repo, &alice(), "Mango issue", "2025-03-01T00:00:00Z"); | ||
| 202 | |||
| 203 | let entries = git_collab::issue::list(&repo, true, false, None, None, SortMode::Alpha).unwrap(); | ||
| 204 | assert_eq!(entries.len(), 3); | ||
| 205 | assert_eq!(entries[0].issue.title, "Apple issue"); | ||
| 206 | assert_eq!(entries[1].issue.title, "Mango issue"); | ||
| 207 | assert_eq!(entries[2].issue.title, "Zebra issue"); | ||
| 208 | } | ||
| 209 | |||
| 210 | // ---- Patch sorting tests ---- | ||
| 211 | |||
| 212 | #[test] | ||
| 213 | fn test_patch_last_updated_populated() { | ||
| 214 | let dir = TempDir::new().unwrap(); | ||
| 215 | let repo = init_repo(dir.path(), &alice()); | ||
| 216 | |||
| 217 | let (ref_name, id) = | ||
| 218 | create_patch_at(&repo, &alice(), "Test patch", "2025-01-01T00:00:00Z"); | ||
| 219 | add_patch_comment_at( | ||
| 220 | &repo, | ||
| 221 | &ref_name, | ||
| 222 | &bob(), | ||
| 223 | "later comment", | ||
| 224 | "2025-06-15T12:00:00Z", | ||
| 225 | ); | ||
| 226 | |||
| 227 | let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 228 | assert_eq!(state.last_updated, "2025-06-15T12:00:00Z"); | ||
| 229 | assert_eq!(state.created_at, "2025-01-01T00:00:00Z"); | ||
| 230 | } | ||
| 231 | |||
| 232 | #[test] | ||
| 233 | fn test_patch_default_sort_by_recency() { | ||
| 234 | let dir = TempDir::new().unwrap(); | ||
| 235 | let repo = init_repo(dir.path(), &alice()); | ||
| 236 | |||
| 237 | // Patch A: created early, updated recently | ||
| 238 | let (ref_a, _) = create_patch_at(&repo, &alice(), "Alpha patch", "2025-01-01T00:00:00Z"); | ||
| 239 | add_patch_comment_at( | ||
| 240 | &repo, | ||
| 241 | &ref_a, | ||
| 242 | &bob(), | ||
| 243 | "recent comment", | ||
| 244 | "2025-12-01T00:00:00Z", | ||
| 245 | ); | ||
| 246 | |||
| 247 | // Patch B: created later, never updated | ||
| 248 | let (_, _) = create_patch_at(&repo, &alice(), "Beta patch", "2025-06-01T00:00:00Z"); | ||
| 249 | |||
| 250 | let patches = git_collab::patch::list(&repo, true, false, None, None, SortMode::Recent).unwrap(); | ||
| 251 | assert_eq!(patches.len(), 2); | ||
| 252 | assert_eq!(patches[0].title, "Alpha patch"); | ||
| 253 | assert_eq!(patches[1].title, "Beta patch"); | ||
| 254 | } | ||
| 255 | |||
| 256 | #[test] | ||
| 257 | fn test_patch_sort_by_created() { | ||
| 258 | let dir = TempDir::new().unwrap(); | ||
| 259 | let repo = init_repo(dir.path(), &alice()); | ||
| 260 | |||
| 261 | let (ref_a, _) = create_patch_at(&repo, &alice(), "Alpha patch", "2025-01-01T00:00:00Z"); | ||
| 262 | add_patch_comment_at( | ||
| 263 | &repo, | ||
| 264 | &ref_a, | ||
| 265 | &bob(), | ||
| 266 | "recent comment", | ||
| 267 | "2025-12-01T00:00:00Z", | ||
| 268 | ); | ||
| 269 | |||
| 270 | let (_, _) = create_patch_at(&repo, &alice(), "Beta patch", "2025-06-01T00:00:00Z"); | ||
| 271 | |||
| 272 | let patches = git_collab::patch::list(&repo, true, false, None, None, SortMode::Created).unwrap(); | ||
| 273 | assert_eq!(patches.len(), 2); | ||
| 274 | assert_eq!(patches[0].title, "Beta patch"); | ||
| 275 | assert_eq!(patches[1].title, "Alpha patch"); | ||
| 276 | } | ||
| 277 | |||
| 278 | #[test] | ||
| 279 | fn test_patch_sort_alpha() { | ||
| 280 | let dir = TempDir::new().unwrap(); | ||
| 281 | let repo = init_repo(dir.path(), &alice()); | ||
| 282 | |||
| 283 | create_patch_at(&repo, &alice(), "Zebra patch", "2025-01-01T00:00:00Z"); | ||
| 284 | create_patch_at(&repo, &alice(), "Apple patch", "2025-06-01T00:00:00Z"); | ||
| 285 | create_patch_at(&repo, &alice(), "Mango patch", "2025-03-01T00:00:00Z"); | ||
| 286 | |||
| 287 | let patches = git_collab::patch::list(&repo, true, false, None, None, SortMode::Alpha).unwrap(); | ||
| 288 | assert_eq!(patches.len(), 3); | ||
| 289 | assert_eq!(patches[0].title, "Apple patch"); | ||
| 290 | assert_eq!(patches[1].title, "Mango patch"); | ||
| 291 | assert_eq!(patches[2].title, "Zebra patch"); | ||
| 292 | } | ||
| 293 | |||
| 294 | // ---- CLI integration test ---- | ||
| 295 | |||
| 296 | #[test] | ||
| 297 | fn test_cli_issue_list_sort_flag() { | ||
| 298 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 299 | |||
| 300 | repo.issue_open("Zebra"); | ||
| 301 | // Small delay to ensure different timestamps | ||
| 302 | std::thread::sleep(std::time::Duration::from_millis(50)); | ||
| 303 | repo.issue_open("Apple"); | ||
| 304 | |||
| 305 | // Default sort (recent) should show Apple first (created more recently) | ||
| 306 | let out = repo.run_ok(&["issue", "list"]); | ||
| 307 | let lines: Vec<&str> = out.lines().collect(); | ||
| 308 | assert_eq!(lines.len(), 2); | ||
| 309 | assert!(lines[0].contains("Apple"), "expected Apple first, got: {}", out); | ||
| 310 | |||
| 311 | // Alpha sort should show Apple first (alphabetical) | ||
| 312 | let out = repo.run_ok(&["issue", "list", "--sort", "alpha"]); | ||
| 313 | let lines: Vec<&str> = out.lines().collect(); | ||
| 314 | assert!(lines[0].contains("Apple"), "expected Apple first in alpha sort, got: {}", out); | ||
| 315 | assert!(lines[1].contains("Zebra"), "expected Zebra second in alpha sort, got: {}", out); | ||
| 316 | |||
| 317 | // Created sort should show Apple first (most recently created) | ||
| 318 | let out = repo.run_ok(&["issue", "list", "--sort", "created"]); | ||
| 319 | let lines: Vec<&str> = out.lines().collect(); | ||
| 320 | assert!(lines[0].contains("Apple"), "expected Apple first in created sort, got: {}", out); | ||
| 321 | } | ||
| 322 | |||
| 323 | #[test] | ||
| 324 | fn test_cli_patch_list_sort_flag() { | ||
| 325 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 326 | |||
| 327 | repo.patch_create("Zebra"); | ||
| 328 | std::thread::sleep(std::time::Duration::from_millis(50)); | ||
| 329 | repo.patch_create("Apple"); | ||
| 330 | |||
| 331 | // Alpha sort | ||
| 332 | let out = repo.run_ok(&["patch", "list", "--sort", "alpha"]); | ||
| 333 | let lines: Vec<&str> = out.lines().collect(); | ||
| 334 | assert_eq!(lines.len(), 2); | ||
| 335 | assert!(lines[0].contains("Apple"), "expected Apple first in alpha sort, got: {}", out); | ||
| 336 | assert!(lines[1].contains("Zebra"), "expected Zebra second in alpha sort, got: {}", out); | ||
| 337 | } | ||
| 338 | |||
| 339 | #[test] | ||
| 340 | fn test_last_updated_serde_default() { | ||
| 341 | // Verify that deserializing an IssueState without last_updated works (backward compat) | ||
| 342 | let json = r#"{ | ||
| 343 | "id": "abc123", | ||
| 344 | "title": "Test", | ||
| 345 | "body": "", | ||
| 346 | "status": "open", | ||
| 347 | "close_reason": null, | ||
| 348 | "closed_by": null, | ||
| 349 | "labels": [], | ||
| 350 | "assignees": [], | ||
| 351 | "comments": [], | ||
| 352 | "created_at": "2025-01-01T00:00:00Z", | ||
| 353 | "author": {"name": "Alice", "email": "alice@example.com"} | ||
| 354 | }"#; | ||
| 355 | let state: IssueState = serde_json::from_str(json).unwrap(); | ||
| 356 | assert_eq!(state.last_updated, ""); | ||
| 357 | assert_eq!(state.title, "Test"); | ||
| 358 | } | ||
tests/sync_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -474,6 +474,7 @@ fn test_unsigned_event_sync_rejected() { | |||
| 474 | action: Action::IssueOpen { | 474 | action: Action::IssueOpen { |
| 475 | title: "Unsigned issue".to_string(), | 475 | title: "Unsigned issue".to_string(), |
| 476 | body: "No signature".to_string(), | 476 | body: "No signature".to_string(), |
| 477 | relates_to: None, | ||
| 477 | }, | 478 | }, |
| 478 | clock: 0, | 479 | clock: 0, |
| 479 | }; | 480 | }; |
| @@ -522,6 +523,7 @@ fn test_tampered_event_sync_rejected() { | |||
| 522 | action: Action::IssueOpen { | 523 | action: Action::IssueOpen { |
| 523 | title: "Tampered issue".to_string(), | 524 | title: "Tampered issue".to_string(), |
| 524 | body: "Will be tampered".to_string(), | 525 | body: "Will be tampered".to_string(), |
| 526 | relates_to: None, | ||
| 525 | }, | 527 | }, |
| 526 | clock: 0, | 528 | clock: 0, |
| 527 | }; | 529 | }; |