a73x

c90c2ec4

Add a global trust store, identity aliases and a state cache

a73x   2026-03-21 17:12

Commit message
Add a global trust store, identity aliases and a state cache

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 email
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 email
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 };