From dee798595c11bf416f5c11dd741338997fd08612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Villase=C3=B1or=20Montfort?= <195970+montfort@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:48:38 -0600 Subject: [PATCH] feat: add `devtrail explore` interactive TUI documentation viewer (#9) Add a full terminal UI for browsing and reading DevTrail documentation directly from the command line, powered by ratatui + crossterm. - Two-panel layout (Navigation + Document) with adaptive fallback for narrow terminals - Metadata panel showing status, confidence, risk bars, tags, and navigable related links - Markdown rendering with heading indentation, styled tables, code blocks, and lists - Hyperlinked navigation between related documents with history stack - Search/filter by filename, title, tags, date, or document ID - Fullscreen document mode, vim-style keybindings, and help popup - Feature-flagged under `tui` (enabled by default, opt-out with --no-default-features) Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/Cargo.lock | 401 +++++++++++++++++- cli/Cargo.toml | 7 + cli/src/commands/explore.rs | 23 ++ cli/src/commands/mod.rs | 2 + cli/src/main.rs | 11 + cli/src/tui/app.rs | 557 ++++++++++++++++++++++++++ cli/src/tui/document.rs | 150 +++++++ cli/src/tui/event.rs | 150 +++++++ cli/src/tui/index.rs | 361 +++++++++++++++++ cli/src/tui/markdown.rs | 402 +++++++++++++++++++ cli/src/tui/mod.rs | 64 +++ cli/src/tui/ui.rs | 115 ++++++ cli/src/tui/widgets/doc_viewer.rs | 167 ++++++++ cli/src/tui/widgets/help_popup.rs | 98 +++++ cli/src/tui/widgets/metadata_panel.rs | 260 ++++++++++++ cli/src/tui/widgets/mod.rs | 5 + cli/src/tui/widgets/nav_tree.rs | 216 ++++++++++ cli/src/tui/widgets/status_bar.rs | 101 +++++ 18 files changed, 3081 insertions(+), 9 deletions(-) create mode 100644 cli/src/commands/explore.rs create mode 100644 cli/src/tui/app.rs create mode 100644 cli/src/tui/document.rs create mode 100644 cli/src/tui/event.rs create mode 100644 cli/src/tui/index.rs create mode 100644 cli/src/tui/markdown.rs create mode 100644 cli/src/tui/mod.rs create mode 100644 cli/src/tui/ui.rs create mode 100644 cli/src/tui/widgets/doc_viewer.rs create mode 100644 cli/src/tui/widgets/help_popup.rs create mode 100644 cli/src/tui/widgets/metadata_panel.rs create mode 100644 cli/src/tui/widgets/mod.rs create mode 100644 cli/src/tui/widgets/nav_tree.rs create mode 100644 cli/src/tui/widgets/status_bar.rs diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 7f2f487..ef8ffb0 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -28,6 +28,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "0.6.21" @@ -189,6 +195,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.56" @@ -279,6 +300,20 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "console" version = "0.15.11" @@ -288,7 +323,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.0", "windows-sys 0.59.0", ] @@ -363,6 +398,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -373,6 +433,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "deflate64" version = "0.1.11" @@ -401,16 +495,19 @@ dependencies = [ [[package]] name = "devtrail-cli" -version = "1.0.0" +version = "1.0.4" dependencies = [ "anyhow", "assert_cmd", "clap", "colored", + "crossterm", "dialoguer", "flate2", "indicatif", "predicates", + "pulldown-cmark", + "ratatui", "reqwest", "semver", "serde", @@ -463,6 +560,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -631,6 +734,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width 0.2.0", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -696,6 +808,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -926,6 +1040,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -968,10 +1088,19 @@ dependencies = [ "console", "number_prefix", "portable-atomic", - "unicode-width", + "unicode-width 0.2.0", "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -981,6 +1110,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1003,6 +1145,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1056,9 +1207,15 @@ dependencies = [ "bitflags", "libc", "plain", - "redox_syscall", + "redox_syscall 0.7.3", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1071,12 +1228,30 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1133,6 +1308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1237,6 +1413,35 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -1356,6 +1561,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quinn" version = "0.11.9" @@ -1455,6 +1679,36 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.7.3" @@ -1559,6 +1813,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1568,7 +1835,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1628,6 +1895,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "3.7.0" @@ -1759,6 +2032,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -1793,12 +2097,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1877,7 +2209,7 @@ dependencies = [ "fastrand", "getrandom 0.4.1", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -2094,17 +2426,46 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode-xid" @@ -2324,6 +2685,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -2625,7 +3008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] diff --git a/cli/Cargo.toml b/cli/Cargo.toml index a95bc15..b398280 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -31,6 +31,13 @@ anyhow = "1" semver = "1" flate2 = "1" tar = "0.4" +ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] } +crossterm = { version = "0.28", optional = true } +pulldown-cmark = { version = "0.12", optional = true } + +[features] +default = ["tui"] +tui = ["ratatui", "crossterm", "pulldown-cmark"] [dev-dependencies] assert_cmd = "2" diff --git a/cli/src/commands/explore.rs b/cli/src/commands/explore.rs new file mode 100644 index 0000000..ae6c687 --- /dev/null +++ b/cli/src/commands/explore.rs @@ -0,0 +1,23 @@ +use anyhow::{bail, Result}; +use std::path::PathBuf; + +use crate::utils; + +pub fn run(path: &str) -> Result<()> { + let target = PathBuf::from(path) + .canonicalize() + .unwrap_or_else(|_| PathBuf::from(path)); + + let devtrail_dir = target.join(".devtrail"); + + if !devtrail_dir.exists() { + utils::warn(&format!( + "DevTrail is not initialized in {}", + target.display() + )); + utils::info("Run 'devtrail init' to initialize DevTrail in this directory."); + bail!("No DevTrail installation found"); + } + + crate::tui::run(&target) +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index baf3725..c090cd7 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -1,4 +1,6 @@ pub mod about; +#[cfg(feature = "tui")] +pub mod explore; pub mod init; pub mod remove; pub mod status; diff --git a/cli/src/main.rs b/cli/src/main.rs index f429603..088c4a1 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -8,6 +8,8 @@ mod inject; mod manifest; mod platform; mod self_update; +#[cfg(feature = "tui")] +mod tui; mod utils; /// DevTrail CLI - Documentation Governance for AI-Assisted Development @@ -47,6 +49,13 @@ enum Commands { }, /// Show version, author, and license information About, + /// Explore DevTrail documentation interactively + #[cfg(feature = "tui")] + Explore { + /// Target directory (default: current directory) + #[arg(default_value = ".")] + path: String, + }, } fn main() { @@ -63,6 +72,8 @@ fn main() { Commands::Remove { full } => commands::remove::run(full), Commands::Status { path } => commands::status::run(&path), Commands::About => commands::about::run(), + #[cfg(feature = "tui")] + Commands::Explore { path } => commands::explore::run(&path), }; if let Err(e) = result { diff --git a/cli/src/tui/app.rs b/cli/src/tui/app.rs new file mode 100644 index 0000000..c8da840 --- /dev/null +++ b/cli/src/tui/app.rs @@ -0,0 +1,557 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use super::document::Document; +use super::index::DocIndex; + +/// Which panel is currently active +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActivePanel { + Navigation, + Metadata, + Document, +} + +/// Current view mode +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViewMode { + /// Two-panel layout (≥100 cols) or single-panel nav (<100 cols) + Normal, + /// Document takes full screen + Fullscreen, + /// Help popup is shown + Help, +} + +/// What is selected in the navigation tree +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NavSelection { + /// A top-level group is selected + Group(usize), + /// A subgroup within a group is selected + Subgroup(usize, usize), + /// A file in a group's direct files + GroupFile(usize, usize), + /// A file within a subgroup + SubgroupFile(usize, usize, usize), +} + +/// Sort order for file listings +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortOrder { + Name, + Date, +} + +/// Navigation history entry for hyperlinked navigation +#[derive(Debug, Clone)] +struct HistoryEntry { + selection: NavSelection, + scroll_offset: u16, +} + +pub struct App { + pub index: DocIndex, + pub active_panel: ActivePanel, + pub view_mode: ViewMode, + pub selection: NavSelection, + /// Which groups are expanded in the tree + pub expanded_groups: Vec, + /// Scroll offset for the document viewer + pub doc_scroll: u16, + /// Total lines in current document (for scroll bounds) + pub doc_total_lines: usize, + /// Currently loaded document + pub current_doc: Option, + /// Document cache: path -> Document + doc_cache: HashMap, + /// Navigation history stack for hyperlinked navigation + nav_history: Vec, + /// Sort order + pub sort_order: SortOrder, + /// Search/filter query + pub search_query: Option, + pub search_input: String, + pub is_searching: bool, + /// Currently selected related link index (for Tab cycling in document panel) + pub selected_related: Option, + /// Temporary notification message shown in the status bar + pub notification: Option, + /// Should the app quit + pub should_quit: bool, + /// Project root path + pub project_root: PathBuf, +} + +impl App { + pub fn new(project_root: &Path) -> Self { + let devtrail_dir = project_root.join(".devtrail"); + let index = DocIndex::build(&devtrail_dir); + let num_groups = index.groups.len(); + + Self { + index, + active_panel: ActivePanel::Navigation, + view_mode: ViewMode::Normal, + selection: NavSelection::Group(0), + expanded_groups: vec![false; num_groups], + doc_scroll: 0, + doc_total_lines: 0, + current_doc: None, + doc_cache: HashMap::new(), + nav_history: Vec::new(), + sort_order: SortOrder::Name, + search_query: None, + search_input: String::new(), + is_searching: false, + selected_related: None, + notification: None, + should_quit: false, + project_root: project_root.to_path_buf(), + } + } + + /// Move selection up in the navigation tree + pub fn nav_up(&mut self) { + let items = self.build_nav_items(); + if items.is_empty() { + return; + } + let current = items.iter().position(|s| *s == self.selection); + match current { + Some(0) | None => self.selection = items.last().unwrap().clone(), + Some(i) => self.selection = items[i - 1].clone(), + } + } + + /// Move selection down in the navigation tree + pub fn nav_down(&mut self) { + let items = self.build_nav_items(); + if items.is_empty() { + return; + } + let current = items.iter().position(|s| *s == self.selection); + match current { + Some(i) if i + 1 < items.len() => self.selection = items[i + 1].clone(), + _ => self.selection = items[0].clone(), + } + } + + /// Enter/expand: toggle group expansion or open a file + pub fn nav_enter(&mut self) { + match &self.selection { + NavSelection::Group(gi) => { + let gi = *gi; + self.expanded_groups[gi] = !self.expanded_groups[gi]; + } + NavSelection::Subgroup(gi, _si) => { + // Subgroups are always expanded when visible, so treat as entering + let gi = *gi; + self.expanded_groups[gi] = true; + } + NavSelection::GroupFile(gi, fi) => { + let gi = *gi; + let fi = *fi; + if let Some(entry) = self.index.groups.get(gi).and_then(|g| g.files.get(fi)) { + self.load_document(&entry.path.clone()); + } + } + NavSelection::SubgroupFile(gi, si, fi) => { + let gi = *gi; + let si = *si; + let fi = *fi; + if let Some(entry) = self + .index + .groups + .get(gi) + .and_then(|g| g.subgroups.get(si)) + .and_then(|sg| sg.files.get(fi)) + { + self.load_document(&entry.path.clone()); + } + } + } + } + + /// Go back: collapse group or go to parent + pub fn nav_back(&mut self) { + if self.view_mode == ViewMode::Fullscreen { + self.view_mode = ViewMode::Normal; + return; + } + + // Try navigation history first + if let Some(entry) = self.nav_history.pop() { + self.selection = entry.selection; + self.doc_scroll = entry.scroll_offset; + // Reload the document at that selection if it was a file + match &self.selection { + NavSelection::GroupFile(gi, fi) => { + if let Some(e) = self.index.groups.get(*gi).and_then(|g| g.files.get(*fi)) { + self.load_document(&e.path.clone()); + } + } + NavSelection::SubgroupFile(gi, si, fi) => { + if let Some(e) = self + .index + .groups + .get(*gi) + .and_then(|g| g.subgroups.get(*si)) + .and_then(|sg| sg.files.get(*fi)) + { + self.load_document(&e.path.clone()); + } + } + _ => { + self.current_doc = None; + } + } + return; + } + + match &self.selection { + NavSelection::GroupFile(gi, _) | NavSelection::Subgroup(gi, _) => { + let gi = *gi; + self.selection = NavSelection::Group(gi); + } + NavSelection::SubgroupFile(gi, si, _) => { + let gi = *gi; + let si = *si; + self.selection = NavSelection::Subgroup(gi, si); + } + NavSelection::Group(gi) => { + let gi = *gi; + self.expanded_groups[gi] = false; + } + } + } + + /// Toggle between panels, or cycle related links when in document panel + pub fn toggle_panel(&mut self) { + if self.current_doc.is_some() { + match self.active_panel { + ActivePanel::Navigation => { + // Nav → Metadata (if doc has frontmatter) + self.selected_related = None; + self.active_panel = ActivePanel::Metadata; + } + ActivePanel::Metadata => { + // In Metadata panel, Tab cycles related links + let related_count = self.related_count(); + if related_count > 0 { + match self.selected_related { + None => { + self.selected_related = Some(0); + } + Some(idx) => { + let next = idx + 1; + if next >= related_count { + // Past last related → move to Document + self.selected_related = None; + self.active_panel = ActivePanel::Document; + } else { + self.selected_related = Some(next); + } + } + } + } else { + // No related links → move to Document + self.active_panel = ActivePanel::Document; + } + } + ActivePanel::Document => { + // Doc → back to Navigation + self.selected_related = None; + self.active_panel = ActivePanel::Navigation; + } + } + } + } + + /// Follow the currently selected related link + pub fn follow_selected_related(&mut self) { + if let Some(idx) = self.selected_related { + if let Some(ref doc) = self.current_doc { + if let Some(ref fm) = doc.frontmatter { + if let Some(related_id) = fm.related.get(idx) { + let id = related_id.clone(); + self.selected_related = None; + self.navigate_to_id(&id); + } + } + } + } + } + + /// Get the number of related links in the current document + fn related_count(&self) -> usize { + self.current_doc + .as_ref() + .and_then(|doc| doc.frontmatter.as_ref()) + .map(|fm| fm.related.len()) + .unwrap_or(0) + } + + /// Toggle fullscreen mode for document + pub fn toggle_fullscreen(&mut self) { + if self.current_doc.is_some() { + self.view_mode = match self.view_mode { + ViewMode::Fullscreen => ViewMode::Normal, + _ => ViewMode::Fullscreen, + }; + } + } + + /// Toggle help popup + pub fn toggle_help(&mut self) { + self.view_mode = match self.view_mode { + ViewMode::Help => ViewMode::Normal, + _ => ViewMode::Help, + }; + } + + /// Scroll document down + pub fn scroll_down(&mut self, amount: u16) { + if self.doc_total_lines > 0 { + let max = self.doc_total_lines.saturating_sub(5) as u16; + self.doc_scroll = (self.doc_scroll + amount).min(max); + } + } + + /// Scroll document up + pub fn scroll_up(&mut self, amount: u16) { + self.doc_scroll = self.doc_scroll.saturating_sub(amount); + } + + /// Scroll to top + pub fn scroll_to_top(&mut self) { + self.doc_scroll = 0; + } + + /// Scroll to bottom + pub fn scroll_to_bottom(&mut self) { + if self.doc_total_lines > 5 { + self.doc_scroll = (self.doc_total_lines - 5) as u16; + } + } + + /// Jump to a specific group by number (1-8) + pub fn jump_to_group(&mut self, num: usize) { + let idx = num.saturating_sub(1); + if idx < self.index.groups.len() { + self.selection = NavSelection::Group(idx); + self.expanded_groups[idx] = true; + } + } + + /// Cycle sort order + pub fn cycle_sort(&mut self) { + self.sort_order = match self.sort_order { + SortOrder::Name => SortOrder::Date, + SortOrder::Date => SortOrder::Name, + }; + } + + /// Navigate to a document by its ID (hyperlinked navigation) + pub fn navigate_to_id(&mut self, id: &str) { + let path = match self.index.find_by_ref(id) { + Some(p) => p, + None => { + self.notification = Some(format!("Document not found: {id}")); + return; + } + }; + + // Save current position to history + self.nav_history.push(HistoryEntry { + selection: self.selection.clone(), + scroll_offset: self.doc_scroll, + }); + + // Find the selection that corresponds to this path + if let Some(sel) = self.find_selection_for_path(&path) { + self.selection = sel; + // Ensure parent group is expanded + match &self.selection { + NavSelection::GroupFile(gi, _) + | NavSelection::Subgroup(gi, _) + | NavSelection::SubgroupFile(gi, _, _) => { + self.expanded_groups[*gi] = true; + } + _ => {} + } + } + + self.load_document(&path); + } + + /// Navigate to next document in the current group/subgroup + pub fn next_document(&mut self) { + self.navigate_sibling(1); + } + + /// Navigate to previous document in the current group/subgroup + pub fn prev_document(&mut self) { + self.navigate_sibling(-1); + } + + /// Start search mode + pub fn start_search(&mut self) { + self.is_searching = true; + self.search_input.clear(); + } + + /// Cancel search + pub fn cancel_search(&mut self) { + self.is_searching = false; + self.search_input.clear(); + self.search_query = None; + } + + /// Apply search filter + pub fn apply_search(&mut self) { + if self.search_input.is_empty() { + self.search_query = None; + } else { + self.search_query = Some(self.search_input.clone()); + } + self.is_searching = false; + } + + // ── Private helpers ── + + fn load_document(&mut self, path: &Path) { + if let Some(cached) = self.doc_cache.get(path) { + self.current_doc = Some(cached.clone()); + } else if let Some(doc) = Document::load(path) { + self.doc_cache.insert(path.to_path_buf(), doc.clone()); + self.current_doc = Some(doc); + } + self.doc_scroll = 0; + self.selected_related = None; + self.active_panel = ActivePanel::Document; + } + + fn navigate_sibling(&mut self, direction: i32) { + match &self.selection { + NavSelection::GroupFile(gi, fi) => { + let gi = *gi; + let fi = *fi; + let len = self.index.groups[gi].files.len(); + if len == 0 { + return; + } + let new_fi = (fi as i32 + direction).rem_euclid(len as i32) as usize; + self.selection = NavSelection::GroupFile(gi, new_fi); + let path = self.index.groups[gi].files[new_fi].path.clone(); + self.load_document(&path); + } + NavSelection::SubgroupFile(gi, si, fi) => { + let gi = *gi; + let si = *si; + let fi = *fi; + let len = self.index.groups[gi].subgroups[si].files.len(); + if len == 0 { + return; + } + let new_fi = (fi as i32 + direction).rem_euclid(len as i32) as usize; + self.selection = NavSelection::SubgroupFile(gi, si, new_fi); + let path = self.index.groups[gi].subgroups[si].files[new_fi].path.clone(); + self.load_document(&path); + } + _ => {} + } + } + + fn find_selection_for_path(&self, target: &Path) -> Option { + for (gi, group) in self.index.groups.iter().enumerate() { + for (fi, entry) in group.files.iter().enumerate() { + if entry.path == target { + return Some(NavSelection::GroupFile(gi, fi)); + } + } + for (si, sg) in group.subgroups.iter().enumerate() { + for (fi, entry) in sg.files.iter().enumerate() { + if entry.path == target { + return Some(NavSelection::SubgroupFile(gi, si, fi)); + } + } + } + } + None + } + + /// Build a flat list of all visible navigation items in order + fn build_nav_items(&self) -> Vec { + let mut items = Vec::new(); + let search = self.search_query.as_deref(); + let has_search = search.is_some(); + + for (gi, group) in self.index.groups.iter().enumerate() { + let show_children = if has_search { + group_has_matches(group, search) + } else { + self.expanded_groups[gi] + }; + + // When searching, skip groups with no matches + if has_search && !show_children { + continue; + } + + items.push(NavSelection::Group(gi)); + + if show_children { + // Direct files + for (fi, entry) in group.files.iter().enumerate() { + if !entry_matches_search(entry, search) { + continue; + } + items.push(NavSelection::GroupFile(gi, fi)); + } + // Subgroups and their files + for (si, sg) in group.subgroups.iter().enumerate() { + let sg_has_matches = + sg.files.iter().any(|e| entry_matches_search(e, search)); + if has_search && !sg_has_matches { + continue; + } + items.push(NavSelection::Subgroup(gi, si)); + for (fi, entry) in sg.files.iter().enumerate() { + if !entry_matches_search(entry, search) { + continue; + } + items.push(NavSelection::SubgroupFile(gi, si, fi)); + } + } + } + } + items + } +} + +fn group_has_matches( + group: &crate::tui::index::DocGroup, + search: Option<&str>, +) -> bool { + group.files.iter().any(|e| entry_matches_search(e, search)) + || group + .subgroups + .iter() + .any(|sg| sg.files.iter().any(|e| entry_matches_search(e, search))) +} + +fn entry_matches_search( + entry: &crate::tui::index::DocEntry, + search: Option<&str>, +) -> bool { + let Some(q) = search else { + return true; + }; + let query = q.to_lowercase(); + entry.filename.to_lowercase().contains(&query) + || entry.title.to_lowercase().contains(&query) + || entry.tags.iter().any(|t| t.to_lowercase().contains(&query)) + || (!entry.created.is_empty() && entry.created.contains(&query)) + || entry.id.to_lowercase().contains(&query) +} diff --git a/cli/src/tui/document.rs b/cli/src/tui/document.rs new file mode 100644 index 0000000..c7679c2 --- /dev/null +++ b/cli/src/tui/document.rs @@ -0,0 +1,150 @@ +use serde::Deserialize; +use std::path::Path; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DocStatus { + Draft, + Accepted, + Deprecated, + Superseded, + #[serde(other)] + Unknown, +} + +impl std::fmt::Display for DocStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Draft => write!(f, "DRAFT"), + Self::Accepted => write!(f, "ACCEPTED"), + Self::Deprecated => write!(f, "DEPRECATED"), + Self::Superseded => write!(f, "SUPERSEDED"), + Self::Unknown => write!(f, "UNKNOWN"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ConfidenceLevel { + High, + Medium, + Low, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RiskLevel { + Low, + Medium, + High, + Critical, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct DocFrontMatter { + #[serde(default)] + pub id: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub created: Option, + #[serde(default)] + pub updated: Option, + #[serde(default)] + pub agent: Option, + #[serde(default)] + pub confidence: Option, + #[serde(default)] + pub review_required: Option, + #[serde(default)] + pub risk_level: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub related: Vec, + #[serde(default)] + pub supersedes: Vec, +} + +#[derive(Debug, Clone)] +pub struct Document { + pub frontmatter: Option, + pub body: String, + pub filename: String, +} + +impl Document { + pub fn load(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let (frontmatter, body) = parse_frontmatter(&content); + + Some(Self { + frontmatter, + body, + filename, + }) + } +} + +fn parse_frontmatter(content: &str) -> (Option, String) { + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return (None, content.to_string()); + } + + // Find the closing --- + let after_first = &trimmed[3..]; + let closing = after_first.find("\n---"); + match closing { + Some(pos) => { + let yaml_str = &after_first[..pos]; + let body_start = pos + 4; // skip \n--- + let body = after_first[body_start..].trim_start_matches('\n').to_string(); + let fm: Option = serde_yaml::from_str(yaml_str).ok(); + (fm, body) + } + None => (None, content.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_frontmatter() { + let content = r#"--- +id: ADR-2025-06-15-001 +title: Test Document +status: accepted +created: 2025-06-15 +risk_level: low +tags: [rust, tui] +related: [REQ-2025-06-10-003] +--- + +# Test Document + +Some content here. +"#; + let (fm, body) = parse_frontmatter(content); + let fm = fm.unwrap(); + assert_eq!(fm.id, "ADR-2025-06-15-001"); + assert_eq!(fm.status, Some(DocStatus::Accepted)); + assert!(body.starts_with("# Test Document")); + } +} diff --git a/cli/src/tui/event.rs b/cli/src/tui/event.rs new file mode 100644 index 0000000..3fb7822 --- /dev/null +++ b/cli/src/tui/event.rs @@ -0,0 +1,150 @@ +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use std::time::Duration; + +use super::app::{ActivePanel, App, ViewMode}; + +/// Process events and update app state. Returns true if a redraw is needed. +pub fn handle_events(app: &mut App) -> std::io::Result { + if !event::poll(Duration::from_millis(50))? { + return Ok(false); + } + + let event = event::read()?; + + match event { + Event::Key(key) if key.kind == KeyEventKind::Press => { + handle_key(app, key); + Ok(true) + } + Event::Resize(_, _) => Ok(true), + _ => Ok(false), + } +} + +fn handle_key(app: &mut App, key: KeyEvent) { + // Search mode: capture typed characters + if app.is_searching { + handle_search_key(app, key); + return; + } + + // Notification: any key dismisses it + if app.notification.is_some() { + app.notification = None; + return; + } + + // Help mode: any key closes it + if app.view_mode == ViewMode::Help { + app.view_mode = ViewMode::Normal; + return; + } + + // Ctrl+C / Ctrl+Q: always quit regardless of mode + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('q')) + { + app.should_quit = true; + return; + } + + match key.code { + KeyCode::Char('q') => app.should_quit = true, + KeyCode::Char('?') => app.toggle_help(), + KeyCode::Tab => app.toggle_panel(), + KeyCode::Char('f') => app.toggle_fullscreen(), + KeyCode::Char('/') => app.start_search(), + KeyCode::Char('s') => app.cycle_sort(), + + // Group jumping with number keys + KeyCode::Char(c @ '1'..='8') => { + app.jump_to_group(c.to_digit(10).unwrap() as usize); + } + + // Navigation or document scrolling depending on active panel + KeyCode::Char('j') | KeyCode::Down => match app.active_panel { + ActivePanel::Document => app.scroll_down(1), + ActivePanel::Navigation => app.nav_down(), + ActivePanel::Metadata => {} + }, + KeyCode::Char('k') | KeyCode::Up => match app.active_panel { + ActivePanel::Document => app.scroll_up(1), + ActivePanel::Navigation => app.nav_up(), + ActivePanel::Metadata => {} + }, + + // Enter: open/expand or follow selected related link + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { + if app.active_panel == ActivePanel::Navigation { + app.nav_enter(); + } else if app.active_panel == ActivePanel::Metadata + && app.selected_related.is_some() + { + app.follow_selected_related(); + } + } + + // Back: collapse/go back / clear search + KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => { + match app.active_panel { + ActivePanel::Document if key.code == KeyCode::Esc => { + if app.view_mode == ViewMode::Fullscreen { + app.view_mode = ViewMode::Normal; + } else { + app.active_panel = ActivePanel::Navigation; + } + } + ActivePanel::Metadata if key.code == KeyCode::Esc => { + app.selected_related = None; + app.active_panel = ActivePanel::Navigation; + } + ActivePanel::Navigation => { + if key.code == KeyCode::Esc && app.search_query.is_some() { + app.cancel_search(); + } else { + app.nav_back(); + } + } + _ => {} + } + } + + // Document scroll shortcuts + KeyCode::Char('g') => app.scroll_to_top(), + KeyCode::Char('G') => app.scroll_to_bottom(), + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.scroll_down(15); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.scroll_up(15); + } + KeyCode::PageDown => app.scroll_down(20), + KeyCode::PageUp => app.scroll_up(20), + + // Next/Previous document + KeyCode::Char('n') => app.next_document(), + KeyCode::Char('N') => app.prev_document(), + + // Refresh + KeyCode::Char('r') => { + let root = app.project_root.clone(); + *app = App::new(&root); + } + + _ => {} + } +} + +fn handle_search_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Esc => app.cancel_search(), + KeyCode::Enter => app.apply_search(), + KeyCode::Backspace => { + app.search_input.pop(); + } + KeyCode::Char(c) => { + app.search_input.push(c); + } + _ => {} + } +} diff --git a/cli/src/tui/index.rs b/cli/src/tui/index.rs new file mode 100644 index 0000000..1b6df36 --- /dev/null +++ b/cli/src/tui/index.rs @@ -0,0 +1,361 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use super::document::DocFrontMatter; + +/// A group in the documentation hierarchy (e.g., "02-design") +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct DocGroup { + /// Directory name (e.g., "02-design") + pub name: String, + /// Display label (e.g., "Design") + pub label: String, + pub path: PathBuf, + pub subgroups: Vec, + /// Files directly in this group (not in a subgroup) + pub files: Vec, +} + +/// A subgroup within a group (e.g., "decisions" under "02-design") +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct DocSubgroup { + /// Directory name (e.g., "technical-debt") + pub name: String, + /// Display label (e.g., "Technical debt") + pub label: String, + pub path: PathBuf, + pub files: Vec, +} + +/// A documentation file entry +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct DocEntry { + pub filename: String, + pub path: PathBuf, + pub title: String, + pub id: String, + pub tags: Vec, + pub created: String, + pub has_frontmatter: bool, +} + +/// Bidirectional relationship index +#[derive(Debug, Default)] +pub struct RelationIndex { + /// doc_id -> list of doc_ids it references + pub references: HashMap>, + /// doc_id -> list of doc_ids that reference it + pub referenced_by: HashMap>, + /// doc_id -> file path + pub id_to_path: HashMap, +} + +/// The full documentation index +pub struct DocIndex { + pub groups: Vec, + pub relations: RelationIndex, + pub total_docs: usize, +} + +/// Subgroup definition: (dir_name, display_label) +type SubgroupDef = (&'static str, &'static str); + +/// Known documentation group definitions: (dir_name, display_label, subgroups) +const GROUP_DEFS: &[(&str, &str, &[SubgroupDef])] = &[ + ("00-governance", "Governance", &[("exceptions", "Exceptions")]), + ("01-requirements", "Requirements", &[]), + ("02-design", "Design", &[("decisions", "Decisions")]), + ("03-implementation", "Implementation", &[]), + ("04-testing", "Testing", &[]), + ( + "05-operations", + "Operations", + &[("incidents", "Incidents"), ("runbooks", "Runbooks")], + ), + ( + "06-evolution", + "Evolution", + &[("technical-debt", "Technical debt")], + ), + ( + "07-ai-audit", + "AI Audit", + &[ + ("agent-logs", "Agent logs"), + ("decisions", "Decisions"), + ("ethical-reviews", "Ethical reviews"), + ], + ), +]; + +impl DocIndex { + /// Build the index by scanning the .devtrail directory + pub fn build(devtrail_dir: &Path) -> Self { + let mut groups = Vec::new(); + let mut relations = RelationIndex::default(); + let mut total_docs = 0; + + for &(group_name, group_label, subgroup_defs) in GROUP_DEFS { + let group_path = devtrail_dir.join(group_name); + if !group_path.exists() { + groups.push(DocGroup { + name: group_name.to_string(), + label: group_label.to_string(), + path: group_path, + subgroups: Vec::new(), + files: Vec::new(), + }); + continue; + } + + // Scan files directly in the group dir + let files = scan_md_files(&group_path, &mut relations); + total_docs += files.len(); + + // Scan subgroups + let mut subgroups = Vec::new(); + for &(sg_name, sg_label) in subgroup_defs { + let sg_path = group_path.join(sg_name); + if sg_path.exists() { + let sg_files = scan_md_files(&sg_path, &mut relations); + total_docs += sg_files.len(); + subgroups.push(DocSubgroup { + name: sg_name.to_string(), + label: sg_label.to_string(), + path: sg_path, + files: sg_files, + }); + } else { + subgroups.push(DocSubgroup { + name: sg_name.to_string(), + label: sg_label.to_string(), + path: sg_path, + files: Vec::new(), + }); + } + } + + groups.push(DocGroup { + name: group_name.to_string(), + label: group_label.to_string(), + path: group_path, + subgroups, + files, + }); + } + + Self { + groups, + relations, + total_docs, + } + } + + /// Find the file path for a related link. + /// Tries multiple resolution strategies: + /// 1. Exact document ID match (e.g., "ADR-2025-06-15-001") + /// 2. Filename match (e.g., "AGENT-RULES.md") + /// 3. Path suffix match (e.g., "00-governance/AGENT-RULES.md") + pub fn find_by_ref(&self, reference: &str) -> Option { + // 1. Try as document ID + if let Some(path) = self.relations.id_to_path.get(reference) { + return Some(path.clone()); + } + + // Normalize: strip leading ./ or ../ segments for matching + let clean_ref = reference + .trim_start_matches("../") + .trim_start_matches("./"); + + // Extract just the filename part + let ref_filename = std::path::Path::new(clean_ref) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(clean_ref); + + // Search all entries across all groups + let mut candidates: Vec<&PathBuf> = Vec::new(); + + for group in &self.groups { + for entry in &group.files { + if entry_matches(&entry.filename, &entry.path, ref_filename, clean_ref) { + candidates.push(&entry.path); + } + } + for sg in &group.subgroups { + for entry in &sg.files { + if entry_matches(&entry.filename, &entry.path, ref_filename, clean_ref) { + candidates.push(&entry.path); + } + } + } + } + + // If exactly one match, return it. If multiple, prefer the one + // whose path ends with the clean reference. + match candidates.len() { + 0 => None, + 1 => Some(candidates[0].clone()), + _ => { + // Prefer path suffix match + let suffix_match = candidates.iter().find(|p| { + p.to_str() + .map(|s| s.ends_with(clean_ref)) + .unwrap_or(false) + }); + Some(suffix_match.unwrap_or(&candidates[0]).to_path_buf()) + } + } + } +} + +fn entry_matches(filename: &str, path: &Path, ref_filename: &str, clean_ref: &str) -> bool { + // Exact filename match + if filename == ref_filename { + return true; + } + // Path suffix match (e.g., "00-governance/AGENT-RULES.md") + if let Some(path_str) = path.to_str() { + if path_str.ends_with(clean_ref) { + return true; + } + } + false +} + +fn scan_md_files(dir: &Path, relations: &mut RelationIndex) -> Vec { + let mut entries = Vec::new(); + + let Ok(read_dir) = std::fs::read_dir(dir) else { + return entries; + }; + + let mut paths: Vec = read_dir + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.is_file() + && p.extension().and_then(|e| e.to_str()) == Some("md") + && !p + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("TEMPLATE-") || n.starts_with('.')) + .unwrap_or(true) + }) + .collect(); + + paths.sort(); + + for path in paths { + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + // Quick frontmatter scan (just read enough to get id/title/tags/created/related) + let meta = quick_scan_frontmatter(&path, relations); + + entries.push(DocEntry { + filename, + path, + title: meta.title, + id: meta.id, + tags: meta.tags, + created: meta.created, + has_frontmatter: meta.has_frontmatter, + }); + } + + entries +} + +struct ScannedMeta { + title: String, + id: String, + tags: Vec, + created: String, + has_frontmatter: bool, +} + +fn fallback_meta(path: &Path) -> ScannedMeta { + ScannedMeta { + title: path + .file_stem() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown") + .to_string(), + id: String::new(), + tags: Vec::new(), + created: String::new(), + has_frontmatter: false, + } +} + +fn quick_scan_frontmatter(path: &Path, relations: &mut RelationIndex) -> ScannedMeta { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return fallback_meta(path), + }; + + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return fallback_meta(path); + } + + let after = &trimmed[3..]; + let Some(end) = after.find("\n---") else { + return fallback_meta(path); + }; + + let yaml_str = &after[..end]; + let fm: Option = serde_yaml::from_str(yaml_str).ok(); + + match fm { + Some(fm) => { + let id = fm.id.clone(); + let title = if fm.title.is_empty() { + path.file_stem() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown") + .to_string() + } else { + fm.title.clone() + }; + let tags = fm.tags.clone(); + let created = fm.created.clone().unwrap_or_default(); + + // Index relationships + if !id.is_empty() { + relations.id_to_path.insert(id.clone(), path.to_path_buf()); + + if !fm.related.is_empty() { + for related_id in &fm.related { + relations + .referenced_by + .entry(related_id.clone()) + .or_default() + .push(id.clone()); + } + relations + .references + .entry(id.clone()) + .or_default() + .extend(fm.related.iter().cloned()); + } + } + + ScannedMeta { + title, + id, + tags, + created, + has_frontmatter: true, + } + } + None => fallback_meta(path), + } +} diff --git a/cli/src/tui/markdown.rs b/cli/src/tui/markdown.rs new file mode 100644 index 0000000..7f336a8 --- /dev/null +++ b/cli/src/tui/markdown.rs @@ -0,0 +1,402 @@ +use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + +/// Convert markdown text to styled ratatui Lines +pub fn markdown_to_lines(markdown: &str) -> Vec> { + let options = Options::ENABLE_TABLES + | Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_TASKLISTS; + let parser = Parser::new_ext(markdown, options); + + let mut lines: Vec> = Vec::new(); + let mut current_spans: Vec> = Vec::new(); + let mut style_stack: Vec