feat: TUI Phase 1 common widgets + scoring/path beads

bd-26f2: Common widgets (render_breadcrumb, render_status_bar,
render_loading, render_error_toast, render_help_overlay) + render_screen
top-level dispatch wired to LoreApp::view(). 27 widget tests.

bd-2w1p: Add half-life fields to ScoringConfig with validation.
bd-1soz: Add half_life_decay() pure function.
bd-18dn: Add normalize_query_path() for path canonicalization.

Phase 1 modules: CommandRegistry, NavigationStack, CrashContext,
TaskSupervisor, AppState with per-screen states.

172 lore-tui tests passing, clippy clean, fmt clean.
This commit is contained in:
teernisse
2026-02-12 15:28:53 -05:00
parent d224a88738
commit eb98595251
27 changed files with 4893 additions and 305 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
bd-20p9
bd-26f2

View File

@@ -1335,6 +1335,9 @@ dependencies = [
"lore",
"regex",
"rusqlite",
"serde",
"serde_json",
"tempfile",
]
[[package]]

View File

@@ -35,5 +35,12 @@ rusqlite = { version = "0.38", features = ["bundled"] }
# Terminal (crossterm for raw mode + event reading, used by ftui runtime)
crossterm = "0.28"
# Serialization (crash context NDJSON dumps)
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Regex (used by safety module for PII/secret redaction)
regex = "1"
[dev-dependencies]
tempfile = "3"

View File

@@ -1,14 +1,26 @@
#![allow(dead_code)] // Phase 0: minimal scaffold, fleshed out in bd-6pmy
#![allow(dead_code)] // Phase 1: methods consumed as screens are implemented
//! Minimal FrankenTUI Model implementation for the lore TUI.
//! Full FrankenTUI Model implementation for the lore TUI.
//!
//! This is the Phase 0 integration proof — validates that the ftui Model trait
//! compiles with our Msg type and produces basic output. The full LoreApp with
//! screen routing, navigation stack, and action dispatch comes in bd-6pmy.
//! LoreApp is the central coordinator: it owns all state, dispatches
//! messages through a 5-stage key pipeline, records crash context
//! breadcrumbs, manages async tasks via the supervisor, and routes
//! view() to per-screen render functions.
use ftui::{Cmd, Frame, Model};
use chrono::TimeDelta;
use ftui::{Cmd, Event, Frame, KeyCode, KeyEvent, Model, Modifiers};
use crate::message::Msg;
use crate::clock::{Clock, SystemClock};
use crate::commands::{CommandRegistry, build_registry};
use crate::crash_context::{CrashContext, CrashEvent};
use crate::db::DbManager;
use crate::message::{InputMode, Msg, Screen};
use crate::navigation::NavigationStack;
use crate::state::{AppState, LoadState};
use crate::task_supervisor::{TaskKey, TaskSupervisor};
/// Timeout for the g-prefix key sequence.
const GO_PREFIX_TIMEOUT: TimeDelta = TimeDelta::milliseconds(500);
// ---------------------------------------------------------------------------
// LoreApp
@@ -16,49 +28,414 @@ use crate::message::Msg;
/// Root model for the lore TUI.
///
/// Phase 0: minimal scaffold that renders a placeholder and handles Quit.
/// Phase 1 (bd-6pmy) will add screen routing, DbManager, theme, and subscriptions.
pub struct LoreApp;
/// Owns all state and implements the FrankenTUI Model trait. The
/// update() method is the single entry point for all state transitions.
pub struct LoreApp {
pub state: AppState,
pub navigation: NavigationStack,
pub supervisor: TaskSupervisor,
pub crash_context: CrashContext,
pub command_registry: CommandRegistry,
pub input_mode: InputMode,
pub clock: Box<dyn Clock>,
pub db: Option<DbManager>,
}
impl LoreApp {
/// Create a new LoreApp with default state.
///
/// Uses a real system clock and no DB connection (set separately).
#[must_use]
pub fn new() -> Self {
Self {
state: AppState::default(),
navigation: NavigationStack::new(),
supervisor: TaskSupervisor::new(),
crash_context: CrashContext::new(),
command_registry: build_registry(),
input_mode: InputMode::Normal,
clock: Box::new(SystemClock),
db: None,
}
}
/// Create a LoreApp for testing with a custom clock.
#[cfg(test)]
fn with_clock(clock: Box<dyn Clock>) -> Self {
Self {
clock,
..Self::new()
}
}
// -----------------------------------------------------------------------
// Key dispatch
// -----------------------------------------------------------------------
/// Normalize terminal key variants for cross-terminal consistency.
fn normalize_key(key: &mut KeyEvent) {
// BackTab -> Shift+Tab canonical form.
if key.code == KeyCode::BackTab {
key.code = KeyCode::Tab;
key.modifiers |= Modifiers::SHIFT;
}
}
/// 5-stage key dispatch pipeline.
///
/// Returns the Cmd to execute (Quit, None, or a task command).
fn interpret_key(&mut self, mut key: KeyEvent) -> Cmd<Msg> {
Self::normalize_key(&mut key);
let screen = self.navigation.current().clone();
// Record key press in crash context.
self.crash_context.push(CrashEvent::KeyPress {
key: format!("{:?}", key.code),
mode: format!("{:?}", self.input_mode),
screen: screen.label().to_string(),
});
// --- Stage 1: Quit check ---
// Ctrl+C always quits regardless of mode.
if key.code == KeyCode::Char('c') && key.modifiers.contains(Modifiers::CTRL) {
return Cmd::quit();
}
// --- Stage 2: InputMode routing ---
match &self.input_mode {
InputMode::Text => {
return self.handle_text_mode_key(&key, &screen);
}
InputMode::Palette => {
return self.handle_palette_mode_key(&key, &screen);
}
InputMode::GoPrefix { started_at } => {
let elapsed = self.clock.now().signed_duration_since(*started_at);
if elapsed > GO_PREFIX_TIMEOUT {
// Timeout expired — cancel prefix and re-process as normal.
self.input_mode = InputMode::Normal;
} else {
return self.handle_go_prefix_key(&key, &screen);
}
}
InputMode::Normal => {}
}
// --- Stage 3: Global shortcuts (Normal mode) ---
// 'q' quits.
if key.code == KeyCode::Char('q') && key.modifiers == Modifiers::NONE {
return Cmd::quit();
}
// 'g' starts prefix sequence.
if self
.command_registry
.is_sequence_starter(&key.code, &key.modifiers)
{
self.input_mode = InputMode::GoPrefix {
started_at: self.clock.now(),
};
return Cmd::none();
}
// Registry-based single-key lookup.
if let Some(cmd_def) =
self.command_registry
.lookup_key(&key.code, &key.modifiers, &screen, &self.input_mode)
{
return self.execute_command(cmd_def.id, &screen);
}
// --- Stage 4: Screen-local keys ---
// Delegated to AppState::interpret_screen_key in future phases.
// --- Stage 5: Fallback (unhandled) ---
Cmd::none()
}
/// Handle keys in Text input mode.
///
/// Only Esc and Ctrl+P pass through; everything else is consumed by
/// the focused text widget (handled in future phases).
fn handle_text_mode_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd<Msg> {
// Esc blurs the text input.
if key.code == KeyCode::Escape {
self.state.blur_text_focus();
self.input_mode = InputMode::Normal;
return Cmd::none();
}
// Ctrl+P opens palette even in text mode.
if let Some(cmd_def) =
self.command_registry
.lookup_key(&key.code, &key.modifiers, screen, &InputMode::Text)
{
return self.execute_command(cmd_def.id, screen);
}
// All other keys consumed by text widget (future).
Cmd::none()
}
/// Handle keys in Palette mode.
fn handle_palette_mode_key(&mut self, key: &KeyEvent, _screen: &Screen) -> Cmd<Msg> {
if key.code == KeyCode::Escape {
self.input_mode = InputMode::Normal;
return Cmd::none();
}
// Palette key dispatch will be expanded in the palette widget phase.
Cmd::none()
}
/// Handle the second key of a g-prefix sequence.
fn handle_go_prefix_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd<Msg> {
self.input_mode = InputMode::Normal;
if let Some(cmd_def) = self.command_registry.complete_sequence(
&KeyCode::Char('g'),
&Modifiers::NONE,
&key.code,
&key.modifiers,
screen,
) {
return self.execute_command(cmd_def.id, screen);
}
// Invalid second key — cancel prefix silently.
Cmd::none()
}
/// Execute a command by ID.
fn execute_command(&mut self, id: &str, _screen: &Screen) -> Cmd<Msg> {
match id {
"quit" => Cmd::quit(),
"go_back" => {
self.navigation.pop();
Cmd::none()
}
"show_help" => {
self.state.show_help = !self.state.show_help;
Cmd::none()
}
"command_palette" => {
self.input_mode = InputMode::Palette;
self.state.command_palette.query_focused = true;
Cmd::none()
}
"open_in_browser" => {
// Will dispatch OpenInBrowser msg in future phase.
Cmd::none()
}
"show_cli" => {
// Will show CLI equivalent in future phase.
Cmd::none()
}
"go_home" => self.navigate_to(Screen::Dashboard),
"go_issues" => self.navigate_to(Screen::IssueList),
"go_mrs" => self.navigate_to(Screen::MrList),
"go_search" => self.navigate_to(Screen::Search),
"go_timeline" => self.navigate_to(Screen::Timeline),
"go_who" => self.navigate_to(Screen::Who),
"go_sync" => self.navigate_to(Screen::Sync),
"jump_back" => {
self.navigation.jump_back();
Cmd::none()
}
"jump_forward" => {
self.navigation.jump_forward();
Cmd::none()
}
"move_down" | "move_up" | "select_item" | "focus_filter" | "scroll_to_top" => {
// Screen-specific actions — delegated in future phases.
Cmd::none()
}
_ => Cmd::none(),
}
}
// -----------------------------------------------------------------------
// Navigation helpers
// -----------------------------------------------------------------------
/// Navigate to a screen, pushing the nav stack and starting a data load.
fn navigate_to(&mut self, screen: Screen) -> Cmd<Msg> {
let screen_label = screen.label().to_string();
let current_label = self.navigation.current().label().to_string();
self.crash_context.push(CrashEvent::StateTransition {
from: current_label,
to: screen_label,
});
self.navigation.push(screen.clone());
self.state
.set_loading(screen.clone(), LoadState::Refreshing);
// Spawn supervised task for data loading (placeholder — actual DB
// query dispatch comes in Phase 2 screen implementations).
let _handle = self.supervisor.submit(TaskKey::LoadScreen(screen));
Cmd::none()
}
// -----------------------------------------------------------------------
// Message dispatch (non-key)
// -----------------------------------------------------------------------
/// Handle non-key messages.
fn handle_msg(&mut self, msg: Msg) -> Cmd<Msg> {
// Record in crash context.
self.crash_context.push(CrashEvent::MsgDispatched {
msg_name: format!("{msg:?}")
.split('(')
.next()
.unwrap_or("?")
.to_string(),
screen: self.navigation.current().label().to_string(),
});
match msg {
Msg::Quit => Cmd::quit(),
// --- Navigation ---
Msg::NavigateTo(screen) => self.navigate_to(screen),
Msg::GoBack => {
self.navigation.pop();
Cmd::none()
}
Msg::GoForward => {
self.navigation.go_forward();
Cmd::none()
}
Msg::GoHome => self.navigate_to(Screen::Dashboard),
Msg::JumpBack(_) => {
self.navigation.jump_back();
Cmd::none()
}
Msg::JumpForward(_) => {
self.navigation.jump_forward();
Cmd::none()
}
// --- Error ---
Msg::Error(err) => {
self.state.set_error(err.to_string());
Cmd::none()
}
// --- Help / UI ---
Msg::ShowHelp => {
self.state.show_help = !self.state.show_help;
Cmd::none()
}
Msg::BlurTextInput => {
self.state.blur_text_focus();
self.input_mode = InputMode::Normal;
Cmd::none()
}
// --- Terminal ---
Msg::Resize { width, height } => {
self.state.terminal_size = (width, height);
Cmd::none()
}
Msg::Tick => Cmd::none(),
// --- Loaded results (stale guard) ---
Msg::IssueListLoaded { generation, rows } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::IssueList), generation)
{
self.state.issue_list.rows = rows;
self.state.set_loading(Screen::IssueList, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::IssueList), generation);
}
Cmd::none()
}
Msg::MrListLoaded { generation, rows } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::MrList), generation)
{
self.state.mr_list.rows = rows;
self.state.set_loading(Screen::MrList, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::MrList), generation);
}
Cmd::none()
}
Msg::DashboardLoaded { generation, data } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::Dashboard), generation)
{
self.state.dashboard.issue_count = data.issue_count;
self.state.dashboard.mr_count = data.mr_count;
self.state.set_loading(Screen::Dashboard, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::Dashboard), generation);
}
Cmd::none()
}
// All other message variants: no-op for now.
// Future phases will fill these in as screens are implemented.
_ => Cmd::none(),
}
}
}
impl Default for LoreApp {
fn default() -> Self {
Self::new()
}
}
impl Model for LoreApp {
type Message = Msg;
fn init(&mut self) -> Cmd<Self::Message> {
// Install crash context panic hook.
CrashContext::install_panic_hook(&self.crash_context);
CrashContext::prune_crash_files();
// Navigate to dashboard (will trigger data load in future phase).
Cmd::none()
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
Msg::Quit => Cmd::quit(),
_ => Cmd::none(),
// Route raw key events through the 5-stage pipeline.
if let Msg::RawEvent(Event::Key(key)) = msg {
return self.interpret_key(key);
}
// Everything else goes through message dispatch.
self.handle_msg(msg)
}
fn view(&self, _frame: &mut Frame) {
// Phase 0: no-op view. Phase 1 will render screens via the frame.
fn view(&self, frame: &mut Frame) {
crate::view::render_screen(frame, self);
}
}
/// Verify that `App::fullscreen(LoreApp).run()` compiles.
///
/// This is a compile-time check — we don't actually run it because that
/// would require a real TTY. The function exists solely to prove the wiring.
/// Verify that `App::fullscreen(LoreApp::new()).run()` compiles.
#[cfg(test)]
fn _assert_app_fullscreen_compiles() {
// This function is never called — it only needs to compile.
fn _inner() {
use ftui::App;
let _app_builder = App::fullscreen(LoreApp);
// _app_builder.run() would need a TTY, so we don't call it.
let _app_builder = App::fullscreen(LoreApp::new());
}
}
/// Verify that `App::inline(LoreApp, 12).run()` compiles.
/// Verify that `App::inline(LoreApp::new(), 12).run()` compiles.
#[cfg(test)]
fn _assert_app_inline_compiles() {
fn _inner() {
use ftui::App;
let _app_builder = App::inline(LoreApp, 12);
let _app_builder = App::inline(LoreApp::new(), 12);
}
}
@@ -69,33 +446,267 @@ fn _assert_app_inline_compiles() {
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FakeClock;
fn test_app() -> LoreApp {
LoreApp::with_clock(Box::new(FakeClock::new(chrono::Utc::now())))
}
#[test]
fn test_lore_app_init_returns_none() {
let mut app = LoreApp;
let mut app = test_app();
let cmd = app.init();
assert!(matches!(cmd, Cmd::None));
}
#[test]
fn test_lore_app_quit_returns_quit_cmd() {
let mut app = LoreApp;
let mut app = test_app();
let cmd = app.update(Msg::Quit);
assert!(matches!(cmd, Cmd::Quit));
}
#[test]
fn test_lore_app_tick_returns_none() {
let mut app = LoreApp;
let mut app = test_app();
let cmd = app.update(Msg::Tick);
assert!(matches!(cmd, Cmd::None));
}
#[test]
fn test_lore_app_navigate_returns_none() {
use crate::message::Screen;
let mut app = LoreApp;
let cmd = app.update(Msg::NavigateTo(Screen::Dashboard));
fn test_lore_app_navigate_to_updates_nav_stack() {
let mut app = test_app();
let cmd = app.update(Msg::NavigateTo(Screen::IssueList));
assert!(matches!(cmd, Cmd::None));
assert!(app.navigation.is_at(&Screen::IssueList));
assert_eq!(app.navigation.depth(), 2);
}
#[test]
fn test_lore_app_go_back() {
let mut app = test_app();
app.update(Msg::NavigateTo(Screen::IssueList));
app.update(Msg::GoBack);
assert!(app.navigation.is_at(&Screen::Dashboard));
}
#[test]
fn test_lore_app_go_forward() {
let mut app = test_app();
app.update(Msg::NavigateTo(Screen::IssueList));
app.update(Msg::GoBack);
app.update(Msg::GoForward);
assert!(app.navigation.is_at(&Screen::IssueList));
}
#[test]
fn test_ctrl_c_always_quits() {
let mut app = test_app();
let key = KeyEvent::new(KeyCode::Char('c')).with_modifiers(Modifiers::CTRL);
let cmd = app.update(Msg::RawEvent(Event::Key(key)));
assert!(matches!(cmd, Cmd::Quit));
}
#[test]
fn test_q_key_quits_in_normal_mode() {
let mut app = test_app();
let key = KeyEvent::new(KeyCode::Char('q'));
let cmd = app.update(Msg::RawEvent(Event::Key(key)));
assert!(matches!(cmd, Cmd::Quit));
}
#[test]
fn test_q_key_blocked_in_text_mode() {
let mut app = test_app();
app.input_mode = InputMode::Text;
let key = KeyEvent::new(KeyCode::Char('q'));
let cmd = app.update(Msg::RawEvent(Event::Key(key)));
// q in text mode should NOT quit.
assert!(matches!(cmd, Cmd::None));
}
#[test]
fn test_esc_blurs_text_mode() {
let mut app = test_app();
app.input_mode = InputMode::Text;
app.state.search.query_focused = true;
let key = KeyEvent::new(KeyCode::Escape);
app.update(Msg::RawEvent(Event::Key(key)));
assert!(matches!(app.input_mode, InputMode::Normal));
assert!(!app.state.has_text_focus());
}
#[test]
fn test_g_prefix_enters_go_mode() {
let mut app = test_app();
let key = KeyEvent::new(KeyCode::Char('g'));
app.update(Msg::RawEvent(Event::Key(key)));
assert!(matches!(app.input_mode, InputMode::GoPrefix { .. }));
}
#[test]
fn test_g_then_i_navigates_to_issues() {
let mut app = test_app();
// First key: 'g'
let key_g = KeyEvent::new(KeyCode::Char('g'));
app.update(Msg::RawEvent(Event::Key(key_g)));
// Second key: 'i'
let key_i = KeyEvent::new(KeyCode::Char('i'));
app.update(Msg::RawEvent(Event::Key(key_i)));
assert!(app.navigation.is_at(&Screen::IssueList));
}
#[test]
fn test_go_prefix_timeout_cancels() {
let clock = FakeClock::new(chrono::Utc::now());
let mut app = LoreApp::with_clock(Box::new(clock.clone()));
// Press 'g'.
let key_g = KeyEvent::new(KeyCode::Char('g'));
app.update(Msg::RawEvent(Event::Key(key_g)));
assert!(matches!(app.input_mode, InputMode::GoPrefix { .. }));
// Advance clock past timeout.
clock.advance(TimeDelta::milliseconds(600));
// Press 'i' after timeout — should NOT navigate to issues.
let key_i = KeyEvent::new(KeyCode::Char('i'));
app.update(Msg::RawEvent(Event::Key(key_i)));
// Should still be at Dashboard (no navigation happened).
assert!(app.navigation.is_at(&Screen::Dashboard));
assert!(matches!(app.input_mode, InputMode::Normal));
}
#[test]
fn test_show_help_toggles() {
let mut app = test_app();
assert!(!app.state.show_help);
app.update(Msg::ShowHelp);
assert!(app.state.show_help);
app.update(Msg::ShowHelp);
assert!(!app.state.show_help);
}
#[test]
fn test_error_msg_sets_toast() {
let mut app = test_app();
app.update(Msg::Error(crate::message::AppError::DbBusy));
assert!(app.state.error_toast.is_some());
assert!(app.state.error_toast.as_ref().unwrap().contains("busy"));
}
#[test]
fn test_resize_updates_terminal_size() {
let mut app = test_app();
app.update(Msg::Resize {
width: 120,
height: 40,
});
assert_eq!(app.state.terminal_size, (120, 40));
}
#[test]
fn test_stale_result_dropped() {
let mut app = test_app();
// Submit two tasks for IssueList — second supersedes first.
let gen1 = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::IssueList))
.generation;
let gen2 = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::IssueList))
.generation;
// Stale result with gen1 should be ignored.
app.update(Msg::IssueListLoaded {
generation: gen1,
rows: vec![crate::message::IssueRow {
key: crate::message::EntityKey::issue(1, 1),
title: "stale".into(),
state: "opened".into(),
}],
});
assert!(app.state.issue_list.rows.is_empty());
// Current result with gen2 should be applied.
app.update(Msg::IssueListLoaded {
generation: gen2,
rows: vec![crate::message::IssueRow {
key: crate::message::EntityKey::issue(1, 2),
title: "fresh".into(),
state: "opened".into(),
}],
});
assert_eq!(app.state.issue_list.rows.len(), 1);
assert_eq!(app.state.issue_list.rows[0].title, "fresh");
}
#[test]
fn test_crash_context_records_events() {
let mut app = test_app();
app.update(Msg::Tick);
app.update(Msg::NavigateTo(Screen::IssueList));
// Should have recorded at least 2 events.
assert!(app.crash_context.len() >= 2);
}
#[test]
fn test_navigate_sets_loading_state() {
let mut app = test_app();
app.update(Msg::NavigateTo(Screen::IssueList));
assert_eq!(
*app.state.load_state.get(&Screen::IssueList),
LoadState::Refreshing
);
}
#[test]
fn test_command_palette_opens_from_ctrl_p() {
let mut app = test_app();
let key = KeyEvent::new(KeyCode::Char('p')).with_modifiers(Modifiers::CTRL);
app.update(Msg::RawEvent(Event::Key(key)));
assert!(matches!(app.input_mode, InputMode::Palette));
assert!(app.state.command_palette.query_focused);
}
#[test]
fn test_esc_closes_palette() {
let mut app = test_app();
app.input_mode = InputMode::Palette;
let key = KeyEvent::new(KeyCode::Escape);
app.update(Msg::RawEvent(Event::Key(key)));
assert!(matches!(app.input_mode, InputMode::Normal));
}
#[test]
fn test_blur_text_input_msg() {
let mut app = test_app();
app.input_mode = InputMode::Text;
app.state.search.query_focused = true;
app.update(Msg::BlurTextInput);
assert!(matches!(app.input_mode, InputMode::Normal));
assert!(!app.state.has_text_focus());
}
#[test]
fn test_default_is_new() {
let app = LoreApp::default();
assert!(app.navigation.is_at(&Screen::Dashboard));
assert!(matches!(app.input_mode, InputMode::Normal));
}
}

View File

@@ -0,0 +1,807 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Command registry — single source of truth for all TUI actions.
//!
//! Every keybinding, palette entry, help text, CLI equivalent, and
//! status hint is generated from [`CommandRegistry`]. No hardcoded
//! duplicate maps exist in view/state modules.
//!
//! Supports single-key and two-key sequences (g-prefix vim bindings).
use std::collections::HashMap;
use ftui::{KeyCode, Modifiers};
use crate::message::{InputMode, Screen};
// ---------------------------------------------------------------------------
// Key formatting
// ---------------------------------------------------------------------------
/// Format a key code + modifiers as a human-readable string.
fn format_key(code: KeyCode, modifiers: Modifiers) -> String {
let mut parts = Vec::new();
if modifiers.contains(Modifiers::CTRL) {
parts.push("Ctrl");
}
if modifiers.contains(Modifiers::ALT) {
parts.push("Alt");
}
if modifiers.contains(Modifiers::SHIFT) {
parts.push("Shift");
}
let key_name = match code {
KeyCode::Char(c) => c.to_string(),
KeyCode::Enter => "Enter".to_string(),
KeyCode::Escape => "Esc".to_string(),
KeyCode::Tab => "Tab".to_string(),
KeyCode::Backspace => "Backspace".to_string(),
KeyCode::Delete => "Del".to_string(),
KeyCode::Up => "Up".to_string(),
KeyCode::Down => "Down".to_string(),
KeyCode::Left => "Left".to_string(),
KeyCode::Right => "Right".to_string(),
KeyCode::Home => "Home".to_string(),
KeyCode::End => "End".to_string(),
KeyCode::PageUp => "PgUp".to_string(),
KeyCode::PageDown => "PgDn".to_string(),
KeyCode::F(n) => format!("F{n}"),
_ => "?".to_string(),
};
parts.push(&key_name);
// We need to own the joined string.
let joined: String = parts.join("+");
joined
}
// ---------------------------------------------------------------------------
// KeyCombo
// ---------------------------------------------------------------------------
/// A keybinding: either a single key or a two-key sequence.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum KeyCombo {
/// Single key press (e.g., `q`, `Esc`, `Ctrl+P`).
Single { code: KeyCode, modifiers: Modifiers },
/// Two-key sequence (e.g., `g` then `i` for go-to-issues).
Sequence {
first_code: KeyCode,
first_modifiers: Modifiers,
second_code: KeyCode,
second_modifiers: Modifiers,
},
}
impl KeyCombo {
/// Convenience: single key with no modifiers.
#[must_use]
pub const fn key(code: KeyCode) -> Self {
Self::Single {
code,
modifiers: Modifiers::NONE,
}
}
/// Convenience: single key with Ctrl modifier.
#[must_use]
pub const fn ctrl(code: KeyCode) -> Self {
Self::Single {
code,
modifiers: Modifiers::CTRL,
}
}
/// Convenience: g-prefix sequence (g + char).
#[must_use]
pub const fn g_then(c: char) -> Self {
Self::Sequence {
first_code: KeyCode::Char('g'),
first_modifiers: Modifiers::NONE,
second_code: KeyCode::Char(c),
second_modifiers: Modifiers::NONE,
}
}
/// Human-readable display string for this key combo.
#[must_use]
pub fn display(&self) -> String {
match self {
Self::Single { code, modifiers } => format_key(*code, *modifiers),
Self::Sequence {
first_code,
first_modifiers,
second_code,
second_modifiers,
} => {
let first = format_key(*first_code, *first_modifiers);
let second = format_key(*second_code, *second_modifiers);
format!("{first} {second}")
}
}
}
/// Whether this combo starts with the given key.
#[must_use]
pub fn starts_with(&self, code: &KeyCode, modifiers: &Modifiers) -> bool {
match self {
Self::Single {
code: c,
modifiers: m,
} => c == code && m == modifiers,
Self::Sequence {
first_code,
first_modifiers,
..
} => first_code == code && first_modifiers == modifiers,
}
}
}
// ---------------------------------------------------------------------------
// ScreenFilter
// ---------------------------------------------------------------------------
/// Specifies which screens a command is available on.
#[derive(Debug, Clone)]
pub enum ScreenFilter {
/// Available on all screens.
Global,
/// Available only on specific screens.
Only(Vec<Screen>),
}
impl ScreenFilter {
/// Whether the command is available on the given screen.
#[must_use]
pub fn matches(&self, screen: &Screen) -> bool {
match self {
Self::Global => true,
Self::Only(screens) => screens.contains(screen),
}
}
}
// ---------------------------------------------------------------------------
// CommandDef
// ---------------------------------------------------------------------------
/// Unique command identifier.
pub type CommandId = &'static str;
/// A registered command with its keybinding, help text, and scope.
#[derive(Debug, Clone)]
pub struct CommandDef {
/// Unique identifier (e.g., "quit", "go_issues").
pub id: CommandId,
/// Human-readable label for palette and help overlay.
pub label: &'static str,
/// Keybinding (if any).
pub keybinding: Option<KeyCombo>,
/// Equivalent `lore` CLI command (for "Show CLI equivalent" feature).
pub cli_equivalent: Option<&'static str>,
/// Description for help overlay.
pub help_text: &'static str,
/// Short hint for status bar (e.g., "q:quit").
pub status_hint: &'static str,
/// Which screens this command is available on.
pub available_in: ScreenFilter,
/// Whether this command works in Text input mode.
pub available_in_text_mode: bool,
}
// ---------------------------------------------------------------------------
// CommandRegistry
// ---------------------------------------------------------------------------
/// Single source of truth for all TUI commands.
///
/// Built once at startup via [`build_registry`]. Provides O(1) lookup
/// by keybinding and per-screen filtering.
pub struct CommandRegistry {
commands: Vec<CommandDef>,
/// Single-key -> command IDs that start with this key.
by_single_key: HashMap<(KeyCode, Modifiers), Vec<usize>>,
/// Full sequence -> command index (for two-key combos).
by_sequence: HashMap<KeyCombo, usize>,
}
impl CommandRegistry {
/// Look up a command by a single key press on a given screen and input mode.
///
/// Returns `None` if no matching command is found. For sequence starters
/// (like 'g'), returns `None` — use [`is_sequence_starter`] to detect
/// that case.
#[must_use]
pub fn lookup_key(
&self,
code: &KeyCode,
modifiers: &Modifiers,
screen: &Screen,
mode: &InputMode,
) -> Option<&CommandDef> {
let is_text = matches!(mode, InputMode::Text);
let key = (*code, *modifiers);
let indices = self.by_single_key.get(&key)?;
for &idx in indices {
let cmd = &self.commands[idx];
if !cmd.available_in.matches(screen) {
continue;
}
if is_text && !cmd.available_in_text_mode {
continue;
}
// Only match Single combos here, not sequence starters.
if let Some(KeyCombo::Single { .. }) = &cmd.keybinding {
return Some(cmd);
}
}
None
}
/// Complete a two-key sequence.
///
/// Called after the first key of a sequence is detected (e.g., after 'g').
#[must_use]
pub fn complete_sequence(
&self,
first_code: &KeyCode,
first_modifiers: &Modifiers,
second_code: &KeyCode,
second_modifiers: &Modifiers,
screen: &Screen,
) -> Option<&CommandDef> {
let combo = KeyCombo::Sequence {
first_code: *first_code,
first_modifiers: *first_modifiers,
second_code: *second_code,
second_modifiers: *second_modifiers,
};
let &idx = self.by_sequence.get(&combo)?;
let cmd = &self.commands[idx];
if cmd.available_in.matches(screen) {
Some(cmd)
} else {
None
}
}
/// Whether a key starts a multi-key sequence (e.g., 'g').
#[must_use]
pub fn is_sequence_starter(&self, code: &KeyCode, modifiers: &Modifiers) -> bool {
self.by_sequence
.keys()
.any(|combo| combo.starts_with(code, modifiers))
}
/// Commands available for the command palette on a given screen.
///
/// Returned sorted by label.
#[must_use]
pub fn palette_entries(&self, screen: &Screen) -> Vec<&CommandDef> {
let mut entries: Vec<&CommandDef> = self
.commands
.iter()
.filter(|c| c.available_in.matches(screen))
.collect();
entries.sort_by_key(|c| c.label);
entries
}
/// Commands for the help overlay on a given screen.
#[must_use]
pub fn help_entries(&self, screen: &Screen) -> Vec<&CommandDef> {
self.commands
.iter()
.filter(|c| c.available_in.matches(screen))
.filter(|c| c.keybinding.is_some())
.collect()
}
/// Status bar hints for the current screen.
#[must_use]
pub fn status_hints(&self, screen: &Screen) -> Vec<&str> {
self.commands
.iter()
.filter(|c| c.available_in.matches(screen))
.filter(|c| !c.status_hint.is_empty())
.map(|c| c.status_hint)
.collect()
}
/// Total number of registered commands.
#[must_use]
pub fn len(&self) -> usize {
self.commands.len()
}
/// Whether the registry has no commands.
#[must_use]
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
}
// ---------------------------------------------------------------------------
// build_registry
// ---------------------------------------------------------------------------
/// Build the command registry with all TUI commands.
///
/// This is the single source of truth — every keybinding, help text,
/// and palette entry originates here.
#[must_use]
pub fn build_registry() -> CommandRegistry {
let commands = vec![
// --- Global commands ---
CommandDef {
id: "quit",
label: "Quit",
keybinding: Some(KeyCombo::key(KeyCode::Char('q'))),
cli_equivalent: None,
help_text: "Exit the TUI",
status_hint: "q:quit",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_back",
label: "Go Back",
keybinding: Some(KeyCombo::key(KeyCode::Escape)),
cli_equivalent: None,
help_text: "Go back to previous screen",
status_hint: "esc:back",
available_in: ScreenFilter::Global,
available_in_text_mode: true,
},
CommandDef {
id: "show_help",
label: "Help",
keybinding: Some(KeyCombo::key(KeyCode::Char('?'))),
cli_equivalent: None,
help_text: "Show keybinding help overlay",
status_hint: "?:help",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "command_palette",
label: "Command Palette",
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('p'))),
cli_equivalent: None,
help_text: "Open command palette",
status_hint: "C-p:palette",
available_in: ScreenFilter::Global,
available_in_text_mode: true,
},
CommandDef {
id: "open_in_browser",
label: "Open in Browser",
keybinding: Some(KeyCombo::key(KeyCode::Char('o'))),
cli_equivalent: None,
help_text: "Open current entity in browser",
status_hint: "o:browser",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "show_cli",
label: "Show CLI Equivalent",
keybinding: Some(KeyCombo::key(KeyCode::Char('!'))),
cli_equivalent: None,
help_text: "Show equivalent lore CLI command",
status_hint: "",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
// --- Navigation: g-prefix sequences ---
CommandDef {
id: "go_home",
label: "Go to Dashboard",
keybinding: Some(KeyCombo::g_then('h')),
cli_equivalent: None,
help_text: "Jump to dashboard",
status_hint: "gh:home",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_issues",
label: "Go to Issues",
keybinding: Some(KeyCombo::g_then('i')),
cli_equivalent: Some("lore issues"),
help_text: "Jump to issue list",
status_hint: "gi:issues",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_mrs",
label: "Go to Merge Requests",
keybinding: Some(KeyCombo::g_then('m')),
cli_equivalent: Some("lore mrs"),
help_text: "Jump to MR list",
status_hint: "gm:mrs",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_search",
label: "Go to Search",
keybinding: Some(KeyCombo::g_then('/')),
cli_equivalent: Some("lore search"),
help_text: "Jump to search",
status_hint: "g/:search",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_timeline",
label: "Go to Timeline",
keybinding: Some(KeyCombo::g_then('t')),
cli_equivalent: Some("lore timeline"),
help_text: "Jump to timeline",
status_hint: "gt:timeline",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_who",
label: "Go to Who",
keybinding: Some(KeyCombo::g_then('w')),
cli_equivalent: Some("lore who"),
help_text: "Jump to people intelligence",
status_hint: "gw:who",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_sync",
label: "Go to Sync",
keybinding: Some(KeyCombo::g_then('s')),
cli_equivalent: Some("lore sync"),
help_text: "Jump to sync status",
status_hint: "gs:sync",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
// --- Vim-style jump list ---
CommandDef {
id: "jump_back",
label: "Jump Back",
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('o'))),
cli_equivalent: None,
help_text: "Jump backward through visited detail views",
status_hint: "C-o:jump back",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "jump_forward",
label: "Jump Forward",
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('i'))),
cli_equivalent: None,
help_text: "Jump forward through visited detail views",
status_hint: "",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
// --- List navigation ---
CommandDef {
id: "move_down",
label: "Move Down",
keybinding: Some(KeyCombo::key(KeyCode::Char('j'))),
cli_equivalent: None,
help_text: "Move cursor down",
status_hint: "j:down",
available_in: ScreenFilter::Only(vec![
Screen::IssueList,
Screen::MrList,
Screen::Search,
Screen::Timeline,
]),
available_in_text_mode: false,
},
CommandDef {
id: "move_up",
label: "Move Up",
keybinding: Some(KeyCombo::key(KeyCode::Char('k'))),
cli_equivalent: None,
help_text: "Move cursor up",
status_hint: "k:up",
available_in: ScreenFilter::Only(vec![
Screen::IssueList,
Screen::MrList,
Screen::Search,
Screen::Timeline,
]),
available_in_text_mode: false,
},
CommandDef {
id: "select_item",
label: "Select",
keybinding: Some(KeyCombo::key(KeyCode::Enter)),
cli_equivalent: None,
help_text: "Open selected item",
status_hint: "enter:open",
available_in: ScreenFilter::Only(vec![
Screen::IssueList,
Screen::MrList,
Screen::Search,
]),
available_in_text_mode: false,
},
// --- Filter ---
CommandDef {
id: "focus_filter",
label: "Filter",
keybinding: Some(KeyCombo::key(KeyCode::Char('/'))),
cli_equivalent: None,
help_text: "Focus the filter input",
status_hint: "/:filter",
available_in: ScreenFilter::Only(vec![Screen::IssueList, Screen::MrList]),
available_in_text_mode: false,
},
// --- Scroll ---
CommandDef {
id: "scroll_to_top",
label: "Scroll to Top",
keybinding: Some(KeyCombo::g_then('g')),
cli_equivalent: None,
help_text: "Scroll to the top of the current view",
status_hint: "",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
];
build_from_defs(commands)
}
/// Build index maps from a list of command definitions.
fn build_from_defs(commands: Vec<CommandDef>) -> CommandRegistry {
let mut by_single_key: HashMap<(KeyCode, Modifiers), Vec<usize>> = HashMap::new();
let mut by_sequence: HashMap<KeyCombo, usize> = HashMap::new();
for (idx, cmd) in commands.iter().enumerate() {
if let Some(combo) = &cmd.keybinding {
match combo {
KeyCombo::Single { code, modifiers } => {
by_single_key
.entry((*code, *modifiers))
.or_default()
.push(idx);
}
KeyCombo::Sequence { .. } => {
by_sequence.insert(combo.clone(), idx);
// Also index the first key so is_sequence_starter works via by_single_key.
if let KeyCombo::Sequence {
first_code,
first_modifiers,
..
} = combo
{
by_single_key
.entry((*first_code, *first_modifiers))
.or_default()
.push(idx);
}
}
}
}
}
CommandRegistry {
commands,
by_single_key,
by_sequence,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
#[test]
fn test_registry_builds_successfully() {
let reg = build_registry();
assert!(!reg.is_empty());
assert!(reg.len() >= 15);
}
#[test]
fn test_registry_lookup_quit() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Char('q'),
&Modifiers::NONE,
&Screen::Dashboard,
&InputMode::Normal,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "quit");
}
#[test]
fn test_registry_lookup_quit_blocked_in_text_mode() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Char('q'),
&Modifiers::NONE,
&Screen::Dashboard,
&InputMode::Text,
);
assert!(cmd.is_none());
}
#[test]
fn test_registry_esc_works_in_text_mode() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Escape,
&Modifiers::NONE,
&Screen::IssueList,
&InputMode::Text,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "go_back");
}
#[test]
fn test_registry_ctrl_p_works_in_text_mode() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Char('p'),
&Modifiers::CTRL,
&Screen::Search,
&InputMode::Text,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "command_palette");
}
#[test]
fn test_g_is_sequence_starter() {
let reg = build_registry();
assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE));
assert!(!reg.is_sequence_starter(&KeyCode::Char('x'), &Modifiers::NONE));
}
#[test]
fn test_complete_sequence_gi() {
let reg = build_registry();
let cmd = reg.complete_sequence(
&KeyCode::Char('g'),
&Modifiers::NONE,
&KeyCode::Char('i'),
&Modifiers::NONE,
&Screen::Dashboard,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "go_issues");
}
#[test]
fn test_complete_sequence_invalid_second_key() {
let reg = build_registry();
let cmd = reg.complete_sequence(
&KeyCode::Char('g'),
&Modifiers::NONE,
&KeyCode::Char('x'),
&Modifiers::NONE,
&Screen::Dashboard,
);
assert!(cmd.is_none());
}
#[test]
fn test_screen_specific_command() {
let reg = build_registry();
// 'j' (move_down) should work on IssueList
let cmd = reg.lookup_key(
&KeyCode::Char('j'),
&Modifiers::NONE,
&Screen::IssueList,
&InputMode::Normal,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "move_down");
// 'j' should NOT match on Dashboard (move_down is list-only).
let cmd = reg.lookup_key(
&KeyCode::Char('j'),
&Modifiers::NONE,
&Screen::Dashboard,
&InputMode::Normal,
);
assert!(cmd.is_none());
}
#[test]
fn test_palette_entries_sorted_by_label() {
let reg = build_registry();
let entries = reg.palette_entries(&Screen::Dashboard);
let labels: Vec<&str> = entries.iter().map(|c| c.label).collect();
let mut sorted = labels.clone();
sorted.sort();
assert_eq!(labels, sorted);
}
#[test]
fn test_help_entries_only_include_keybindings() {
let reg = build_registry();
let entries = reg.help_entries(&Screen::Dashboard);
for entry in &entries {
assert!(
entry.keybinding.is_some(),
"help entry without keybinding: {}",
entry.id
);
}
}
#[test]
fn test_status_hints_non_empty() {
let reg = build_registry();
let hints = reg.status_hints(&Screen::Dashboard);
assert!(!hints.is_empty());
// All returned hints should be non-empty strings.
for hint in &hints {
assert!(!hint.is_empty());
}
}
#[test]
fn test_cli_equivalents_populated() {
let reg = build_registry();
let with_cli: Vec<&CommandDef> = reg
.commands
.iter()
.filter(|c| c.cli_equivalent.is_some())
.collect();
assert!(
with_cli.len() >= 5,
"expected at least 5 commands with cli_equivalent, got {}",
with_cli.len()
);
}
#[test]
fn test_go_prefix_timeout_detection() {
let reg = build_registry();
// Simulate GoPrefix mode entering: 'g' detected as sequence starter.
assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE));
// Simulate InputMode::GoPrefix with timeout check.
let started = Utc::now();
let mode = InputMode::GoPrefix {
started_at: started,
};
// In GoPrefix mode, normal lookup should still work for non-sequence keys.
let cmd = reg.lookup_key(
&KeyCode::Char('q'),
&Modifiers::NONE,
&Screen::Dashboard,
&mode,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "quit");
}
#[test]
fn test_all_commands_have_nonempty_help() {
let reg = build_registry();
for cmd in &reg.commands {
assert!(
!cmd.help_text.is_empty(),
"command {} has empty help_text",
cmd.id
);
}
}
}

View File

@@ -0,0 +1,443 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Ring buffer of recent app events for post-mortem crash diagnostics.
//!
//! The TUI pushes every key press, message dispatch, and state transition
//! into [`CrashContext`]. On panic the installed hook dumps the last 2000
//! events to `~/.local/share/lore/crash-<timestamp>.json` as NDJSON.
//!
//! Retention: only the 5 most recent crash files are kept.
use std::collections::VecDeque;
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
use serde::Serialize;
/// Maximum number of events retained in the ring buffer.
const MAX_EVENTS: usize = 2000;
/// Maximum number of crash files to keep on disk.
const MAX_CRASH_FILES: usize = 5;
// ---------------------------------------------------------------------------
// CrashEvent
// ---------------------------------------------------------------------------
/// A single event recorded for crash diagnostics.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum CrashEvent {
/// A key was pressed.
KeyPress {
key: String,
mode: String,
screen: String,
},
/// A message was dispatched through update().
MsgDispatched { msg_name: String, screen: String },
/// Navigation changed screens.
StateTransition { from: String, to: String },
/// An error occurred.
Error { message: String },
/// Catch-all for ad-hoc diagnostic breadcrumbs.
Custom { tag: String, detail: String },
}
// ---------------------------------------------------------------------------
// CrashContext
// ---------------------------------------------------------------------------
/// Ring buffer of recent app events for panic diagnostics.
///
/// Holds at most [`MAX_EVENTS`] entries. When full, the oldest event
/// is evicted on each push.
pub struct CrashContext {
events: VecDeque<CrashEvent>,
}
impl CrashContext {
/// Create an empty crash context with pre-allocated capacity.
#[must_use]
pub fn new() -> Self {
Self {
events: VecDeque::with_capacity(MAX_EVENTS),
}
}
/// Record an event. Evicts the oldest when the buffer is full.
pub fn push(&mut self, event: CrashEvent) {
if self.events.len() == MAX_EVENTS {
self.events.pop_front();
}
self.events.push_back(event);
}
/// Number of events currently stored.
#[must_use]
pub fn len(&self) -> usize {
self.events.len()
}
/// Whether the buffer is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
/// Iterate over stored events (oldest first).
pub fn iter(&self) -> impl Iterator<Item = &CrashEvent> {
self.events.iter()
}
/// Dump all events to a file as newline-delimited JSON.
///
/// Creates parent directories if they don't exist.
/// Returns `Ok(())` on success, `Err` on I/O failure.
pub fn dump_to_file(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::File::create(path)?;
let mut writer = BufWriter::new(file);
for event in &self.events {
match serde_json::to_string(event) {
Ok(json) => {
writeln!(writer, "{json}")?;
}
Err(_) => {
// Fallback to debug format if serialization fails.
writeln!(
writer,
"{{\"type\":\"SerializationError\",\"debug\":\"{event:?}\"}}"
)?;
}
}
}
writer.flush()?;
Ok(())
}
/// Default crash directory: `~/.local/share/lore/`.
#[must_use]
pub fn crash_dir() -> Option<PathBuf> {
dirs::data_local_dir().map(|d| d.join("lore"))
}
/// Generate a timestamped crash file path.
#[must_use]
pub fn crash_file_path() -> Option<PathBuf> {
let dir = Self::crash_dir()?;
let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.3f");
Some(dir.join(format!("crash-{timestamp}.json")))
}
/// Remove old crash files, keeping only the most recent [`MAX_CRASH_FILES`].
///
/// Best-effort: silently ignores I/O errors on individual deletions.
pub fn prune_crash_files() {
let Some(dir) = Self::crash_dir() else {
return;
};
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
};
let mut crash_files: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("crash-") && n.ends_with(".json"))
})
.collect();
// Sort ascending by filename (timestamps sort lexicographically).
crash_files.sort();
if crash_files.len() > MAX_CRASH_FILES {
let to_remove = crash_files.len() - MAX_CRASH_FILES;
for path in &crash_files[..to_remove] {
let _ = std::fs::remove_file(path);
}
}
}
/// Install a panic hook that dumps the crash context to disk.
///
/// Captures the current events via a snapshot. The hook chains with
/// the default panic handler so backtraces are still printed.
pub fn install_panic_hook(ctx: &Self) {
let snapshot: Vec<CrashEvent> = ctx.events.iter().cloned().collect();
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
// Best-effort dump — never panic inside the panic hook.
if let Some(path) = Self::crash_file_path() {
let mut dump = CrashContext::new();
for event in &snapshot {
dump.push(event.clone());
}
// Add the panic info itself as the final event.
dump.push(CrashEvent::Error {
message: format!("{info}"),
});
let _ = dump.dump_to_file(&path);
}
// Chain to the previous hook (prints backtrace, etc.).
prev_hook(info);
}));
}
}
impl Default for CrashContext {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::io::BufRead;
/// Helper: create a numbered Custom event.
fn event(n: usize) -> CrashEvent {
CrashEvent::Custom {
tag: "test".into(),
detail: format!("event-{n}"),
}
}
#[test]
fn test_ring_buffer_evicts_oldest() {
let mut ctx = CrashContext::new();
for i in 0..2500 {
ctx.push(event(i));
}
assert_eq!(ctx.len(), MAX_EVENTS);
// First retained event should be #500 (0..499 evicted).
let first = ctx.iter().next().unwrap();
match first {
CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-500"),
other => panic!("unexpected variant: {other:?}"),
}
// Last retained event should be #2499.
let last = ctx.iter().last().unwrap();
match last {
CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-2499"),
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn test_new_is_empty() {
let ctx = CrashContext::new();
assert!(ctx.is_empty());
assert_eq!(ctx.len(), 0);
}
#[test]
fn test_push_increments_len() {
let mut ctx = CrashContext::new();
ctx.push(event(1));
ctx.push(event(2));
assert_eq!(ctx.len(), 2);
}
#[test]
fn test_push_does_not_evict_below_capacity() {
let mut ctx = CrashContext::new();
for i in 0..MAX_EVENTS {
ctx.push(event(i));
}
assert_eq!(ctx.len(), MAX_EVENTS);
// First should still be event-0.
match ctx.iter().next().unwrap() {
CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-0"),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn test_dump_to_file_writes_ndjson() {
let mut ctx = CrashContext::new();
ctx.push(CrashEvent::KeyPress {
key: "j".into(),
mode: "Normal".into(),
screen: "Dashboard".into(),
});
ctx.push(CrashEvent::MsgDispatched {
msg_name: "NavigateTo".into(),
screen: "Dashboard".into(),
});
ctx.push(CrashEvent::StateTransition {
from: "Dashboard".into(),
to: "IssueList".into(),
});
ctx.push(CrashEvent::Error {
message: "db busy".into(),
});
ctx.push(CrashEvent::Custom {
tag: "test".into(),
detail: "hello".into(),
});
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test-crash.json");
ctx.dump_to_file(&path).unwrap();
// Verify: each line is valid JSON, total lines == 5.
let file = std::fs::File::open(&path).unwrap();
let reader = io::BufReader::new(file);
let lines: Vec<String> = reader.lines().map(Result::unwrap).collect();
assert_eq!(lines.len(), 5);
// Each line must parse as JSON.
for line in &lines {
let val: serde_json::Value = serde_json::from_str(line).unwrap();
assert!(val.get("type").is_some(), "missing 'type' field: {line}");
}
// Spot check first line: KeyPress with correct fields.
let first: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(first["type"], "KeyPress");
assert_eq!(first["key"], "j");
assert_eq!(first["mode"], "Normal");
assert_eq!(first["screen"], "Dashboard");
}
#[test]
fn test_dump_creates_parent_directories() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("a").join("b").join("c").join("crash.json");
let mut ctx = CrashContext::new();
ctx.push(event(1));
ctx.dump_to_file(&nested).unwrap();
assert!(nested.exists());
}
#[test]
fn test_dump_empty_context_creates_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.json");
let ctx = CrashContext::new();
ctx.dump_to_file(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.is_empty());
}
#[test]
fn test_prune_keeps_newest_files() {
let dir = tempfile::tempdir().unwrap();
let crash_dir = dir.path();
// Create 8 crash files with ordered timestamps.
let filenames: Vec<String> = (0..8)
.map(|i| format!("crash-2026010{i}-120000.000.json"))
.collect();
for name in &filenames {
std::fs::write(crash_dir.join(name), "{}").unwrap();
}
// Prune, pointing at our temp dir.
prune_crash_files_in(crash_dir);
let remaining: Vec<String> = std::fs::read_dir(crash_dir)
.unwrap()
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with("crash-") && n.ends_with(".json"))
.collect();
assert_eq!(remaining.len(), MAX_CRASH_FILES);
// Oldest 3 should be gone.
for name in filenames.iter().take(3) {
assert!(!remaining.contains(name));
}
// Newest 5 should remain.
for name in filenames.iter().skip(3) {
assert!(remaining.contains(name));
}
}
#[test]
fn test_all_event_variants_serialize() {
let events = vec![
CrashEvent::KeyPress {
key: "q".into(),
mode: "Normal".into(),
screen: "Dashboard".into(),
},
CrashEvent::MsgDispatched {
msg_name: "Quit".into(),
screen: "Dashboard".into(),
},
CrashEvent::StateTransition {
from: "Dashboard".into(),
to: "IssueList".into(),
},
CrashEvent::Error {
message: "oops".into(),
},
CrashEvent::Custom {
tag: "debug".into(),
detail: "trace".into(),
},
];
for event in events {
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("type").is_some());
}
}
#[test]
fn test_default_is_new() {
let ctx = CrashContext::default();
assert!(ctx.is_empty());
}
// -----------------------------------------------------------------------
// Test helper: prune files in a specific directory (not the real path).
// -----------------------------------------------------------------------
fn prune_crash_files_in(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut crash_files: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("crash-") && n.ends_with(".json"))
})
.collect();
crash_files.sort();
if crash_files.len() > MAX_CRASH_FILES {
let to_remove = crash_files.len() - MAX_CRASH_FILES;
for path in &crash_files[..to_remove] {
let _ = std::fs::remove_file(path);
}
}
}
}

View File

@@ -18,6 +18,14 @@ pub mod theme; // Flexoki theme: build_theme, state_color, label_style (bd-5ofk)
pub mod app; // LoreApp Model trait impl (Phase 0 proof: bd-2emv, full: bd-6pmy)
// Phase 1 modules.
pub mod commands; // CommandRegistry: keybindings, help, palette (bd-38lb)
pub mod crash_context; // CrashContext ring buffer + panic hook (bd-2fr7)
pub mod navigation; // NavigationStack: back/forward/jump list (bd-1qpp)
pub mod state; // AppState, LoadState, ScreenIntent, per-screen states (bd-1v9m)
pub mod task_supervisor; // TaskSupervisor: dedup + cancel + generation IDs (bd-3le2)
pub mod view; // View layer: render_screen + common widgets (bd-26f2)
/// Options controlling how the TUI launches.
#[derive(Debug, Clone)]
pub struct LaunchOptions {

View File

@@ -9,8 +9,8 @@
//! - [`InputMode`] — controls key dispatch routing.
use std::fmt;
use std::time::Instant;
use chrono::{DateTime, Utc};
use ftui::Event;
// ---------------------------------------------------------------------------
@@ -74,7 +74,7 @@ impl fmt::Display for EntityKey {
// ---------------------------------------------------------------------------
/// Navigation targets within the TUI.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Screen {
Dashboard,
IssueList,
@@ -174,7 +174,7 @@ pub enum InputMode {
/// Command palette is open.
Palette,
/// "g" prefix pressed — waiting for second key (500ms timeout).
GoPrefix { started_at: Instant },
GoPrefix { started_at: DateTime<Utc> },
}
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,339 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Browser-like navigation stack with vim-style jump list.
//!
//! Supports back/forward (browser), jump back/forward (vim Ctrl+O/Ctrl+I),
//! and breadcrumb generation. State is preserved when navigating away —
//! screens are never cleared on pop.
use crate::message::Screen;
// ---------------------------------------------------------------------------
// NavigationStack
// ---------------------------------------------------------------------------
/// Browser-like navigation with back/forward stacks and a vim jump list.
///
/// The jump list only records "significant" hops — detail views and
/// cross-references — skipping list/dashboard screens that users
/// visit briefly during drilling.
pub struct NavigationStack {
back_stack: Vec<Screen>,
current: Screen,
forward_stack: Vec<Screen>,
jump_list: Vec<Screen>,
jump_index: usize,
}
impl NavigationStack {
/// Create a new stack starting at the Dashboard.
#[must_use]
pub fn new() -> Self {
Self {
back_stack: Vec::new(),
current: Screen::Dashboard,
forward_stack: Vec::new(),
jump_list: Vec::new(),
jump_index: 0,
}
}
/// The currently displayed screen.
#[must_use]
pub fn current(&self) -> &Screen {
&self.current
}
/// Whether the current screen matches the given screen.
#[must_use]
pub fn is_at(&self, screen: &Screen) -> bool {
&self.current == screen
}
/// Navigate to a new screen.
///
/// Pushes current to back_stack, clears forward_stack (browser behavior),
/// and records detail hops in the jump list.
pub fn push(&mut self, screen: Screen) {
let old = std::mem::replace(&mut self.current, screen);
self.back_stack.push(old);
self.forward_stack.clear();
// Record significant hops in jump list (vim behavior):
// truncate any forward entries beyond jump_index, then append.
if self.current.is_detail_or_entity() {
self.jump_list.truncate(self.jump_index);
self.jump_list.push(self.current.clone());
self.jump_index = self.jump_list.len();
}
}
/// Go back to the previous screen.
///
/// Returns `None` at root (can't pop past the initial screen).
pub fn pop(&mut self) -> Option<&Screen> {
let prev = self.back_stack.pop()?;
let old = std::mem::replace(&mut self.current, prev);
self.forward_stack.push(old);
Some(&self.current)
}
/// Go forward (redo a pop).
///
/// Returns `None` if there's nothing to go forward to.
pub fn go_forward(&mut self) -> Option<&Screen> {
let next = self.forward_stack.pop()?;
let old = std::mem::replace(&mut self.current, next);
self.back_stack.push(old);
Some(&self.current)
}
/// Jump backward through the jump list (vim Ctrl+O).
///
/// Only visits detail/entity screens.
pub fn jump_back(&mut self) -> Option<&Screen> {
if self.jump_index == 0 {
return None;
}
self.jump_index -= 1;
self.jump_list.get(self.jump_index)
}
/// Jump forward through the jump list (vim Ctrl+I).
pub fn jump_forward(&mut self) -> Option<&Screen> {
if self.jump_index >= self.jump_list.len() {
return None;
}
let screen = self.jump_list.get(self.jump_index)?;
self.jump_index += 1;
Some(screen)
}
/// Reset to a single screen, clearing all history.
pub fn reset_to(&mut self, screen: Screen) {
self.current = screen;
self.back_stack.clear();
self.forward_stack.clear();
self.jump_list.clear();
self.jump_index = 0;
}
/// Breadcrumb labels for the current navigation path.
///
/// Returns the back stack labels plus the current screen label.
#[must_use]
pub fn breadcrumbs(&self) -> Vec<&str> {
self.back_stack
.iter()
.chain(std::iter::once(&self.current))
.map(Screen::label)
.collect()
}
/// Navigation depth (1 = at root, 2 = one push deep, etc.).
#[must_use]
pub fn depth(&self) -> usize {
self.back_stack.len() + 1
}
/// Whether there's anything to go back to.
#[must_use]
pub fn can_go_back(&self) -> bool {
!self.back_stack.is_empty()
}
/// Whether there's anything to go forward to.
#[must_use]
pub fn can_go_forward(&self) -> bool {
!self.forward_stack.is_empty()
}
}
impl Default for NavigationStack {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::message::EntityKey;
#[test]
fn test_new_starts_at_dashboard() {
let nav = NavigationStack::new();
assert!(nav.is_at(&Screen::Dashboard));
assert_eq!(nav.depth(), 1);
}
#[test]
fn test_push_pop_preserves_order() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::IssueDetail(EntityKey::issue(1, 42)));
assert!(nav.is_at(&Screen::IssueDetail(EntityKey::issue(1, 42))));
assert_eq!(nav.depth(), 3);
nav.pop();
assert!(nav.is_at(&Screen::IssueList));
nav.pop();
assert!(nav.is_at(&Screen::Dashboard));
}
#[test]
fn test_pop_at_root_returns_none() {
let mut nav = NavigationStack::new();
assert!(nav.pop().is_none());
assert!(nav.is_at(&Screen::Dashboard));
}
#[test]
fn test_forward_stack_cleared_on_new_push() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::Search);
nav.pop(); // back to IssueList, Search in forward
assert!(nav.can_go_forward());
nav.push(Screen::Timeline); // new push clears forward
assert!(!nav.can_go_forward());
}
#[test]
fn test_go_forward_restores() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::Search);
nav.pop(); // back to IssueList
let screen = nav.go_forward();
assert!(screen.is_some());
assert!(nav.is_at(&Screen::Search));
}
#[test]
fn test_go_forward_returns_none_when_empty() {
let mut nav = NavigationStack::new();
assert!(nav.go_forward().is_none());
}
#[test]
fn test_jump_list_skips_list_screens() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList); // not a detail — skip
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1))); // detail — record
nav.push(Screen::MrList); // not a detail — skip
nav.push(Screen::MrDetail(EntityKey::mr(1, 2))); // detail — record
assert_eq!(nav.jump_list.len(), 2);
}
#[test]
fn test_jump_back_and_forward() {
let mut nav = NavigationStack::new();
let issue = Screen::IssueDetail(EntityKey::issue(1, 1));
let mr = Screen::MrDetail(EntityKey::mr(1, 2));
nav.push(Screen::IssueList);
nav.push(issue.clone());
nav.push(Screen::MrList);
nav.push(mr.clone());
// jump_index is at 2 (past the end of 2 items)
let prev = nav.jump_back();
assert_eq!(prev, Some(&mr));
let prev = nav.jump_back();
assert_eq!(prev, Some(&issue));
// at beginning
assert!(nav.jump_back().is_none());
// forward
let next = nav.jump_forward();
assert_eq!(next, Some(&issue));
let next = nav.jump_forward();
assert_eq!(next, Some(&mr));
// at end
assert!(nav.jump_forward().is_none());
}
#[test]
fn test_jump_list_truncates_on_new_push() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1)));
nav.push(Screen::IssueDetail(EntityKey::issue(1, 2)));
nav.push(Screen::IssueDetail(EntityKey::issue(1, 3)));
// jump back twice
nav.jump_back();
nav.jump_back();
// jump_index = 1, pointing at issue 2
// new detail push truncates forward entries
nav.push(Screen::MrDetail(EntityKey::mr(1, 99)));
// should have issue(1,1) and mr(1,99), not issue(1,2) or issue(1,3)
assert_eq!(nav.jump_list.len(), 2);
assert_eq!(nav.jump_list[1], Screen::MrDetail(EntityKey::mr(1, 99)));
}
#[test]
fn test_reset_clears_all_history() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::Search);
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1)));
nav.reset_to(Screen::Dashboard);
assert!(nav.is_at(&Screen::Dashboard));
assert_eq!(nav.depth(), 1);
assert!(!nav.can_go_back());
assert!(!nav.can_go_forward());
assert!(nav.jump_list.is_empty());
}
#[test]
fn test_breadcrumbs_reflect_stack() {
let mut nav = NavigationStack::new();
assert_eq!(nav.breadcrumbs(), vec!["Dashboard"]);
nav.push(Screen::IssueList);
assert_eq!(nav.breadcrumbs(), vec!["Dashboard", "Issues"]);
nav.push(Screen::IssueDetail(EntityKey::issue(1, 42)));
assert_eq!(nav.breadcrumbs(), vec!["Dashboard", "Issues", "Issue"]);
}
#[test]
fn test_default_is_new() {
let nav = NavigationStack::default();
assert!(nav.is_at(&Screen::Dashboard));
assert_eq!(nav.depth(), 1);
}
#[test]
fn test_can_go_back_and_forward() {
let mut nav = NavigationStack::new();
assert!(!nav.can_go_back());
assert!(!nav.can_go_forward());
nav.push(Screen::IssueList);
assert!(nav.can_go_back());
assert!(!nav.can_go_forward());
nav.pop();
assert!(!nav.can_go_back());
assert!(nav.can_go_forward());
}
}

View File

@@ -0,0 +1,11 @@
#![allow(dead_code)]
//! Command palette state.
/// State for the command palette overlay.
#[derive(Debug, Default)]
pub struct CommandPaletteState {
pub query: String,
pub query_focused: bool,
pub selected_index: usize,
}

View File

@@ -0,0 +1,10 @@
#![allow(dead_code)]
//! Dashboard screen state.
/// State for the dashboard summary screen.
#[derive(Debug, Default)]
pub struct DashboardState {
pub issue_count: u64,
pub mr_count: u64,
}

View File

@@ -0,0 +1,14 @@
#![allow(dead_code)]
//! Issue detail screen state.
use crate::message::{Discussion, EntityKey, IssueDetail};
/// State for the issue detail screen.
#[derive(Debug, Default)]
pub struct IssueDetailState {
pub key: Option<EntityKey>,
pub detail: Option<IssueDetail>,
pub discussions: Vec<Discussion>,
pub scroll_offset: u16,
}

View File

@@ -0,0 +1,14 @@
#![allow(dead_code)]
//! Issue list screen state.
use crate::message::IssueRow;
/// State for the issue list screen.
#[derive(Debug, Default)]
pub struct IssueListState {
pub rows: Vec<IssueRow>,
pub filter: String,
pub filter_focused: bool,
pub selected_index: usize,
}

View File

@@ -0,0 +1,335 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Top-level state composition for the TUI.
//!
//! Each screen has its own state struct. State is preserved when
//! navigating away — screens are never cleared on pop.
//!
//! [`LoadState`] enables stale-while-revalidate: screens show the last
//! available data during a refresh, with a spinner indicating the load.
//!
//! [`ScreenIntent`] is the pure return type from state handlers — they
//! never spawn async tasks directly. The intent is interpreted by
//! [`LoreApp`](crate::app::LoreApp) which dispatches through the
//! [`TaskSupervisor`](crate::task_supervisor::TaskSupervisor).
pub mod command_palette;
pub mod dashboard;
pub mod issue_detail;
pub mod issue_list;
pub mod mr_detail;
pub mod mr_list;
pub mod search;
pub mod sync;
pub mod timeline;
pub mod who;
use std::collections::HashMap;
use crate::message::Screen;
// Re-export screen states for convenience.
pub use command_palette::CommandPaletteState;
pub use dashboard::DashboardState;
pub use issue_detail::IssueDetailState;
pub use issue_list::IssueListState;
pub use mr_detail::MrDetailState;
pub use mr_list::MrListState;
pub use search::SearchState;
pub use sync::SyncState;
pub use timeline::TimelineState;
pub use who::WhoState;
// ---------------------------------------------------------------------------
// LoadState
// ---------------------------------------------------------------------------
/// Loading state for a screen's data.
///
/// Enables stale-while-revalidate: screens render their last data while
/// showing a spinner when `Refreshing`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum LoadState {
/// No load in progress, data is current (or screen was never loaded).
#[default]
Idle,
/// First load — no data to show yet, display a full-screen spinner.
LoadingInitial,
/// Background refresh — show existing data with a spinner indicator.
Refreshing,
/// Load failed — display the error alongside any stale data.
Error(String),
}
impl LoadState {
/// Whether data is currently being loaded.
#[must_use]
pub fn is_loading(&self) -> bool {
matches!(self, Self::LoadingInitial | Self::Refreshing)
}
}
// ---------------------------------------------------------------------------
// ScreenLoadStateMap
// ---------------------------------------------------------------------------
/// Tracks per-screen load state.
///
/// Returns [`LoadState::Idle`] for screens that haven't been tracked.
/// Automatically removes entries set to `Idle` to prevent unbounded growth.
#[derive(Debug, Default)]
pub struct ScreenLoadStateMap {
map: HashMap<Screen, LoadState>,
}
impl ScreenLoadStateMap {
/// Get the load state for a screen (defaults to `Idle`).
#[must_use]
pub fn get(&self, screen: &Screen) -> &LoadState {
static IDLE: LoadState = LoadState::Idle;
self.map.get(screen).unwrap_or(&IDLE)
}
/// Set the load state for a screen.
///
/// Setting to `Idle` removes the entry to prevent map growth.
pub fn set(&mut self, screen: Screen, state: LoadState) {
if state == LoadState::Idle {
self.map.remove(&screen);
} else {
self.map.insert(screen, state);
}
}
/// Whether any screen is currently loading.
#[must_use]
pub fn any_loading(&self) -> bool {
self.map.values().any(LoadState::is_loading)
}
}
// ---------------------------------------------------------------------------
// ScreenIntent
// ---------------------------------------------------------------------------
/// Pure return type from screen state handlers.
///
/// State handlers must never spawn async work directly — they return
/// an intent that [`LoreApp`] interprets and dispatches through the
/// [`TaskSupervisor`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScreenIntent {
/// No action needed.
None,
/// Navigate to a new screen.
Navigate(Screen),
/// Screen data needs re-querying (e.g., filter changed).
RequeryNeeded(Screen),
}
// ---------------------------------------------------------------------------
// ScopeContext
// ---------------------------------------------------------------------------
/// Global scope filters applied across all screens.
///
/// When a project filter is active, all data queries scope to that
/// project. The TUI shows the active scope in the status bar.
#[derive(Debug, Default)]
pub struct ScopeContext {
/// Active project filter (project_id).
pub project_id: Option<i64>,
/// Human-readable project name for display.
pub project_name: Option<String>,
}
// ---------------------------------------------------------------------------
// AppState
// ---------------------------------------------------------------------------
/// Top-level state composition for the TUI.
///
/// Each field holds one screen's state. State is preserved when
/// navigating away and restored on return.
#[derive(Debug, Default)]
pub struct AppState {
// Per-screen states.
pub dashboard: DashboardState,
pub issue_list: IssueListState,
pub issue_detail: IssueDetailState,
pub mr_list: MrListState,
pub mr_detail: MrDetailState,
pub search: SearchState,
pub timeline: TimelineState,
pub who: WhoState,
pub sync: SyncState,
pub command_palette: CommandPaletteState,
// Cross-cutting state.
pub global_scope: ScopeContext,
pub load_state: ScreenLoadStateMap,
pub error_toast: Option<String>,
pub show_help: bool,
pub terminal_size: (u16, u16),
}
impl AppState {
/// Set a screen's load state.
pub fn set_loading(&mut self, screen: Screen, state: LoadState) {
self.load_state.set(screen, state);
}
/// Set the global error toast.
pub fn set_error(&mut self, msg: String) {
self.error_toast = Some(msg);
}
/// Clear the global error toast.
pub fn clear_error(&mut self) {
self.error_toast = None;
}
/// Whether any text input is currently focused.
#[must_use]
pub fn has_text_focus(&self) -> bool {
self.issue_list.filter_focused
|| self.mr_list.filter_focused
|| self.search.query_focused
|| self.command_palette.query_focused
}
/// Remove focus from all text inputs.
pub fn blur_text_focus(&mut self) {
self.issue_list.filter_focused = false;
self.mr_list.filter_focused = false;
self.search.query_focused = false;
self.command_palette.query_focused = false;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_state_default_idle() {
let map = ScreenLoadStateMap::default();
assert_eq!(*map.get(&Screen::Dashboard), LoadState::Idle);
assert_eq!(*map.get(&Screen::IssueList), LoadState::Idle);
}
#[test]
fn test_load_state_set_and_get() {
let mut map = ScreenLoadStateMap::default();
map.set(Screen::Dashboard, LoadState::LoadingInitial);
assert_eq!(*map.get(&Screen::Dashboard), LoadState::LoadingInitial);
assert_eq!(*map.get(&Screen::IssueList), LoadState::Idle);
}
#[test]
fn test_load_state_set_idle_removes_entry() {
let mut map = ScreenLoadStateMap::default();
map.set(Screen::Dashboard, LoadState::Refreshing);
assert_eq!(map.map.len(), 1);
map.set(Screen::Dashboard, LoadState::Idle);
assert_eq!(map.map.len(), 0);
assert_eq!(*map.get(&Screen::Dashboard), LoadState::Idle);
}
#[test]
fn test_any_loading() {
let mut map = ScreenLoadStateMap::default();
assert!(!map.any_loading());
map.set(Screen::Dashboard, LoadState::LoadingInitial);
assert!(map.any_loading());
map.set(Screen::Dashboard, LoadState::Error("oops".into()));
assert!(!map.any_loading());
}
#[test]
fn test_load_state_is_loading() {
assert!(!LoadState::Idle.is_loading());
assert!(LoadState::LoadingInitial.is_loading());
assert!(LoadState::Refreshing.is_loading());
assert!(!LoadState::Error("x".into()).is_loading());
}
#[test]
fn test_app_state_default_compiles() {
let state = AppState::default();
assert!(!state.show_help);
assert!(state.error_toast.is_none());
assert_eq!(state.terminal_size, (0, 0));
}
#[test]
fn test_app_state_set_error_and_clear() {
let mut state = AppState::default();
state.set_error("db busy".into());
assert_eq!(state.error_toast.as_deref(), Some("db busy"));
state.clear_error();
assert!(state.error_toast.is_none());
}
#[test]
fn test_app_state_has_text_focus() {
let mut state = AppState::default();
assert!(!state.has_text_focus());
state.search.query_focused = true;
assert!(state.has_text_focus());
}
#[test]
fn test_app_state_blur_text_focus() {
let mut state = AppState::default();
state.issue_list.filter_focused = true;
state.mr_list.filter_focused = true;
state.search.query_focused = true;
state.command_palette.query_focused = true;
state.blur_text_focus();
assert!(!state.has_text_focus());
assert!(!state.issue_list.filter_focused);
assert!(!state.mr_list.filter_focused);
assert!(!state.search.query_focused);
assert!(!state.command_palette.query_focused);
}
#[test]
fn test_app_state_set_loading() {
let mut state = AppState::default();
state.set_loading(Screen::IssueList, LoadState::Refreshing);
assert_eq!(
*state.load_state.get(&Screen::IssueList),
LoadState::Refreshing
);
}
#[test]
fn test_screen_intent_variants() {
let none = ScreenIntent::None;
let nav = ScreenIntent::Navigate(Screen::IssueList);
let requery = ScreenIntent::RequeryNeeded(Screen::Search);
assert_eq!(none, ScreenIntent::None);
assert_eq!(nav, ScreenIntent::Navigate(Screen::IssueList));
assert_eq!(requery, ScreenIntent::RequeryNeeded(Screen::Search));
}
#[test]
fn test_scope_context_default() {
let scope = ScopeContext::default();
assert!(scope.project_id.is_none());
assert!(scope.project_name.is_none());
}
}

View File

@@ -0,0 +1,14 @@
#![allow(dead_code)]
//! Merge request detail screen state.
use crate::message::{Discussion, EntityKey, MrDetail};
/// State for the MR detail screen.
#[derive(Debug, Default)]
pub struct MrDetailState {
pub key: Option<EntityKey>,
pub detail: Option<MrDetail>,
pub discussions: Vec<Discussion>,
pub scroll_offset: u16,
}

View File

@@ -0,0 +1,14 @@
#![allow(dead_code)]
//! Merge request list screen state.
use crate::message::MrRow;
/// State for the MR list screen.
#[derive(Debug, Default)]
pub struct MrListState {
pub rows: Vec<MrRow>,
pub filter: String,
pub filter_focused: bool,
pub selected_index: usize,
}

View File

@@ -0,0 +1,14 @@
#![allow(dead_code)]
//! Search screen state.
use crate::message::SearchResult;
/// State for the search screen.
#[derive(Debug, Default)]
pub struct SearchState {
pub query: String,
pub query_focused: bool,
pub results: Vec<SearchResult>,
pub selected_index: usize,
}

View File

@@ -0,0 +1,15 @@
#![allow(dead_code)]
//! Sync screen state.
/// State for the sync progress/summary screen.
#[derive(Debug, Default)]
pub struct SyncState {
pub stage: String,
pub current: u64,
pub total: u64,
pub log_lines: Vec<String>,
pub completed: bool,
pub elapsed_ms: Option<u64>,
pub error: Option<String>,
}

View File

@@ -0,0 +1,12 @@
#![allow(dead_code)]
//! Timeline screen state.
use crate::message::TimelineEvent;
/// State for the timeline screen.
#[derive(Debug, Default)]
pub struct TimelineState {
pub events: Vec<TimelineEvent>,
pub scroll_offset: u16,
}

View File

@@ -0,0 +1,12 @@
#![allow(dead_code)]
//! Who (people intelligence) screen state.
use crate::message::WhoResult;
/// State for the who/people screen.
#[derive(Debug, Default)]
pub struct WhoState {
pub result: Option<WhoResult>,
pub scroll_offset: u16,
}

View File

@@ -0,0 +1,380 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Centralized background task management with dedup and cancellation.
//!
//! All background work (DB queries, sync, search) flows through
//! [`TaskSupervisor`]. Submitting a task with a key that already has an
//! active handle cancels the previous task via its [`CancelToken`] and
//! bumps the generation counter.
//!
//! Generation IDs enable stale-result detection: when an async result
//! arrives, [`is_current`] checks whether the result's generation
//! matches the latest submission for that key.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use crate::message::Screen;
// ---------------------------------------------------------------------------
// TaskKey
// ---------------------------------------------------------------------------
/// Deduplication key for background tasks.
///
/// Two tasks with the same key cannot run concurrently — submitting a
/// new task with an existing key cancels the previous one.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum TaskKey {
/// Load data for a specific screen.
LoadScreen(Screen),
/// Global search query.
Search,
/// Sync stream (only one at a time).
SyncStream,
/// Re-query after filter change on a specific screen.
FilterRequery(Screen),
}
// ---------------------------------------------------------------------------
// TaskPriority
// ---------------------------------------------------------------------------
/// Priority levels for task scheduling.
///
/// Lower numeric value = higher priority.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum TaskPriority {
/// User-initiated input (highest priority).
Input = 0,
/// Navigation-triggered data load.
Navigation = 1,
/// Background refresh / prefetch (lowest priority).
Background = 2,
}
// ---------------------------------------------------------------------------
// CancelToken
// ---------------------------------------------------------------------------
/// Thread-safe cooperative cancellation flag.
///
/// Background tasks poll [`is_cancelled`] periodically and exit early
/// when it returns `true`.
#[derive(Debug)]
pub struct CancelToken {
cancelled: AtomicBool,
}
impl CancelToken {
/// Create a new, non-cancelled token.
#[must_use]
pub fn new() -> Self {
Self {
cancelled: AtomicBool::new(false),
}
}
/// Signal cancellation.
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Relaxed);
}
/// Check whether cancellation has been requested.
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Relaxed)
}
}
impl Default for CancelToken {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// InterruptHandle
// ---------------------------------------------------------------------------
/// Opaque handle for interrupting a rusqlite operation.
///
/// Wraps the rusqlite `InterruptHandle` so the supervisor can cancel
/// long-running queries. This is only set for tasks that lease a reader
/// connection from [`DbManager`](crate::db::DbManager).
pub struct InterruptHandle {
handle: rusqlite::InterruptHandle,
}
impl std::fmt::Debug for InterruptHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InterruptHandle").finish_non_exhaustive()
}
}
impl InterruptHandle {
/// Wrap a rusqlite interrupt handle.
#[must_use]
pub fn new(handle: rusqlite::InterruptHandle) -> Self {
Self { handle }
}
/// Interrupt the associated SQLite operation.
pub fn interrupt(&self) {
self.handle.interrupt();
}
}
// ---------------------------------------------------------------------------
// TaskHandle
// ---------------------------------------------------------------------------
/// Handle returned when a task is submitted.
///
/// Callers use this to pass the generation ID into async work so
/// results can be tagged and checked for staleness.
#[derive(Debug)]
pub struct TaskHandle {
/// Dedup key for this task.
pub key: TaskKey,
/// Monotonically increasing generation for stale detection.
pub generation: u64,
/// Cooperative cancellation token (shared with the supervisor).
pub cancel: Arc<CancelToken>,
/// Optional SQLite interrupt handle for long queries.
pub interrupt: Option<InterruptHandle>,
}
// ---------------------------------------------------------------------------
// TaskSupervisor
// ---------------------------------------------------------------------------
/// Manages background tasks with deduplication and cancellation.
///
/// Only one task per [`TaskKey`] can be active. Submitting a new task
/// with an existing key cancels the previous one (via its cancel token
/// and optional interrupt handle) before registering the new handle.
pub struct TaskSupervisor {
active: HashMap<TaskKey, TaskHandle>,
next_generation: AtomicU64,
}
impl TaskSupervisor {
/// Create a new supervisor with no active tasks.
#[must_use]
pub fn new() -> Self {
Self {
active: HashMap::new(),
next_generation: AtomicU64::new(1),
}
}
/// Submit a new task, cancelling any existing task with the same key.
///
/// Returns a [`TaskHandle`] with a fresh generation ID and a shared
/// cancel token. The caller clones the `Arc<CancelToken>` and passes
/// it into the async work.
pub fn submit(&mut self, key: TaskKey) -> &TaskHandle {
// Cancel existing task with this key, if any.
if let Some(old) = self.active.remove(&key) {
old.cancel.cancel();
if let Some(interrupt) = &old.interrupt {
interrupt.interrupt();
}
}
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
let cancel = Arc::new(CancelToken::new());
let handle = TaskHandle {
key: key.clone(),
generation,
cancel,
interrupt: None,
};
self.active.insert(key.clone(), handle);
self.active.get(&key).expect("just inserted")
}
/// Check whether a generation is current for a given key.
///
/// Returns `true` only if the key has an active handle with the
/// specified generation.
#[must_use]
pub fn is_current(&self, key: &TaskKey, generation: u64) -> bool {
self.active
.get(key)
.is_some_and(|h| h.generation == generation)
}
/// Mark a task as complete, removing its handle.
///
/// Only removes the handle if the generation matches the active one.
/// This prevents a late-arriving completion from removing a newer
/// task's handle.
pub fn complete(&mut self, key: &TaskKey, generation: u64) {
if self.is_current(key, generation) {
self.active.remove(key);
}
}
/// Cancel all active tasks.
///
/// Used during shutdown to ensure background work stops promptly.
pub fn cancel_all(&mut self) {
for (_, handle) in self.active.drain() {
handle.cancel.cancel();
if let Some(interrupt) = &handle.interrupt {
interrupt.interrupt();
}
}
}
/// Number of currently active tasks.
#[must_use]
pub fn active_count(&self) -> usize {
self.active.len()
}
}
impl Default for TaskSupervisor {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_submit_cancels_previous() {
let mut sup = TaskSupervisor::new();
let gen1 = sup.submit(TaskKey::Search).generation;
let cancel1 = sup.active.get(&TaskKey::Search).unwrap().cancel.clone();
let gen2 = sup.submit(TaskKey::Search).generation;
// First task's token should be cancelled.
assert!(cancel1.is_cancelled());
// Second task should have a different (higher) generation.
assert!(gen2 > gen1);
// Only one active task for this key.
assert_eq!(sup.active_count(), 1);
}
#[test]
fn test_is_current_after_supersede() {
let mut sup = TaskSupervisor::new();
let gen1 = sup.submit(TaskKey::Search).generation;
let gen2 = sup.submit(TaskKey::Search).generation;
assert!(!sup.is_current(&TaskKey::Search, gen1));
assert!(sup.is_current(&TaskKey::Search, gen2));
}
#[test]
fn test_complete_removes_handle() {
let mut sup = TaskSupervisor::new();
let generation = sup.submit(TaskKey::Search).generation;
assert_eq!(sup.active_count(), 1);
sup.complete(&TaskKey::Search, generation);
assert_eq!(sup.active_count(), 0);
}
#[test]
fn test_complete_ignores_stale() {
let mut sup = TaskSupervisor::new();
let gen1 = sup.submit(TaskKey::Search).generation;
let gen2 = sup.submit(TaskKey::Search).generation;
// Completing with old generation should NOT remove the newer handle.
sup.complete(&TaskKey::Search, gen1);
assert_eq!(sup.active_count(), 1);
assert!(sup.is_current(&TaskKey::Search, gen2));
}
#[test]
fn test_generation_monotonic() {
let mut sup = TaskSupervisor::new();
let g1 = sup.submit(TaskKey::Search).generation;
let g2 = sup.submit(TaskKey::SyncStream).generation;
let g3 = sup.submit(TaskKey::Search).generation;
assert!(g1 < g2);
assert!(g2 < g3);
}
#[test]
fn test_different_keys_coexist() {
let mut sup = TaskSupervisor::new();
sup.submit(TaskKey::Search);
sup.submit(TaskKey::SyncStream);
sup.submit(TaskKey::LoadScreen(Screen::Dashboard));
assert_eq!(sup.active_count(), 3);
}
#[test]
fn test_cancel_all() {
let mut sup = TaskSupervisor::new();
let cancel_search = {
sup.submit(TaskKey::Search);
sup.active.get(&TaskKey::Search).unwrap().cancel.clone()
};
let cancel_sync = {
sup.submit(TaskKey::SyncStream);
sup.active.get(&TaskKey::SyncStream).unwrap().cancel.clone()
};
sup.cancel_all();
assert!(cancel_search.is_cancelled());
assert!(cancel_sync.is_cancelled());
assert_eq!(sup.active_count(), 0);
}
#[test]
fn test_cancel_token_default_is_not_cancelled() {
let token = CancelToken::new();
assert!(!token.is_cancelled());
token.cancel();
assert!(token.is_cancelled());
}
#[test]
fn test_cancel_token_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<CancelToken>();
assert_send_sync::<Arc<CancelToken>>();
}
#[test]
fn test_task_supervisor_default() {
let sup = TaskSupervisor::default();
assert_eq!(sup.active_count(), 0);
}
#[test]
fn test_filter_requery_key_distinct_per_screen() {
let mut sup = TaskSupervisor::new();
sup.submit(TaskKey::FilterRequery(Screen::IssueList));
sup.submit(TaskKey::FilterRequery(Screen::MrList));
assert_eq!(sup.active_count(), 2);
}
}

View File

@@ -0,0 +1,816 @@
#![allow(dead_code)] // Phase 1: consumed by screen views in Phase 2+
//! Common widgets shared across all TUI screens.
//!
//! These are pure rendering functions — they write directly into the
//! [`Frame`] buffer using ftui's `Draw` trait. No state mutation,
//! no side effects.
//!
//! - [`render_breadcrumb`] — navigation trail ("Dashboard > Issues > #42")
//! - [`render_status_bar`] — bottom bar with key hints and mode indicator
//! - [`render_loading`] — full-screen spinner or subtle refresh indicator
//! - [`render_error_toast`] — floating error message at bottom-right
//! - [`render_help_overlay`] — centered modal listing keybindings
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::commands::CommandRegistry;
use crate::message::{InputMode, Screen};
use crate::navigation::NavigationStack;
use crate::state::LoadState;
// ---------------------------------------------------------------------------
// Spinner frames
// ---------------------------------------------------------------------------
/// Braille spinner frames for loading animation.
const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
/// Select spinner frame from tick count.
#[must_use]
fn spinner_char(tick: u64) -> char {
SPINNER_FRAMES[(tick as usize) % SPINNER_FRAMES.len()]
}
// ---------------------------------------------------------------------------
// render_breadcrumb
// ---------------------------------------------------------------------------
/// Render the navigation breadcrumb trail.
///
/// Shows "Dashboard > Issues > Issue" with " > " separators. When the
/// trail exceeds the available width, entries are truncated from the left
/// with a leading "...".
pub fn render_breadcrumb(
frame: &mut Frame<'_>,
area: Rect,
nav: &NavigationStack,
text_color: PackedRgba,
muted_color: PackedRgba,
) {
if area.height == 0 || area.width < 3 {
return;
}
let crumbs = nav.breadcrumbs();
let separator = " > ";
// Build the full breadcrumb string and calculate width.
let full: String = crumbs.join(separator);
let max_width = area.width as usize;
let display = if full.len() <= max_width {
full
} else {
// Truncate from the left: show "... > last_crumbs"
truncate_breadcrumb_left(&crumbs, separator, max_width)
};
let base = Cell {
fg: text_color,
..Cell::default()
};
let muted = Cell {
fg: muted_color,
..Cell::default()
};
// Render each segment with separators in muted color.
let mut x = area.x;
let max_x = area.x.saturating_add(area.width);
if let Some(rest) = display.strip_prefix("...") {
// Render ellipsis in muted, then the rest
x = frame.print_text_clipped(x, area.y, "...", muted, max_x);
if !rest.is_empty() {
render_crumb_segments(frame, x, area.y, rest, separator, base, muted, max_x);
}
} else {
render_crumb_segments(frame, x, area.y, &display, separator, base, muted, max_x);
}
}
/// Render breadcrumb text with separators in muted color.
#[allow(clippy::too_many_arguments)]
fn render_crumb_segments(
frame: &mut Frame<'_>,
start_x: u16,
y: u16,
text: &str,
separator: &str,
base: Cell,
muted: Cell,
max_x: u16,
) {
let mut x = start_x;
let parts: Vec<&str> = text.split(separator).collect();
for (i, part) in parts.iter().enumerate() {
if i > 0 {
x = frame.print_text_clipped(x, y, separator, muted, max_x);
}
x = frame.print_text_clipped(x, y, part, base, max_x);
if x >= max_x {
break;
}
}
}
/// Truncate breadcrumb from the left to fit within max_width.
fn truncate_breadcrumb_left(crumbs: &[&str], separator: &str, max_width: usize) -> String {
let ellipsis = "...";
// Try showing progressively fewer crumbs from the right.
for skip in 1..crumbs.len() {
let tail = &crumbs[skip..];
let tail_str: String = tail.join(separator);
let candidate = format!("{ellipsis}{separator}{tail_str}");
if candidate.len() <= max_width {
return candidate;
}
}
// Last resort: just the current screen truncated.
let last = crumbs.last().unwrap_or(&"");
if last.len() + ellipsis.len() <= max_width {
return format!("{ellipsis}{last}");
}
// Truly tiny terminal: just ellipsis.
ellipsis.to_string()
}
// ---------------------------------------------------------------------------
// render_status_bar
// ---------------------------------------------------------------------------
/// Render the bottom status bar with key hints and mode indicator.
///
/// Layout: `[mode] ─── [key hints]`
///
/// Key hints are sourced from the [`CommandRegistry`] filtered to the
/// current screen, showing only the most important bindings.
#[allow(clippy::too_many_arguments)]
pub fn render_status_bar(
frame: &mut Frame<'_>,
area: Rect,
registry: &CommandRegistry,
screen: &Screen,
mode: &InputMode,
bar_bg: PackedRgba,
text_color: PackedRgba,
accent_color: PackedRgba,
) {
if area.height == 0 || area.width < 5 {
return;
}
// Fill the bar background.
let bg_cell = Cell {
bg: bar_bg,
..Cell::default()
};
frame.draw_rect_filled(area, bg_cell);
let mode_label = match mode {
InputMode::Normal => "NORMAL",
InputMode::Text => "INPUT",
InputMode::Palette => "PALETTE",
InputMode::GoPrefix { .. } => "g...",
};
// Left side: mode indicator.
let mode_cell = Cell {
fg: accent_color,
bg: bar_bg,
..Cell::default()
};
let mut x = frame.print_text_clipped(
area.x.saturating_add(1),
area.y,
mode_label,
mode_cell,
area.right(),
);
// Spacer.
x = x.saturating_add(2);
// Right side: key hints from registry (formatted as "key:action").
let hints = registry.status_hints(screen);
let hint_cell = Cell {
fg: text_color,
bg: bar_bg,
..Cell::default()
};
let key_cell = Cell {
fg: accent_color,
bg: bar_bg,
..Cell::default()
};
for hint in &hints {
if x >= area.right().saturating_sub(1) {
break;
}
// Split "q:quit" into key part and description part.
if let Some((key_part, desc_part)) = hint.split_once(':') {
x = frame.print_text_clipped(x, area.y, key_part, key_cell, area.right());
x = frame.print_text_clipped(x, area.y, ":", hint_cell, area.right());
x = frame.print_text_clipped(x, area.y, desc_part, hint_cell, area.right());
} else {
x = frame.print_text_clipped(x, area.y, hint, hint_cell, area.right());
}
x = x.saturating_add(2);
}
}
// ---------------------------------------------------------------------------
// render_loading
// ---------------------------------------------------------------------------
/// Render a loading indicator.
///
/// - `LoadingInitial`: centered full-screen spinner with "Loading..."
/// - `Refreshing`: subtle spinner in top-right corner
/// - Other states: no-op
pub fn render_loading(
frame: &mut Frame<'_>,
area: Rect,
load_state: &LoadState,
text_color: PackedRgba,
muted_color: PackedRgba,
tick: u64,
) {
match load_state {
LoadState::LoadingInitial => {
render_centered_spinner(frame, area, "Loading...", text_color, tick);
}
LoadState::Refreshing => {
render_corner_spinner(frame, area, muted_color, tick);
}
_ => {}
}
}
/// Render a centered spinner with message.
fn render_centered_spinner(
frame: &mut Frame<'_>,
area: Rect,
message: &str,
color: PackedRgba,
tick: u64,
) {
if area.height == 0 || area.width < 5 {
return;
}
let spinner = spinner_char(tick);
let text = format!("{spinner} {message}");
let text_len = text.len() as u16;
// Center horizontally and vertically.
let x = area
.x
.saturating_add(area.width.saturating_sub(text_len) / 2);
let y = area.y.saturating_add(area.height / 2);
let cell = Cell {
fg: color,
..Cell::default()
};
frame.print_text_clipped(x, y, &text, cell, area.right());
}
/// Render a subtle spinner in the top-right corner.
fn render_corner_spinner(frame: &mut Frame<'_>, area: Rect, color: PackedRgba, tick: u64) {
if area.width < 2 || area.height == 0 {
return;
}
let spinner = spinner_char(tick);
let x = area.right().saturating_sub(2);
let y = area.y;
let cell = Cell {
fg: color,
..Cell::default()
};
frame.print_text_clipped(x, y, &spinner.to_string(), cell, area.right());
}
// ---------------------------------------------------------------------------
// render_error_toast
// ---------------------------------------------------------------------------
/// Render a floating error toast at the bottom-right of the area.
///
/// The toast has a colored background and truncates long messages.
pub fn render_error_toast(
frame: &mut Frame<'_>,
area: Rect,
msg: &str,
error_bg: PackedRgba,
error_fg: PackedRgba,
) {
if area.height < 3 || area.width < 10 || msg.is_empty() {
return;
}
// Toast dimensions: message + padding, max 60 chars or half screen.
let max_toast_width = (area.width / 2).clamp(20, 60);
let toast_text = if msg.len() as u16 > max_toast_width.saturating_sub(4) {
let trunc_len = max_toast_width.saturating_sub(7) as usize;
format!(" {}... ", &msg[..trunc_len.min(msg.len())])
} else {
format!(" {msg} ")
};
let toast_width = toast_text.len() as u16;
let toast_height: u16 = 1;
// Position: bottom-right with 1-cell margin.
let x = area.right().saturating_sub(toast_width + 1);
let y = area.bottom().saturating_sub(toast_height + 1);
let toast_rect = Rect::new(x, y, toast_width, toast_height);
// Fill background.
let bg_cell = Cell {
bg: error_bg,
..Cell::default()
};
frame.draw_rect_filled(toast_rect, bg_cell);
// Render text.
let text_cell = Cell {
fg: error_fg,
bg: error_bg,
..Cell::default()
};
frame.print_text_clipped(x, y, &toast_text, text_cell, area.right());
}
// ---------------------------------------------------------------------------
// render_help_overlay
// ---------------------------------------------------------------------------
/// Render a centered help overlay listing keybindings for the current screen.
///
/// The overlay is a bordered modal that lists all commands from the
/// registry that are available on the current screen.
#[allow(clippy::too_many_arguments)]
pub fn render_help_overlay(
frame: &mut Frame<'_>,
area: Rect,
registry: &CommandRegistry,
screen: &Screen,
border_color: PackedRgba,
text_color: PackedRgba,
muted_color: PackedRgba,
scroll_offset: usize,
) {
if area.height < 5 || area.width < 20 {
return;
}
// Overlay dimensions: 60% of screen, capped.
let overlay_width = (area.width * 3 / 5).clamp(30, 70);
let overlay_height = (area.height * 3 / 5).clamp(8, 30);
let overlay_x = area.x + (area.width.saturating_sub(overlay_width)) / 2;
let overlay_y = area.y + (area.height.saturating_sub(overlay_height)) / 2;
let overlay_rect = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
// Draw border.
let border_cell = Cell {
fg: border_color,
..Cell::default()
};
frame.draw_border(
overlay_rect,
ftui::render::drawing::BorderChars::ROUNDED,
border_cell,
);
// Title.
let title = " Help (? to close) ";
let title_x = overlay_x + (overlay_width.saturating_sub(title.len() as u16)) / 2;
let title_cell = Cell {
fg: border_color,
..Cell::default()
};
frame.print_text_clipped(title_x, overlay_y, title, title_cell, overlay_rect.right());
// Inner content area (inside border).
let inner = Rect::new(
overlay_x + 2,
overlay_y + 1,
overlay_width.saturating_sub(4),
overlay_height.saturating_sub(2),
);
// Get commands for this screen.
let commands = registry.help_entries(screen);
let visible_lines = inner.height as usize;
let key_cell = Cell {
fg: text_color,
..Cell::default()
};
let desc_cell = Cell {
fg: muted_color,
..Cell::default()
};
for (i, cmd) in commands.iter().skip(scroll_offset).enumerate() {
if i >= visible_lines {
break;
}
let y = inner.y + i as u16;
// Key binding label (left).
let key_label = cmd
.keybinding
.as_ref()
.map_or_else(String::new, |kb| kb.display());
let label_end = frame.print_text_clipped(inner.x, y, &key_label, key_cell, inner.right());
// Spacer + description (right).
let desc_x = label_end.saturating_add(2);
if desc_x < inner.right() {
frame.print_text_clipped(desc_x, y, cmd.help_text, desc_cell, inner.right());
}
}
// Scroll indicator if needed.
if commands.len() > visible_lines + scroll_offset {
let indicator = format!("({}/{})", scroll_offset + visible_lines, commands.len());
let ind_x = inner.right().saturating_sub(indicator.len() as u16);
let ind_y = overlay_rect.bottom().saturating_sub(1);
frame.print_text_clipped(ind_x, ind_y, &indicator, desc_cell, overlay_rect.right());
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::build_registry;
use crate::message::Screen;
use crate::navigation::NavigationStack;
use crate::state::LoadState;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn white() -> PackedRgba {
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
}
fn gray() -> PackedRgba {
PackedRgba::rgb(0x80, 0x80, 0x80)
}
fn red_bg() -> PackedRgba {
PackedRgba::rgb(0xFF, 0x00, 0x00)
}
// --- Breadcrumb tests ---
#[test]
fn test_breadcrumb_single_screen() {
with_frame!(80, 1, |frame| {
let nav = NavigationStack::new(); // Dashboard only
render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 1), &nav, white(), gray());
// Verify "Dashboard" was rendered by checking first cell.
let cell = frame.buffer.get(0, 0).unwrap();
assert!(
cell.content.as_char() == Some('D'),
"Expected 'D' at (0,0), got {:?}",
cell.content.as_char()
);
});
}
#[test]
fn test_breadcrumb_multi_screen() {
with_frame!(80, 1, |frame| {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 1), &nav, white(), gray());
// Should render "Dashboard > Issues"
// Check 'D' at start and 'I' after separator.
let d = frame.buffer.get(0, 0).unwrap();
assert_eq!(d.content.as_char(), Some('D'));
// "Dashboard > Issues" = 'D' at 0, ' > ' at 9, 'I' at 12
let i_cell = frame.buffer.get(12, 0).unwrap();
assert_eq!(i_cell.content.as_char(), Some('I'));
});
}
#[test]
fn test_breadcrumb_truncation() {
// Very narrow terminal — should show "..." prefix.
let crumbs = vec!["Dashboard", "Issues", "Issue"];
let result = truncate_breadcrumb_left(&crumbs, " > ", 20);
assert!(
result.starts_with("..."),
"Expected ellipsis prefix, got: {result}"
);
assert!(result.len() <= 20, "Result too long: {result}");
}
#[test]
fn test_breadcrumb_zero_height_noop() {
// Frame requires height >= 1, but Rect can have height=0.
with_frame!(80, 1, |frame| {
let nav = NavigationStack::new();
// Should not panic — early return for zero-height area.
render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 0), &nav, white(), gray());
});
}
// --- Status bar tests ---
#[test]
fn test_status_bar_renders_mode() {
with_frame!(80, 1, |frame| {
let registry = build_registry();
render_status_bar(
&mut frame,
Rect::new(0, 0, 80, 1),
&registry,
&Screen::Dashboard,
&InputMode::Normal,
gray(),
white(),
white(),
);
// "NORMAL" should appear starting at x=1.
let n_cell = frame.buffer.get(1, 0).unwrap();
assert_eq!(n_cell.content.as_char(), Some('N'));
});
}
#[test]
fn test_status_bar_text_mode() {
with_frame!(80, 1, |frame| {
let registry = build_registry();
render_status_bar(
&mut frame,
Rect::new(0, 0, 80, 1),
&registry,
&Screen::Search,
&InputMode::Text,
gray(),
white(),
white(),
);
// "INPUT" should appear at x=1.
let i_cell = frame.buffer.get(1, 0).unwrap();
assert_eq!(i_cell.content.as_char(), Some('I'));
});
}
#[test]
fn test_status_bar_narrow_terminal() {
with_frame!(4, 1, |frame| {
let registry = build_registry();
// Width < 5, should be a no-op.
render_status_bar(
&mut frame,
Rect::new(0, 0, 4, 1),
&registry,
&Screen::Dashboard,
&InputMode::Normal,
gray(),
white(),
white(),
);
// First cell should be empty (no-op).
let cell = frame.buffer.get(0, 0).unwrap();
assert!(cell.is_empty());
});
}
// --- Loading indicator tests ---
#[test]
fn test_loading_initial_renders_spinner() {
with_frame!(80, 24, |frame| {
render_loading(
&mut frame,
Rect::new(0, 0, 80, 24),
&LoadState::LoadingInitial,
white(),
gray(),
0,
);
// Spinner should be centered at y=12.
// The spinner char is at the center position.
let center_y = 12u16;
// Find any non-empty cell on the center row.
let has_content = (0..80u16).any(|x| {
let cell = frame.buffer.get(x, center_y).unwrap();
!cell.is_empty()
});
assert!(has_content, "Expected loading spinner at center row");
});
}
#[test]
fn test_loading_refreshing_renders_corner() {
with_frame!(80, 24, |frame| {
render_loading(
&mut frame,
Rect::new(0, 0, 80, 24),
&LoadState::Refreshing,
white(),
gray(),
0,
);
// Corner spinner should be at (78, 0).
let cell = frame.buffer.get(78, 0).unwrap();
assert!(!cell.is_empty(), "Expected corner spinner");
});
}
#[test]
fn test_loading_idle_noop() {
with_frame!(80, 24, |frame| {
render_loading(
&mut frame,
Rect::new(0, 0, 80, 24),
&LoadState::Idle,
white(),
gray(),
0,
);
// All cells should be empty.
let has_content = (0..80u16).any(|x| {
(0..24u16).any(|y| {
let cell = frame.buffer.get(x, y).unwrap();
!cell.is_empty()
})
});
assert!(!has_content, "Idle state should render nothing");
});
}
#[test]
fn test_spinner_animation_cycles() {
// Different tick values produce different spinner chars.
let frame0 = spinner_char(0);
let frame1 = spinner_char(1);
let frame_wrap = spinner_char(SPINNER_FRAMES.len() as u64);
assert_ne!(frame0, frame1, "Adjacent frames should differ");
assert_eq!(frame0, frame_wrap, "Should wrap around");
}
// --- Error toast tests ---
#[test]
fn test_error_toast_renders() {
with_frame!(80, 24, |frame| {
render_error_toast(
&mut frame,
Rect::new(0, 0, 80, 24),
"Database is busy",
red_bg(),
white(),
);
// Toast should be at bottom-right. Check row 22 (24-1-1).
let y = 22u16;
let has_content = (40..80u16).any(|x| {
let cell = frame.buffer.get(x, y).unwrap();
!cell.is_empty()
});
assert!(has_content, "Expected error toast at bottom-right");
});
}
#[test]
fn test_error_toast_empty_message_noop() {
with_frame!(80, 24, |frame| {
render_error_toast(&mut frame, Rect::new(0, 0, 80, 24), "", red_bg(), white());
// No content should be rendered.
let has_content = (0..80u16).any(|x| {
(0..24u16).any(|y| {
let cell = frame.buffer.get(x, y).unwrap();
!cell.is_empty()
})
});
assert!(!has_content, "Empty message should render nothing");
});
}
#[test]
fn test_error_toast_truncates_long_message() {
with_frame!(80, 24, |frame| {
let long_msg = "A".repeat(200);
// Should not panic and should truncate.
render_error_toast(
&mut frame,
Rect::new(0, 0, 80, 24),
&long_msg,
red_bg(),
white(),
);
});
}
// --- Help overlay tests ---
#[test]
fn test_help_overlay_renders_border() {
with_frame!(80, 24, |frame| {
let registry = build_registry();
render_help_overlay(
&mut frame,
Rect::new(0, 0, 80, 24),
&registry,
&Screen::Dashboard,
gray(),
white(),
gray(),
0,
);
// The overlay should have non-empty cells in the center area.
let has_content = (20..60u16).any(|x| {
(8..16u16).any(|y| {
let cell = frame.buffer.get(x, y).unwrap();
!cell.is_empty()
})
});
assert!(has_content, "Expected help overlay in center area");
});
}
#[test]
fn test_help_overlay_tiny_terminal_noop() {
with_frame!(15, 4, |frame| {
let registry = build_registry();
// Too small — should be a no-op.
render_help_overlay(
&mut frame,
Rect::new(0, 0, 15, 4),
&registry,
&Screen::Dashboard,
gray(),
white(),
gray(),
0,
);
});
}
// --- Truncation helper tests ---
#[test]
fn test_truncate_breadcrumb_fits() {
let crumbs = vec!["A", "B"];
let result = truncate_breadcrumb_left(&crumbs, " > ", 100);
// When it doesn't need truncation, this function is not called
// in practice, but it should still work.
assert!(result.contains("..."), "Should always add ellipsis");
}
#[test]
fn test_truncate_breadcrumb_single_entry() {
let crumbs = vec!["Dashboard"];
let result = truncate_breadcrumb_left(&crumbs, " > ", 5);
assert_eq!(result, "...");
}
#[test]
fn test_truncate_breadcrumb_shows_last_entries() {
let crumbs = vec!["Dashboard", "Issues", "Issue Detail"];
let result = truncate_breadcrumb_left(&crumbs, " > ", 30);
assert!(result.starts_with("..."));
assert!(result.contains("Issue Detail"));
}
}

View File

@@ -0,0 +1,185 @@
#![allow(dead_code)] // Phase 1: screen content renders added in Phase 2+
//! Top-level view dispatch for the lore TUI.
//!
//! [`render_screen`] is the entry point called from `LoreApp::view()`.
//! It composes the layout: breadcrumb bar, screen content area, status
//! bar, and optional overlays (help, error toast).
pub mod common;
use ftui::layout::{Constraint, Flex};
use ftui::render::cell::PackedRgba;
use ftui::render::frame::Frame;
use crate::app::LoreApp;
use common::{
render_breadcrumb, render_error_toast, render_help_overlay, render_loading, render_status_bar,
};
// ---------------------------------------------------------------------------
// Colors (hardcoded Flexoki palette — will use Theme in Phase 2)
// ---------------------------------------------------------------------------
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
const ERROR_BG: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
const ERROR_FG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
// ---------------------------------------------------------------------------
// render_screen
// ---------------------------------------------------------------------------
/// Top-level view dispatch: composes breadcrumb + content + status bar + overlays.
///
/// Called from `LoreApp::view()`. The layout is:
/// ```text
/// +-----------------------------------+
/// | Breadcrumb (1 row) |
/// +-----------------------------------+
/// | |
/// | Screen content (fill) |
/// | |
/// +-----------------------------------+
/// | Status bar (1 row) |
/// +-----------------------------------+
/// ```
///
/// Overlays (help, error toast) render on top of existing content.
pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
let bounds = frame.bounds();
if bounds.width < 3 || bounds.height < 3 {
return; // Terminal too small to render anything useful.
}
// Split vertically: breadcrumb (1) | content (fill) | status bar (1).
let regions = Flex::vertical()
.constraints([
Constraint::Fixed(1), // breadcrumb
Constraint::Fill, // content
Constraint::Fixed(1), // status bar
])
.split(bounds);
let breadcrumb_area = regions[0];
let content_area = regions[1];
let status_area = regions[2];
let screen = app.navigation.current();
// --- Breadcrumb ---
render_breadcrumb(frame, breadcrumb_area, &app.navigation, TEXT, TEXT_MUTED);
// --- Screen content ---
let load_state = app.state.load_state.get(screen);
// tick=0 placeholder — animation wired up when Msg::Tick increments a counter.
render_loading(frame, content_area, load_state, TEXT, TEXT_MUTED, 0);
// Per-screen content dispatch (Phase 2+).
// match screen {
// Screen::Dashboard => ...,
// Screen::IssueList => ...,
// ...
// }
// --- Status bar ---
render_status_bar(
frame,
status_area,
&app.command_registry,
screen,
&app.input_mode,
BG_SURFACE,
TEXT,
ACCENT,
);
// --- Overlays (render last, on top of everything) ---
// Error toast.
if let Some(ref error_msg) = app.state.error_toast {
render_error_toast(frame, bounds, error_msg, ERROR_BG, ERROR_FG);
}
// Help overlay.
if app.state.show_help {
render_help_overlay(
frame,
bounds,
&app.command_registry,
screen,
BORDER,
TEXT,
TEXT_MUTED,
0, // scroll_offset — tracked in future phase
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::app::LoreApp;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
#[test]
fn test_render_screen_does_not_panic() {
with_frame!(80, 24, |frame| {
let app = LoreApp::new();
render_screen(&mut frame, &app);
});
}
#[test]
fn test_render_screen_tiny_terminal_noop() {
with_frame!(2, 2, |frame| {
let app = LoreApp::new();
render_screen(&mut frame, &app);
// Should not panic — early return for tiny terminals.
});
}
#[test]
fn test_render_screen_with_error_toast() {
with_frame!(80, 24, |frame| {
let mut app = LoreApp::new();
app.state.set_error("test error".into());
render_screen(&mut frame, &app);
// Should render without panicking.
});
}
#[test]
fn test_render_screen_with_help_overlay() {
with_frame!(80, 24, |frame| {
let mut app = LoreApp::new();
app.state.show_help = true;
render_screen(&mut frame, &app);
// Should render without panicking.
});
}
#[test]
fn test_render_screen_narrow_terminal() {
with_frame!(20, 5, |frame| {
let app = LoreApp::new();
render_screen(&mut frame, &app);
});
}
}

View File

@@ -13,6 +13,20 @@ use crate::core::paths::get_db_path;
use crate::core::project::resolve_project;
use crate::core::time::{ms_to_iso, now_ms, parse_since};
// ─── Decay Math ─────────────────────────────────────────────────────────────
/// Exponential half-life decay: R = 2^(-t/h)
/// Returns 1.0 at elapsed=0, 0.5 at elapsed=half_life, 0.0 if half_life=0.
#[allow(dead_code)] // Used by bd-13q8 (decay aggregation)
fn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 {
let days = (elapsed_ms as f64 / 86_400_000.0).max(0.0);
let hl = f64::from(half_life_days);
if hl <= 0.0 {
return 0.0;
}
2.0_f64.powf(-days / hl)
}
// ─── Mode Discrimination ────────────────────────────────────────────────────
/// Determines which query mode to run based on args.
@@ -3568,6 +3582,7 @@ mod tests {
author_weight: 5,
reviewer_weight: 30,
note_bonus: 1,
..ScoringConfig::default()
};
let result = query_expert(&conn, "src/app.rs", None, 0, 20, &flipped, false).unwrap();
assert_eq!(result.experts[0].username, "the_reviewer");
@@ -3690,4 +3705,38 @@ mod tests {
);
}
}
// ─── half_life_decay tests ────────────────────────────────────────────────
#[test]
fn test_half_life_decay_math() {
let hl_180 = 180;
// At t=0, full retention
assert!((half_life_decay(0, hl_180) - 1.0).abs() < f64::EPSILON);
// At t=half_life, exactly 0.5
let one_hl_ms = 180 * 86_400_000_i64;
assert!((half_life_decay(one_hl_ms, hl_180) - 0.5).abs() < 1e-10);
// At t=2*half_life, exactly 0.25
assert!((half_life_decay(2 * one_hl_ms, hl_180) - 0.25).abs() < 1e-10);
// Negative elapsed clamped to 0 -> 1.0
assert!((half_life_decay(-1000, hl_180) - 1.0).abs() < f64::EPSILON);
// Zero half-life -> 0.0 (div-by-zero guard)
assert!((half_life_decay(86_400_000, 0)).abs() < f64::EPSILON);
}
#[test]
fn test_score_monotonicity_by_age() {
let mut seed: u64 = 42;
let hl = 90_u32;
for _ in 0..50 {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let newer_ms = (seed % 100_000_000) as i64;
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let older_ms = newer_ms + (seed % 500_000_000) as i64;
assert!(
half_life_decay(older_ms, hl) <= half_life_decay(newer_ms, hl),
"Monotonicity violated: decay({older_ms}) > decay({newer_ms})"
);
}
}
}

View File

@@ -164,6 +164,38 @@ pub struct ScoringConfig {
/// Bonus points per individual inline review comment (DiffNote).
#[serde(rename = "noteBonus")]
pub note_bonus: i64,
/// Points for being assigned as reviewer (without substantive notes).
#[serde(rename = "reviewerAssignmentWeight")]
pub reviewer_assignment_weight: i64,
/// Half-life in days for author contribution decay.
#[serde(rename = "authorHalfLifeDays")]
pub author_half_life_days: u32,
/// Half-life in days for reviewer participation decay.
#[serde(rename = "reviewerHalfLifeDays")]
pub reviewer_half_life_days: u32,
/// Half-life in days for reviewer-assignment-only decay.
#[serde(rename = "reviewerAssignmentHalfLifeDays")]
pub reviewer_assignment_half_life_days: u32,
/// Half-life in days for note/comment decay.
#[serde(rename = "noteHalfLifeDays")]
pub note_half_life_days: u32,
/// Multiplier applied to closed (not merged) MRs.
#[serde(rename = "closedMrMultiplier")]
pub closed_mr_multiplier: f64,
/// Minimum character count for a reviewer note to be "substantive".
#[serde(rename = "reviewerMinNoteChars")]
pub reviewer_min_note_chars: u32,
/// Usernames to exclude from scoring (e.g. bots).
#[serde(rename = "excludedUsernames")]
pub excluded_usernames: Vec<String>,
}
impl Default for ScoringConfig {
@@ -172,6 +204,14 @@ impl Default for ScoringConfig {
author_weight: 25,
reviewer_weight: 10,
note_bonus: 1,
reviewer_assignment_weight: 3,
author_half_life_days: 180,
reviewer_half_life_days: 90,
reviewer_assignment_half_life_days: 45,
note_half_life_days: 45,
closed_mr_multiplier: 0.5,
reviewer_min_note_chars: 20,
excluded_usernames: Vec::new(),
}
}
}
@@ -287,6 +327,51 @@ fn validate_scoring(scoring: &ScoringConfig) -> Result<()> {
details: "scoring.noteBonus must be >= 0".to_string(),
});
}
if scoring.reviewer_assignment_weight < 0 {
return Err(LoreError::ConfigInvalid {
details: "scoring.reviewerAssignmentWeight must be >= 0".to_string(),
});
}
for (field, value) in [
("authorHalfLifeDays", scoring.author_half_life_days),
("reviewerHalfLifeDays", scoring.reviewer_half_life_days),
(
"reviewerAssignmentHalfLifeDays",
scoring.reviewer_assignment_half_life_days,
),
("noteHalfLifeDays", scoring.note_half_life_days),
] {
if value == 0 || value > 3650 {
return Err(LoreError::ConfigInvalid {
details: format!("scoring.{field} must be > 0 and <= 3650"),
});
}
}
if !scoring.closed_mr_multiplier.is_finite()
|| scoring.closed_mr_multiplier <= 0.0
|| scoring.closed_mr_multiplier > 1.0
{
return Err(LoreError::ConfigInvalid {
details: "scoring.closedMrMultiplier must be finite and in (0.0, 1.0]".to_string(),
});
}
if scoring.reviewer_min_note_chars > 4096 {
return Err(LoreError::ConfigInvalid {
details: "scoring.reviewerMinNoteChars must be <= 4096".to_string(),
});
}
for username in &scoring.excluded_usernames {
if username.is_empty() {
return Err(LoreError::ConfigInvalid {
details: "scoring.excludedUsernames entries must be non-empty".to_string(),
});
}
}
Ok(())
}
@@ -543,6 +628,78 @@ mod tests {
);
}
#[test]
fn test_config_validation_rejects_zero_half_life() {
let mut cfg = ScoringConfig::default();
assert!(validate_scoring(&cfg).is_ok());
cfg.author_half_life_days = 0;
assert!(validate_scoring(&cfg).is_err());
cfg.author_half_life_days = 180;
cfg.reviewer_half_life_days = 0;
assert!(validate_scoring(&cfg).is_err());
cfg.reviewer_half_life_days = 90;
cfg.closed_mr_multiplier = 0.0;
assert!(validate_scoring(&cfg).is_err());
cfg.closed_mr_multiplier = 1.5;
assert!(validate_scoring(&cfg).is_err());
cfg.closed_mr_multiplier = 1.0;
assert!(validate_scoring(&cfg).is_ok());
}
#[test]
fn test_config_validation_rejects_absurd_half_life() {
let mut cfg = ScoringConfig::default();
cfg.author_half_life_days = 5000; // > 3650 cap
assert!(validate_scoring(&cfg).is_err());
cfg.author_half_life_days = 3650; // boundary: valid
assert!(validate_scoring(&cfg).is_ok());
cfg.reviewer_min_note_chars = 5000; // > 4096 cap
assert!(validate_scoring(&cfg).is_err());
cfg.reviewer_min_note_chars = 4096; // boundary: valid
assert!(validate_scoring(&cfg).is_ok());
}
#[test]
fn test_config_validation_rejects_nan_multiplier() {
let mut cfg = ScoringConfig::default();
cfg.closed_mr_multiplier = f64::NAN;
assert!(validate_scoring(&cfg).is_err());
cfg.closed_mr_multiplier = f64::INFINITY;
assert!(validate_scoring(&cfg).is_err());
cfg.closed_mr_multiplier = f64::NEG_INFINITY;
assert!(validate_scoring(&cfg).is_err());
}
#[test]
fn test_config_validation_rejects_negative_assignment_weight() {
let mut cfg = ScoringConfig::default();
cfg.reviewer_assignment_weight = -1;
assert!(validate_scoring(&cfg).is_err());
}
#[test]
fn test_config_validation_rejects_empty_excluded_username() {
let mut cfg = ScoringConfig::default();
cfg.excluded_usernames = vec!["".to_string()];
assert!(validate_scoring(&cfg).is_err());
}
#[test]
fn test_scoring_config_defaults() {
let cfg = ScoringConfig::default();
assert_eq!(cfg.author_weight, 25);
assert_eq!(cfg.reviewer_weight, 10);
assert_eq!(cfg.note_bonus, 1);
assert_eq!(cfg.reviewer_assignment_weight, 3);
assert_eq!(cfg.author_half_life_days, 180);
assert_eq!(cfg.reviewer_half_life_days, 90);
assert_eq!(cfg.reviewer_assignment_half_life_days, 45);
assert_eq!(cfg.note_half_life_days, 45);
assert!((cfg.closed_mr_multiplier - 0.5).abs() < f64::EPSILON);
assert_eq!(cfg.reviewer_min_note_chars, 20);
assert!(cfg.excluded_usernames.is_empty());
}
#[test]
fn test_minimal_config_includes_default_project_when_set() {
let config = MinimalConfig {