Compare commits
1 Commits
robot-meta
...
ba4ba9f508
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba4ba9f508 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ lore.config.json
|
|||||||
# Added by cargo
|
# Added by cargo
|
||||||
|
|
||||||
/target
|
/target
|
||||||
|
**/target/
|
||||||
|
|||||||
3168
crates/lore-tui/Cargo.lock
generated
Normal file
3168
crates/lore-tui/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
crates/lore-tui/Cargo.toml
Normal file
39
crates/lore-tui/Cargo.toml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
[package]
|
||||||
|
name = "lore-tui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Terminal UI for Gitlore — local GitLab data explorer"
|
||||||
|
authors = ["Taylor Eernisse"]
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "lore-tui"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# FrankenTUI (Elm-architecture TUI framework)
|
||||||
|
ftui = "0.1.1"
|
||||||
|
|
||||||
|
# Lore library (config, db, ingestion, search, etc.)
|
||||||
|
lore = { path = "../.." }
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
# Time
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
dirs = "6"
|
||||||
|
|
||||||
|
# Database (read-only queries from TUI)
|
||||||
|
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||||
|
|
||||||
|
# Terminal (crossterm for raw mode + event reading, used by ftui runtime)
|
||||||
|
crossterm = "0.28"
|
||||||
|
|
||||||
|
# Regex (used by safety module for PII/secret redaction)
|
||||||
|
regex = "1"
|
||||||
4
crates/lore-tui/rust-toolchain.toml
Normal file
4
crates/lore-tui/rust-toolchain.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "nightly-2026-02-08"
|
||||||
|
profile = "minimal"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
101
crates/lore-tui/src/app.rs
Normal file
101
crates/lore-tui/src/app.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#![allow(dead_code)] // Phase 0: minimal scaffold, fleshed out in bd-6pmy
|
||||||
|
|
||||||
|
//! Minimal 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.
|
||||||
|
|
||||||
|
use ftui::{Cmd, Frame, Model};
|
||||||
|
|
||||||
|
use crate::message::Msg;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LoreApp
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
impl Model for LoreApp {
|
||||||
|
type Message = Msg;
|
||||||
|
|
||||||
|
fn init(&mut self) -> Cmd<Self::Message> {
|
||||||
|
Cmd::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
|
||||||
|
match msg {
|
||||||
|
Msg::Quit => Cmd::quit(),
|
||||||
|
_ => Cmd::none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, _frame: &mut Frame) {
|
||||||
|
// Phase 0: no-op view. Phase 1 will render screens via the frame.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
#[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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that `App::inline(LoreApp, 12).run()` compiles.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn _assert_app_inline_compiles() {
|
||||||
|
fn _inner() {
|
||||||
|
use ftui::App;
|
||||||
|
let _app_builder = App::inline(LoreApp, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lore_app_init_returns_none() {
|
||||||
|
let mut app = LoreApp;
|
||||||
|
let cmd = app.init();
|
||||||
|
assert!(matches!(cmd, Cmd::None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lore_app_quit_returns_quit_cmd() {
|
||||||
|
let mut app = LoreApp;
|
||||||
|
let cmd = app.update(Msg::Quit);
|
||||||
|
assert!(matches!(cmd, Cmd::Quit));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lore_app_tick_returns_none() {
|
||||||
|
let mut app = LoreApp;
|
||||||
|
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));
|
||||||
|
assert!(matches!(cmd, Cmd::None));
|
||||||
|
}
|
||||||
|
}
|
||||||
151
crates/lore-tui/src/clock.rs
Normal file
151
crates/lore-tui/src/clock.rs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
//! Injected clock for deterministic time in tests and consistent frame timestamps.
|
||||||
|
//!
|
||||||
|
//! All relative-time rendering (e.g., "3h ago") uses [`Clock::now()`] rather
|
||||||
|
//! than wall-clock time directly. This enables:
|
||||||
|
//! - Deterministic snapshot tests via [`FakeClock`]
|
||||||
|
//! - Consistent timestamps within a single frame render pass
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
|
|
||||||
|
/// Trait for obtaining the current time.
|
||||||
|
///
|
||||||
|
/// Inject via `Arc<dyn Clock>` to allow swapping between real and fake clocks.
|
||||||
|
pub trait Clock: Send + Sync {
|
||||||
|
/// Returns the current time.
|
||||||
|
fn now(&self) -> DateTime<Utc>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SystemClock
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Real wall-clock time via `chrono::Utc::now()`.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct SystemClock;
|
||||||
|
|
||||||
|
impl Clock for SystemClock {
|
||||||
|
fn now(&self) -> DateTime<Utc> {
|
||||||
|
Utc::now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FakeClock
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A controllable clock for tests. Returns a frozen time that can be
|
||||||
|
/// advanced or set explicitly.
|
||||||
|
///
|
||||||
|
/// `FakeClock` is `Clone` (shares the inner `Arc`) and `Send + Sync`
|
||||||
|
/// for use across `Cmd::task` threads.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FakeClock {
|
||||||
|
inner: Arc<Mutex<DateTime<Utc>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeClock {
|
||||||
|
/// Create a fake clock frozen at the given time.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(time: DateTime<Utc>) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(Mutex::new(time)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance the clock by `duration`. Uses `checked_add` to handle overflow
|
||||||
|
/// gracefully — if the addition would overflow, the time is not changed.
|
||||||
|
pub fn advance(&self, duration: TimeDelta) {
|
||||||
|
let mut guard = self.inner.lock().expect("FakeClock mutex poisoned");
|
||||||
|
if let Some(advanced) = guard.checked_add_signed(duration) {
|
||||||
|
*guard = advanced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the clock to an exact time.
|
||||||
|
pub fn set(&self, time: DateTime<Utc>) {
|
||||||
|
let mut guard = self.inner.lock().expect("FakeClock mutex poisoned");
|
||||||
|
*guard = time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clock for FakeClock {
|
||||||
|
fn now(&self) -> DateTime<Utc> {
|
||||||
|
*self.inner.lock().expect("FakeClock mutex poisoned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::TimeZone;
|
||||||
|
|
||||||
|
fn fixed_time() -> DateTime<Utc> {
|
||||||
|
Utc.with_ymd_and_hms(2026, 2, 12, 12, 0, 0).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fake_clock_frozen() {
|
||||||
|
let clock = FakeClock::new(fixed_time());
|
||||||
|
let t1 = clock.now();
|
||||||
|
let t2 = clock.now();
|
||||||
|
assert_eq!(t1, t2);
|
||||||
|
assert_eq!(t1, fixed_time());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fake_clock_advance() {
|
||||||
|
let clock = FakeClock::new(fixed_time());
|
||||||
|
clock.advance(TimeDelta::hours(3));
|
||||||
|
let expected = Utc.with_ymd_and_hms(2026, 2, 12, 15, 0, 0).unwrap();
|
||||||
|
assert_eq!(clock.now(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fake_clock_set() {
|
||||||
|
let clock = FakeClock::new(fixed_time());
|
||||||
|
let new_time = Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap();
|
||||||
|
clock.set(new_time);
|
||||||
|
assert_eq!(clock.now(), new_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fake_clock_clone_shares_state() {
|
||||||
|
let clock1 = FakeClock::new(fixed_time());
|
||||||
|
let clock2 = clock1.clone();
|
||||||
|
clock1.advance(TimeDelta::minutes(30));
|
||||||
|
// Both clones see the advanced time.
|
||||||
|
assert_eq!(clock1.now(), clock2.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_system_clock_returns_reasonable_time() {
|
||||||
|
let clock = SystemClock;
|
||||||
|
let now = clock.now();
|
||||||
|
// Sanity: time should be after 2025.
|
||||||
|
assert!(now.year() >= 2025);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fake_clock_is_send_sync() {
|
||||||
|
fn assert_send_sync<T: Send + Sync>() {}
|
||||||
|
assert_send_sync::<FakeClock>();
|
||||||
|
assert_send_sync::<SystemClock>();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clock_trait_object_works() {
|
||||||
|
let fake: Arc<dyn Clock> = Arc::new(FakeClock::new(fixed_time()));
|
||||||
|
assert_eq!(fake.now(), fixed_time());
|
||||||
|
|
||||||
|
let real: Arc<dyn Clock> = Arc::new(SystemClock);
|
||||||
|
let _ = real.now(); // Just verify it doesn't panic.
|
||||||
|
}
|
||||||
|
|
||||||
|
use chrono::Datelike;
|
||||||
|
}
|
||||||
270
crates/lore-tui/src/db.rs
Normal file
270
crates/lore-tui/src/db.rs
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
#![allow(dead_code)] // Phase 0: types defined now, consumed in Phase 1+
|
||||||
|
|
||||||
|
//! Database access layer for the TUI.
|
||||||
|
//!
|
||||||
|
//! Provides a read pool (3 connections, round-robin) plus a dedicated writer
|
||||||
|
//! connection. All connections use WAL mode and busy_timeout for concurrency.
|
||||||
|
//!
|
||||||
|
//! The TUI operates read-heavy: parallel queries for dashboard, list views,
|
||||||
|
//! and prefetch. Writes are rare (TUI-local state: scroll positions, bookmarks).
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
/// Number of reader connections in the pool.
|
||||||
|
const READER_COUNT: usize = 3;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DbManager
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Manages a pool of read-only connections plus a dedicated writer.
|
||||||
|
///
|
||||||
|
/// Designed for `Arc<DbManager>` sharing across FrankenTUI's `Cmd::task`
|
||||||
|
/// background threads. Each reader is individually `Mutex`-protected so
|
||||||
|
/// concurrent tasks can query different readers without blocking.
|
||||||
|
pub struct DbManager {
|
||||||
|
readers: Vec<Mutex<Connection>>,
|
||||||
|
writer: Mutex<Connection>,
|
||||||
|
next_reader: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbManager {
|
||||||
|
/// Open a database at `path` with 3 reader + 1 writer connections.
|
||||||
|
///
|
||||||
|
/// All connections get WAL mode, 5000ms busy_timeout, and foreign keys.
|
||||||
|
/// Reader connections additionally set `query_only = ON` as a safety guard.
|
||||||
|
pub fn open(path: &Path) -> Result<Self> {
|
||||||
|
let mut readers = Vec::with_capacity(READER_COUNT);
|
||||||
|
for i in 0..READER_COUNT {
|
||||||
|
let conn =
|
||||||
|
open_connection(path).with_context(|| format!("opening reader connection {i}"))?;
|
||||||
|
conn.pragma_update(None, "query_only", "ON")
|
||||||
|
.context("setting query_only on reader")?;
|
||||||
|
readers.push(Mutex::new(conn));
|
||||||
|
}
|
||||||
|
|
||||||
|
let writer = open_connection(path).context("opening writer connection")?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
readers,
|
||||||
|
writer: Mutex::new(writer),
|
||||||
|
next_reader: AtomicUsize::new(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a read-only query against the pool.
|
||||||
|
///
|
||||||
|
/// Selects the next reader via round-robin. The connection is borrowed
|
||||||
|
/// for the duration of `f` and cannot leak outside.
|
||||||
|
pub fn with_reader<F, T>(&self, f: F) -> Result<T>
|
||||||
|
where
|
||||||
|
F: FnOnce(&Connection) -> Result<T>,
|
||||||
|
{
|
||||||
|
let idx = self.next_reader.fetch_add(1, Ordering::Relaxed) % READER_COUNT;
|
||||||
|
let conn = self.readers[idx].lock().expect("reader mutex poisoned");
|
||||||
|
f(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a write operation against the dedicated writer.
|
||||||
|
///
|
||||||
|
/// Serialized via a single `Mutex`. The TUI writes infrequently
|
||||||
|
/// (bookmarks, scroll state) so contention is negligible.
|
||||||
|
pub fn with_writer<F, T>(&self, f: F) -> Result<T>
|
||||||
|
where
|
||||||
|
F: FnOnce(&Connection) -> Result<T>,
|
||||||
|
{
|
||||||
|
let conn = self.writer.lock().expect("writer mutex poisoned");
|
||||||
|
f(&conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Connection setup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Open a single SQLite connection with TUI-appropriate pragmas.
|
||||||
|
///
|
||||||
|
/// Mirrors lore's `create_connection` pragmas (WAL, busy_timeout, etc.)
|
||||||
|
/// but skips the sqlite-vec extension registration — the TUI reads standard
|
||||||
|
/// tables only, never vec0 virtual tables.
|
||||||
|
fn open_connection(path: &Path) -> Result<Connection> {
|
||||||
|
let conn = Connection::open(path).context("opening SQLite database")?;
|
||||||
|
|
||||||
|
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||||
|
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||||
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
|
conn.pragma_update(None, "busy_timeout", 5000)?;
|
||||||
|
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||||
|
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Create a temporary database file for testing.
|
||||||
|
///
|
||||||
|
/// Uses an atomic counter + thread ID to guarantee unique paths even
|
||||||
|
/// when tests run in parallel.
|
||||||
|
fn test_db_path() -> std::path::PathBuf {
|
||||||
|
use std::sync::atomic::AtomicU64;
|
||||||
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let dir = std::env::temp_dir().join("lore-tui-tests");
|
||||||
|
std::fs::create_dir_all(&dir).expect("create test dir");
|
||||||
|
dir.join(format!(
|
||||||
|
"test-{}-{:?}-{n}.db",
|
||||||
|
std::process::id(),
|
||||||
|
std::thread::current().id(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_test_table(conn: &Connection) {
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS test_items (id INTEGER PRIMARY KEY, name TEXT);",
|
||||||
|
)
|
||||||
|
.expect("create test table");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dbmanager_opens_successfully() {
|
||||||
|
let path = test_db_path();
|
||||||
|
let db = DbManager::open(&path).expect("open");
|
||||||
|
// Writer creates the test table
|
||||||
|
db.with_writer(|conn| {
|
||||||
|
create_test_table(conn);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("create table via writer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reader_is_query_only() {
|
||||||
|
let path = test_db_path();
|
||||||
|
let db = DbManager::open(&path).expect("open");
|
||||||
|
|
||||||
|
// Create table via writer first
|
||||||
|
db.with_writer(|conn| {
|
||||||
|
create_test_table(conn);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Attempt INSERT via reader — should fail
|
||||||
|
let result = db.with_reader(|conn| {
|
||||||
|
conn.execute("INSERT INTO test_items (name) VALUES ('boom')", [])
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
assert!(result.is_err(), "reader should reject writes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_writer_allows_mutations() {
|
||||||
|
let path = test_db_path();
|
||||||
|
let db = DbManager::open(&path).expect("open");
|
||||||
|
|
||||||
|
db.with_writer(|conn| {
|
||||||
|
create_test_table(conn);
|
||||||
|
conn.execute("INSERT INTO test_items (name) VALUES ('hello')", [])?;
|
||||||
|
let count: i64 = conn.query_row("SELECT COUNT(*) FROM test_items", [], |r| r.get(0))?;
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("writer should allow mutations");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_round_robin_rotates_readers() {
|
||||||
|
let path = test_db_path();
|
||||||
|
let db = DbManager::open(&path).expect("open");
|
||||||
|
|
||||||
|
// Call with_reader 6 times — should cycle through readers 0,1,2,0,1,2
|
||||||
|
for expected_cycle in 0..2 {
|
||||||
|
for expected_idx in 0..READER_COUNT {
|
||||||
|
let current = db.next_reader.load(Ordering::Relaxed);
|
||||||
|
assert_eq!(
|
||||||
|
current % READER_COUNT,
|
||||||
|
(expected_cycle * READER_COUNT + expected_idx) % READER_COUNT,
|
||||||
|
);
|
||||||
|
db.with_reader(|_conn| Ok(())).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reader_can_read_writer_data() {
|
||||||
|
let path = test_db_path();
|
||||||
|
let db = DbManager::open(&path).expect("open");
|
||||||
|
|
||||||
|
db.with_writer(|conn| {
|
||||||
|
create_test_table(conn);
|
||||||
|
conn.execute("INSERT INTO test_items (name) VALUES ('visible')", [])?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let name: String = db
|
||||||
|
.with_reader(|conn| {
|
||||||
|
let n: String =
|
||||||
|
conn.query_row("SELECT name FROM test_items WHERE id = 1", [], |r| r.get(0))?;
|
||||||
|
Ok(n)
|
||||||
|
})
|
||||||
|
.expect("reader should see writer's data");
|
||||||
|
|
||||||
|
assert_eq!(name, "visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dbmanager_is_send_sync() {
|
||||||
|
fn assert_send_sync<T: Send + Sync>() {}
|
||||||
|
assert_send_sync::<DbManager>();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_concurrent_reads() {
|
||||||
|
let path = test_db_path();
|
||||||
|
let db = Arc::new(DbManager::open(&path).expect("open"));
|
||||||
|
|
||||||
|
db.with_writer(|conn| {
|
||||||
|
create_test_table(conn);
|
||||||
|
for i in 0..10 {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO test_items (name) VALUES (?1)",
|
||||||
|
[format!("item-{i}")],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for _ in 0..6 {
|
||||||
|
let db = Arc::clone(&db);
|
||||||
|
handles.push(std::thread::spawn(move || {
|
||||||
|
db.with_reader(|conn| {
|
||||||
|
let count: i64 =
|
||||||
|
conn.query_row("SELECT COUNT(*) FROM test_items", [], |r| r.get(0))?;
|
||||||
|
assert_eq!(count, 10);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("concurrent read should succeed");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
for h in handles {
|
||||||
|
h.join().expect("thread should not panic");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
crates/lore-tui/src/lib.rs
Normal file
58
crates/lore-tui/src/lib.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Gitlore TUI — terminal interface for exploring GitLab data locally.
|
||||||
|
//!
|
||||||
|
//! Built on FrankenTUI (Elm architecture): Model, update, view.
|
||||||
|
//! The `lore` CLI spawns `lore-tui` via PATH lookup at runtime.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
// Phase 0 modules.
|
||||||
|
pub mod clock; // Clock trait: SystemClock + FakeClock (bd-2lg6)
|
||||||
|
pub mod message; // Msg, Screen, EntityKey, AppError, InputMode (bd-c9gk)
|
||||||
|
|
||||||
|
pub mod safety; // Terminal safety: sanitize + URL policy + redact (bd-3ir1)
|
||||||
|
|
||||||
|
pub mod db; // DbManager: read pool + dedicated writer (bd-2kop)
|
||||||
|
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)
|
||||||
|
|
||||||
|
/// Options controlling how the TUI launches.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LaunchOptions {
|
||||||
|
/// Path to lore config file.
|
||||||
|
pub config_path: Option<String>,
|
||||||
|
/// Run a background sync before displaying data.
|
||||||
|
pub sync_on_start: bool,
|
||||||
|
/// Clear cached TUI state and start fresh.
|
||||||
|
pub fresh: bool,
|
||||||
|
/// Render backend: "crossterm" or "native".
|
||||||
|
pub render_mode: String,
|
||||||
|
/// Use ASCII-only box drawing characters.
|
||||||
|
pub ascii: bool,
|
||||||
|
/// Disable alternate screen (render inline).
|
||||||
|
pub no_alt_screen: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launch the TUI in browse mode (no sync).
|
||||||
|
///
|
||||||
|
/// Loads config from `options.config_path` (or default location),
|
||||||
|
/// opens the database read-only, and enters the FrankenTUI event loop.
|
||||||
|
pub fn launch_tui(options: LaunchOptions) -> Result<()> {
|
||||||
|
let _options = options;
|
||||||
|
// Phase 1 will wire this to LoreApp + App::fullscreen().run()
|
||||||
|
eprintln!("lore-tui: browse mode not yet implemented (Phase 1)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launch the TUI with an initial sync pass.
|
||||||
|
///
|
||||||
|
/// Runs `lore sync` in the background while displaying a progress screen,
|
||||||
|
/// then transitions to browse mode once sync completes.
|
||||||
|
pub fn launch_sync_tui(options: LaunchOptions) -> Result<()> {
|
||||||
|
let _options = options;
|
||||||
|
// Phase 2 will implement the sync progress screen
|
||||||
|
eprintln!("lore-tui: sync mode not yet implemented (Phase 2)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
53
crates/lore-tui/src/main.rs
Normal file
53
crates/lore-tui/src/main.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use lore_tui::LaunchOptions;
|
||||||
|
|
||||||
|
/// Terminal UI for Gitlore — explore GitLab issues, MRs, and search locally.
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "lore-tui", version, about)]
|
||||||
|
struct TuiCli {
|
||||||
|
/// Path to lore config file (default: ~/.config/lore/config.json).
|
||||||
|
#[arg(short, long, env = "LORE_CONFIG_PATH")]
|
||||||
|
config: Option<String>,
|
||||||
|
|
||||||
|
/// Run a sync before launching the TUI.
|
||||||
|
#[arg(long)]
|
||||||
|
sync: bool,
|
||||||
|
|
||||||
|
/// Clear cached state and start fresh.
|
||||||
|
#[arg(long)]
|
||||||
|
fresh: bool,
|
||||||
|
|
||||||
|
/// Render mode: "crossterm" (default) or "native".
|
||||||
|
#[arg(long, default_value = "crossterm")]
|
||||||
|
render_mode: String,
|
||||||
|
|
||||||
|
/// Use ASCII-only drawing characters (no Unicode box drawing).
|
||||||
|
#[arg(long)]
|
||||||
|
ascii: bool,
|
||||||
|
|
||||||
|
/// Disable alternate screen (render inline).
|
||||||
|
#[arg(long)]
|
||||||
|
no_alt_screen: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = TuiCli::parse();
|
||||||
|
|
||||||
|
let options = LaunchOptions {
|
||||||
|
config_path: cli.config,
|
||||||
|
sync_on_start: cli.sync,
|
||||||
|
fresh: cli.fresh,
|
||||||
|
render_mode: cli.render_mode,
|
||||||
|
ascii: cli.ascii,
|
||||||
|
no_alt_screen: cli.no_alt_screen,
|
||||||
|
};
|
||||||
|
|
||||||
|
if options.sync_on_start {
|
||||||
|
lore_tui::launch_sync_tui(options)
|
||||||
|
} else {
|
||||||
|
lore_tui::launch_tui(options)
|
||||||
|
}
|
||||||
|
}
|
||||||
523
crates/lore-tui/src/message.rs
Normal file
523
crates/lore-tui/src/message.rs
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
#![allow(dead_code)] // Phase 0: types defined now, consumed in Phase 1+
|
||||||
|
|
||||||
|
//! Core types for the lore-tui Elm architecture.
|
||||||
|
//!
|
||||||
|
//! - [`Msg`] — every user action and async result flows through this enum.
|
||||||
|
//! - [`Screen`] — navigation targets.
|
||||||
|
//! - [`EntityKey`] — safe cross-project entity identity.
|
||||||
|
//! - [`AppError`] — structured error display in the TUI.
|
||||||
|
//! - [`InputMode`] — controls key dispatch routing.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use ftui::Event;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EntityKind
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Distinguishes issue vs merge request in an [`EntityKey`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum EntityKind {
|
||||||
|
Issue,
|
||||||
|
MergeRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EntityKey
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Uniquely identifies an entity (issue or MR) across projects.
|
||||||
|
///
|
||||||
|
/// Bare `iid` is unsafe in multi-project datasets — equality requires
|
||||||
|
/// project_id + iid + kind.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct EntityKey {
|
||||||
|
pub project_id: i64,
|
||||||
|
pub iid: i64,
|
||||||
|
pub kind: EntityKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EntityKey {
|
||||||
|
#[must_use]
|
||||||
|
pub fn issue(project_id: i64, iid: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
project_id,
|
||||||
|
iid,
|
||||||
|
kind: EntityKind::Issue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn mr(project_id: i64, iid: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
project_id,
|
||||||
|
iid,
|
||||||
|
kind: EntityKind::MergeRequest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for EntityKey {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let prefix = match self.kind {
|
||||||
|
EntityKind::Issue => "#",
|
||||||
|
EntityKind::MergeRequest => "!",
|
||||||
|
};
|
||||||
|
write!(f, "p{}:{}{}", self.project_id, prefix, self.iid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Screen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Navigation targets within the TUI.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Screen {
|
||||||
|
Dashboard,
|
||||||
|
IssueList,
|
||||||
|
IssueDetail(EntityKey),
|
||||||
|
MrList,
|
||||||
|
MrDetail(EntityKey),
|
||||||
|
Search,
|
||||||
|
Timeline,
|
||||||
|
Who,
|
||||||
|
Sync,
|
||||||
|
Stats,
|
||||||
|
Doctor,
|
||||||
|
Bootstrap,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Screen {
|
||||||
|
/// Human-readable label for breadcrumbs and status bar.
|
||||||
|
#[must_use]
|
||||||
|
pub fn label(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Dashboard => "Dashboard",
|
||||||
|
Self::IssueList => "Issues",
|
||||||
|
Self::IssueDetail(_) => "Issue",
|
||||||
|
Self::MrList => "Merge Requests",
|
||||||
|
Self::MrDetail(_) => "Merge Request",
|
||||||
|
Self::Search => "Search",
|
||||||
|
Self::Timeline => "Timeline",
|
||||||
|
Self::Who => "Who",
|
||||||
|
Self::Sync => "Sync",
|
||||||
|
Self::Stats => "Stats",
|
||||||
|
Self::Doctor => "Doctor",
|
||||||
|
Self::Bootstrap => "Bootstrap",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this screen shows a specific entity detail view.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_detail_or_entity(&self) -> bool {
|
||||||
|
matches!(self, Self::IssueDetail(_) | Self::MrDetail(_))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AppError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Structured error types for user-facing display in the TUI.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum AppError {
|
||||||
|
/// Database is busy (WAL contention).
|
||||||
|
DbBusy,
|
||||||
|
/// Database corruption detected.
|
||||||
|
DbCorruption(String),
|
||||||
|
/// GitLab rate-limited; retry after N seconds (if header present).
|
||||||
|
NetworkRateLimited { retry_after_secs: Option<u64> },
|
||||||
|
/// Network unavailable.
|
||||||
|
NetworkUnavailable,
|
||||||
|
/// GitLab authentication failed.
|
||||||
|
AuthFailed,
|
||||||
|
/// Data parsing error.
|
||||||
|
ParseError(String),
|
||||||
|
/// Internal / unexpected error.
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for AppError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::DbBusy => write!(f, "Database is busy — another process holds the lock"),
|
||||||
|
Self::DbCorruption(detail) => write!(f, "Database corruption: {detail}"),
|
||||||
|
Self::NetworkRateLimited {
|
||||||
|
retry_after_secs: Some(secs),
|
||||||
|
} => write!(f, "Rate limited by GitLab — retry in {secs}s"),
|
||||||
|
Self::NetworkRateLimited {
|
||||||
|
retry_after_secs: None,
|
||||||
|
} => write!(f, "Rate limited by GitLab — try again shortly"),
|
||||||
|
Self::NetworkUnavailable => write!(f, "Network unavailable — working offline"),
|
||||||
|
Self::AuthFailed => write!(f, "GitLab authentication failed — check your token"),
|
||||||
|
Self::ParseError(detail) => write!(f, "Parse error: {detail}"),
|
||||||
|
Self::Internal(detail) => write!(f, "Internal error: {detail}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// InputMode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Controls how keystrokes are routed through the key dispatch pipeline.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub enum InputMode {
|
||||||
|
/// Standard navigation mode — keys dispatch to screen-specific handlers.
|
||||||
|
#[default]
|
||||||
|
Normal,
|
||||||
|
/// Text input focused (filter bar, search box).
|
||||||
|
Text,
|
||||||
|
/// Command palette is open.
|
||||||
|
Palette,
|
||||||
|
/// "g" prefix pressed — waiting for second key (500ms timeout).
|
||||||
|
GoPrefix { started_at: Instant },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Msg
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Every user action and async result flows through this enum.
|
||||||
|
///
|
||||||
|
/// Generation fields (`generation: u64`) on async result variants enable
|
||||||
|
/// stale-response detection: if the generation doesn't match the current
|
||||||
|
/// request generation, the result is silently dropped.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Msg {
|
||||||
|
// --- Terminal events ---
|
||||||
|
/// Raw terminal event (key, mouse, paste, focus, clipboard).
|
||||||
|
RawEvent(Event),
|
||||||
|
/// Periodic tick from runtime subscription.
|
||||||
|
Tick,
|
||||||
|
/// Terminal resized.
|
||||||
|
Resize {
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Navigation ---
|
||||||
|
/// Navigate to a specific screen.
|
||||||
|
NavigateTo(Screen),
|
||||||
|
/// Go back in navigation history.
|
||||||
|
GoBack,
|
||||||
|
/// Go forward in navigation history.
|
||||||
|
GoForward,
|
||||||
|
/// Jump to the dashboard.
|
||||||
|
GoHome,
|
||||||
|
/// Jump back N screens in history.
|
||||||
|
JumpBack(usize),
|
||||||
|
/// Jump forward N screens in history.
|
||||||
|
JumpForward(usize),
|
||||||
|
|
||||||
|
// --- Command palette ---
|
||||||
|
OpenCommandPalette,
|
||||||
|
CloseCommandPalette,
|
||||||
|
CommandPaletteInput(String),
|
||||||
|
CommandPaletteSelect(String),
|
||||||
|
|
||||||
|
// --- Issue list ---
|
||||||
|
IssueListLoaded {
|
||||||
|
generation: u64,
|
||||||
|
rows: Vec<IssueRow>,
|
||||||
|
},
|
||||||
|
IssueListFilterChanged(String),
|
||||||
|
IssueListSortChanged,
|
||||||
|
IssueSelected(EntityKey),
|
||||||
|
|
||||||
|
// --- MR list ---
|
||||||
|
MrListLoaded {
|
||||||
|
generation: u64,
|
||||||
|
rows: Vec<MrRow>,
|
||||||
|
},
|
||||||
|
MrListFilterChanged(String),
|
||||||
|
MrSelected(EntityKey),
|
||||||
|
|
||||||
|
// --- Issue detail ---
|
||||||
|
IssueDetailLoaded {
|
||||||
|
generation: u64,
|
||||||
|
key: EntityKey,
|
||||||
|
detail: Box<IssueDetail>,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- MR detail ---
|
||||||
|
MrDetailLoaded {
|
||||||
|
generation: u64,
|
||||||
|
key: EntityKey,
|
||||||
|
detail: Box<MrDetail>,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Discussions (shared by issue + MR detail) ---
|
||||||
|
DiscussionsLoaded {
|
||||||
|
generation: u64,
|
||||||
|
discussions: Vec<Discussion>,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Search ---
|
||||||
|
SearchQueryChanged(String),
|
||||||
|
SearchRequestStarted {
|
||||||
|
generation: u64,
|
||||||
|
query: String,
|
||||||
|
},
|
||||||
|
SearchExecuted {
|
||||||
|
generation: u64,
|
||||||
|
results: Vec<SearchResult>,
|
||||||
|
},
|
||||||
|
SearchResultSelected(EntityKey),
|
||||||
|
SearchModeChanged,
|
||||||
|
SearchCapabilitiesLoaded,
|
||||||
|
|
||||||
|
// --- Timeline ---
|
||||||
|
TimelineLoaded {
|
||||||
|
generation: u64,
|
||||||
|
events: Vec<TimelineEvent>,
|
||||||
|
},
|
||||||
|
TimelineEntitySelected(EntityKey),
|
||||||
|
|
||||||
|
// --- Who (people) ---
|
||||||
|
WhoResultLoaded {
|
||||||
|
generation: u64,
|
||||||
|
result: Box<WhoResult>,
|
||||||
|
},
|
||||||
|
WhoModeChanged,
|
||||||
|
|
||||||
|
// --- Sync ---
|
||||||
|
SyncStarted,
|
||||||
|
SyncProgress {
|
||||||
|
stage: String,
|
||||||
|
current: u64,
|
||||||
|
total: u64,
|
||||||
|
},
|
||||||
|
SyncProgressBatch {
|
||||||
|
stage: String,
|
||||||
|
batch_size: u64,
|
||||||
|
},
|
||||||
|
SyncLogLine(String),
|
||||||
|
SyncBackpressureDrop,
|
||||||
|
SyncCompleted {
|
||||||
|
elapsed_ms: u64,
|
||||||
|
},
|
||||||
|
SyncCancelled,
|
||||||
|
SyncFailed(String),
|
||||||
|
SyncStreamStats {
|
||||||
|
bytes: u64,
|
||||||
|
items: u64,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Search debounce ---
|
||||||
|
SearchDebounceArmed {
|
||||||
|
generation: u64,
|
||||||
|
},
|
||||||
|
SearchDebounceFired {
|
||||||
|
generation: u64,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Dashboard ---
|
||||||
|
DashboardLoaded {
|
||||||
|
generation: u64,
|
||||||
|
data: Box<DashboardData>,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Global actions ---
|
||||||
|
Error(AppError),
|
||||||
|
ShowHelp,
|
||||||
|
ShowCliEquivalent,
|
||||||
|
OpenInBrowser,
|
||||||
|
BlurTextInput,
|
||||||
|
ScrollToTopCurrentScreen,
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert terminal events into messages.
|
||||||
|
///
|
||||||
|
/// FrankenTUI requires `From<Event>` on the message type so the runtime
|
||||||
|
/// can inject terminal events into the model's update loop.
|
||||||
|
impl From<Event> for Msg {
|
||||||
|
fn from(event: Event) -> Self {
|
||||||
|
match event {
|
||||||
|
Event::Resize { width, height } => Self::Resize { width, height },
|
||||||
|
Event::Tick => Self::Tick,
|
||||||
|
other => Self::RawEvent(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Placeholder data types (will be fleshed out in Phase 1+)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Placeholder for an issue row in list views.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IssueRow {
|
||||||
|
pub key: EntityKey,
|
||||||
|
pub title: String,
|
||||||
|
pub state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder for a merge request row in list views.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MrRow {
|
||||||
|
pub key: EntityKey,
|
||||||
|
pub title: String,
|
||||||
|
pub state: String,
|
||||||
|
pub draft: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder for issue detail payload.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IssueDetail {
|
||||||
|
pub key: EntityKey,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder for MR detail payload.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MrDetail {
|
||||||
|
pub key: EntityKey,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder for a discussion thread.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Discussion {
|
||||||
|
pub id: String,
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder for a search result.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SearchResult {
|
||||||
|
pub key: EntityKey,
|
||||||
|
pub title: String,
|
||||||
|
pub score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder for a timeline event.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TimelineEvent {
|
||||||
|
pub timestamp: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder for who/people intelligence result.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WhoResult {
|
||||||
|
pub experts: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder for dashboard summary data.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DashboardData {
|
||||||
|
pub issue_count: u64,
|
||||||
|
pub mr_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_entity_key_equality() {
|
||||||
|
assert_eq!(EntityKey::issue(1, 42), EntityKey::issue(1, 42));
|
||||||
|
assert_ne!(EntityKey::issue(1, 42), EntityKey::mr(1, 42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_entity_key_different_projects() {
|
||||||
|
assert_ne!(EntityKey::issue(1, 42), EntityKey::issue(2, 42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_entity_key_display() {
|
||||||
|
assert_eq!(EntityKey::issue(5, 123).to_string(), "p5:#123");
|
||||||
|
assert_eq!(EntityKey::mr(5, 456).to_string(), "p5:!456");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_entity_key_hash_is_usable_in_collections() {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let mut set = HashSet::new();
|
||||||
|
set.insert(EntityKey::issue(1, 1));
|
||||||
|
set.insert(EntityKey::issue(1, 1)); // duplicate
|
||||||
|
set.insert(EntityKey::mr(1, 1));
|
||||||
|
assert_eq!(set.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_screen_labels() {
|
||||||
|
assert_eq!(Screen::Dashboard.label(), "Dashboard");
|
||||||
|
assert_eq!(Screen::IssueList.label(), "Issues");
|
||||||
|
assert_eq!(Screen::MrList.label(), "Merge Requests");
|
||||||
|
assert_eq!(Screen::Search.label(), "Search");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_screen_is_detail_or_entity() {
|
||||||
|
assert!(Screen::IssueDetail(EntityKey::issue(1, 1)).is_detail_or_entity());
|
||||||
|
assert!(Screen::MrDetail(EntityKey::mr(1, 1)).is_detail_or_entity());
|
||||||
|
assert!(!Screen::Dashboard.is_detail_or_entity());
|
||||||
|
assert!(!Screen::IssueList.is_detail_or_entity());
|
||||||
|
assert!(!Screen::Search.is_detail_or_entity());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_app_error_display() {
|
||||||
|
let err = AppError::DbBusy;
|
||||||
|
assert!(err.to_string().contains("busy"));
|
||||||
|
|
||||||
|
let err = AppError::NetworkRateLimited {
|
||||||
|
retry_after_secs: Some(30),
|
||||||
|
};
|
||||||
|
assert!(err.to_string().contains("30s"));
|
||||||
|
|
||||||
|
let err = AppError::NetworkRateLimited {
|
||||||
|
retry_after_secs: None,
|
||||||
|
};
|
||||||
|
assert!(err.to_string().contains("shortly"));
|
||||||
|
|
||||||
|
let err = AppError::AuthFailed;
|
||||||
|
assert!(err.to_string().contains("token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_input_mode_default_is_normal() {
|
||||||
|
assert!(matches!(InputMode::default(), InputMode::Normal));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_msg_from_event_resize() {
|
||||||
|
let event = Event::Resize {
|
||||||
|
width: 80,
|
||||||
|
height: 24,
|
||||||
|
};
|
||||||
|
let msg = Msg::from(event);
|
||||||
|
assert!(matches!(
|
||||||
|
msg,
|
||||||
|
Msg::Resize {
|
||||||
|
width: 80,
|
||||||
|
height: 24
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_msg_from_event_tick() {
|
||||||
|
let msg = Msg::from(Event::Tick);
|
||||||
|
assert!(matches!(msg, Msg::Tick));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_msg_from_event_focus_wraps_raw() {
|
||||||
|
let msg = Msg::from(Event::Focus(true));
|
||||||
|
assert!(matches!(msg, Msg::RawEvent(Event::Focus(true))));
|
||||||
|
}
|
||||||
|
}
|
||||||
587
crates/lore-tui/src/safety.rs
Normal file
587
crates/lore-tui/src/safety.rs
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
//! Terminal safety: sanitize untrusted text, URL policy, credential redaction.
|
||||||
|
//!
|
||||||
|
//! GitLab content can contain ANSI escapes, bidi overrides, OSC hyperlinks,
|
||||||
|
//! and C1 control codes that could corrupt terminal rendering. This module
|
||||||
|
//! strips dangerous sequences while preserving a safe SGR subset for readability.
|
||||||
|
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UrlPolicy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Controls how OSC 8 hyperlinks in input are handled.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum UrlPolicy {
|
||||||
|
/// Remove OSC 8 hyperlinks entirely, keeping only the link text.
|
||||||
|
#[default]
|
||||||
|
Strip,
|
||||||
|
/// Convert hyperlinks to numbered footnotes: `text [1]` with URL list appended.
|
||||||
|
Footnote,
|
||||||
|
/// Pass hyperlinks through unchanged (only for trusted content).
|
||||||
|
Passthrough,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RedactPattern
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Common patterns for PII/secret redaction.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RedactPattern {
|
||||||
|
patterns: Vec<regex::Regex>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RedactPattern {
|
||||||
|
/// Create a default set of redaction patterns (tokens, emails, etc.).
|
||||||
|
#[must_use]
|
||||||
|
pub fn defaults() -> Self {
|
||||||
|
let patterns = vec![
|
||||||
|
// GitLab personal access tokens
|
||||||
|
regex::Regex::new(r"glpat-[A-Za-z0-9_\-]{20,}").expect("valid regex"),
|
||||||
|
// Generic bearer/API tokens (long hex or base64-ish strings after common prefixes)
|
||||||
|
regex::Regex::new(r"(?i)(token|bearer|api[_-]?key)[\s:=]+\S{8,}").expect("valid regex"),
|
||||||
|
// Email addresses
|
||||||
|
regex::Regex::new(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")
|
||||||
|
.expect("valid regex"),
|
||||||
|
];
|
||||||
|
Self { patterns }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply all redaction patterns to the input string.
|
||||||
|
#[must_use]
|
||||||
|
pub fn redact(&self, input: &str) -> String {
|
||||||
|
let mut result = input.to_string();
|
||||||
|
for pattern in &self.patterns {
|
||||||
|
result = pattern.replace_all(&result, "[REDACTED]").into_owned();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// sanitize_for_terminal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Sanitize untrusted text for safe terminal display.
|
||||||
|
///
|
||||||
|
/// - Strips C1 control codes (0x80-0x9F)
|
||||||
|
/// - Strips OSC sequences (ESC ] ... ST)
|
||||||
|
/// - Strips cursor movement CSI sequences (CSI n A/B/C/D/E/F/G/H/J/K)
|
||||||
|
/// - Strips bidi overrides (U+202A-U+202E, U+2066-U+2069)
|
||||||
|
/// - Preserves safe SGR subset (bold, italic, underline, reset, standard colors)
|
||||||
|
///
|
||||||
|
/// `url_policy` controls handling of OSC 8 hyperlinks.
|
||||||
|
#[must_use]
|
||||||
|
pub fn sanitize_for_terminal(input: &str, url_policy: UrlPolicy) -> String {
|
||||||
|
let mut output = String::with_capacity(input.len());
|
||||||
|
let mut footnotes: Vec<String> = Vec::new();
|
||||||
|
let chars: Vec<char> = input.chars().collect();
|
||||||
|
let len = chars.len();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < len {
|
||||||
|
let ch = chars[i];
|
||||||
|
|
||||||
|
// --- Bidi overrides ---
|
||||||
|
if is_bidi_override(ch) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C1 control codes (U+0080-U+009F) ---
|
||||||
|
if ('\u{0080}'..='\u{009F}').contains(&ch) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C0 control codes except tab, newline, carriage return ---
|
||||||
|
if ch.is_ascii_control() && ch != '\t' && ch != '\n' && ch != '\r' && ch != '\x1B' {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ESC sequences ---
|
||||||
|
if ch == '\x1B' {
|
||||||
|
if i + 1 < len {
|
||||||
|
match chars[i + 1] {
|
||||||
|
// CSI sequence: ESC [
|
||||||
|
'[' => {
|
||||||
|
let (consumed, safe_seq) = parse_csi(&chars, i);
|
||||||
|
if let Some(seq) = safe_seq {
|
||||||
|
output.push_str(&seq);
|
||||||
|
}
|
||||||
|
i += consumed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// OSC sequence: ESC ]
|
||||||
|
']' => {
|
||||||
|
let (consumed, link_text, link_url) = parse_osc(&chars, i);
|
||||||
|
match url_policy {
|
||||||
|
UrlPolicy::Strip => {
|
||||||
|
if let Some(text) = link_text {
|
||||||
|
output.push_str(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UrlPolicy::Footnote => {
|
||||||
|
if let (Some(text), Some(url)) = (link_text, link_url) {
|
||||||
|
footnotes.push(url);
|
||||||
|
let _ = write!(output, "{text} [{n}]", n = footnotes.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UrlPolicy::Passthrough => {
|
||||||
|
// Reproduce the raw OSC sequence
|
||||||
|
for &ch_raw in &chars[i..len.min(i + consumed)] {
|
||||||
|
output.push(ch_raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += consumed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Unknown ESC sequence — skip ESC + next char
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Trailing ESC at end of input
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Normal character ---
|
||||||
|
output.push(ch);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append footnotes if any
|
||||||
|
if !footnotes.is_empty() {
|
||||||
|
output.push('\n');
|
||||||
|
for (idx, url) in footnotes.iter().enumerate() {
|
||||||
|
let _ = write!(output, "\n[{}] {url}", idx + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bidi check
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn is_bidi_override(ch: char) -> bool {
|
||||||
|
matches!(
|
||||||
|
ch,
|
||||||
|
'\u{202A}' // LRE
|
||||||
|
| '\u{202B}' // RLE
|
||||||
|
| '\u{202C}' // PDF
|
||||||
|
| '\u{202D}' // LRO
|
||||||
|
| '\u{202E}' // RLO
|
||||||
|
| '\u{2066}' // LRI
|
||||||
|
| '\u{2067}' // RLI
|
||||||
|
| '\u{2068}' // FSI
|
||||||
|
| '\u{2069}' // PDI
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CSI parser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Parse a CSI sequence starting at `chars[start]` (which should be ESC).
|
||||||
|
///
|
||||||
|
/// Returns `(chars_consumed, Option<safe_sequence_string>)`.
|
||||||
|
/// If the CSI is a safe SGR, returns the full sequence string to preserve.
|
||||||
|
/// Otherwise returns None (strip it).
|
||||||
|
fn parse_csi(chars: &[char], start: usize) -> (usize, Option<String>) {
|
||||||
|
// Minimum: ESC [ <final_byte>
|
||||||
|
debug_assert!(chars[start] == '\x1B');
|
||||||
|
debug_assert!(start + 1 < chars.len() && chars[start + 1] == '[');
|
||||||
|
|
||||||
|
let mut i = start + 2; // skip ESC [
|
||||||
|
let len = chars.len();
|
||||||
|
|
||||||
|
// Collect parameter bytes (0x30-0x3F) and intermediate bytes (0x20-0x2F)
|
||||||
|
let param_start = i;
|
||||||
|
while i < len && (chars[i] as u32) >= 0x20 && (chars[i] as u32) <= 0x3F {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect intermediate bytes
|
||||||
|
while i < len && (chars[i] as u32) >= 0x20 && (chars[i] as u32) <= 0x2F {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final byte (0x40-0x7E)
|
||||||
|
if i >= len || (chars[i] as u32) < 0x40 || (chars[i] as u32) > 0x7E {
|
||||||
|
// Malformed — consume what we've seen and strip
|
||||||
|
return (i.saturating_sub(start).max(2), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let final_byte = chars[i];
|
||||||
|
let consumed = i + 1 - start;
|
||||||
|
|
||||||
|
// Only preserve SGR sequences (final byte 'm')
|
||||||
|
if final_byte == 'm' {
|
||||||
|
let param_str: String = chars[param_start..i].iter().collect();
|
||||||
|
if is_safe_sgr(¶m_str) {
|
||||||
|
let full_seq: String = chars[start..start + consumed].iter().collect();
|
||||||
|
return (consumed, Some(full_seq));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything else (cursor movement A-H, erase J/K, etc.) is stripped
|
||||||
|
(consumed, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if all SGR parameters in a sequence are in the safe subset.
|
||||||
|
///
|
||||||
|
/// Safe: 0 (reset), 1 (bold), 3 (italic), 4 (underline), 22 (normal intensity),
|
||||||
|
/// 23 (not italic), 24 (not underline), 39 (default fg), 49 (default bg),
|
||||||
|
/// 30-37 (standard fg), 40-47 (standard bg), 90-97 (bright fg), 100-107 (bright bg).
|
||||||
|
fn is_safe_sgr(params: &str) -> bool {
|
||||||
|
if params.is_empty() {
|
||||||
|
return true; // ESC[m is reset
|
||||||
|
}
|
||||||
|
|
||||||
|
for param in params.split(';') {
|
||||||
|
let param = param.trim();
|
||||||
|
if param.is_empty() {
|
||||||
|
continue; // treat empty as 0
|
||||||
|
}
|
||||||
|
let Ok(n) = param.parse::<u32>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !is_safe_sgr_code(n) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_safe_sgr_code(n: u32) -> bool {
|
||||||
|
matches!(
|
||||||
|
n,
|
||||||
|
0 // reset
|
||||||
|
| 1 // bold
|
||||||
|
| 3 // italic
|
||||||
|
| 4 // underline
|
||||||
|
| 22 // normal intensity (turn off bold)
|
||||||
|
| 23 // not italic
|
||||||
|
| 24 // not underline
|
||||||
|
| 39 // default foreground
|
||||||
|
| 49 // default background
|
||||||
|
| 30..=37 // standard foreground colors
|
||||||
|
| 40..=47 // standard background colors
|
||||||
|
| 90..=97 // bright foreground colors
|
||||||
|
| 100..=107 // bright background colors
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OSC parser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Parse an OSC sequence starting at `chars[start]` (ESC ]).
|
||||||
|
///
|
||||||
|
/// Returns `(chars_consumed, link_text, link_url)`.
|
||||||
|
/// For OSC 8 hyperlinks: `ESC ] 8 ; params ; url ST text ESC ] 8 ; ; ST`
|
||||||
|
/// For other OSC: consumed without extracting link data.
|
||||||
|
fn parse_osc(chars: &[char], start: usize) -> (usize, Option<String>, Option<String>) {
|
||||||
|
debug_assert!(chars[start] == '\x1B');
|
||||||
|
debug_assert!(start + 1 < chars.len() && chars[start + 1] == ']');
|
||||||
|
|
||||||
|
let len = chars.len();
|
||||||
|
let i = start + 2; // skip ESC ]
|
||||||
|
|
||||||
|
// Find ST (String Terminator): ESC \ or BEL (0x07)
|
||||||
|
let osc_end = find_st(chars, i);
|
||||||
|
|
||||||
|
// Check if this is OSC 8 (hyperlink)
|
||||||
|
if i < len && chars[i] == '8' && i + 1 < len && chars[i + 1] == ';' {
|
||||||
|
// OSC 8 hyperlink: ESC ] 8 ; params ; url ST ... ESC ] 8 ; ; ST
|
||||||
|
let osc_content: String = chars[i..osc_end.0].iter().collect();
|
||||||
|
let first_consumed = osc_end.1;
|
||||||
|
|
||||||
|
// Extract URL from "8;params;url"
|
||||||
|
let url = extract_osc8_url(&osc_content);
|
||||||
|
|
||||||
|
// Now find the link text (between first ST and second OSC 8)
|
||||||
|
let after_first_st = start + 2 + first_consumed;
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut j = after_first_st;
|
||||||
|
|
||||||
|
// Collect text until we hit the closing OSC 8 or end of input
|
||||||
|
while j < len {
|
||||||
|
if j + 1 < len && chars[j] == '\x1B' && chars[j + 1] == ']' {
|
||||||
|
// Found another OSC — this should be the closing OSC 8
|
||||||
|
let close_end = find_st(chars, j + 2);
|
||||||
|
return (
|
||||||
|
j + close_end.1 - start + 2,
|
||||||
|
Some(text),
|
||||||
|
url.map(String::from),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
text.push(chars[j]);
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reached end without closing OSC 8
|
||||||
|
return (j - start, Some(text), url.map(String::from));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-OSC-8: just consume and strip
|
||||||
|
(osc_end.1 + (start + 2 - start), None, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the String Terminator (ST) for an OSC sequence.
|
||||||
|
/// ST is either ESC \ (two chars) or BEL (0x07).
|
||||||
|
/// Returns (content_end_index, total_consumed_from_content_start).
|
||||||
|
fn find_st(chars: &[char], from: usize) -> (usize, usize) {
|
||||||
|
let len = chars.len();
|
||||||
|
let mut i = from;
|
||||||
|
while i < len {
|
||||||
|
if chars[i] == '\x07' {
|
||||||
|
return (i, i - from + 1);
|
||||||
|
}
|
||||||
|
if i + 1 < len && chars[i] == '\x1B' && chars[i + 1] == '\\' {
|
||||||
|
return (i, i - from + 2);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
// Unterminated — consume everything
|
||||||
|
(len, len - from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract URL from OSC 8 content "8;params;url".
|
||||||
|
fn extract_osc8_url(content: &str) -> Option<&str> {
|
||||||
|
// Format: "8;params;url"
|
||||||
|
let rest = content.strip_prefix("8;")?;
|
||||||
|
// Skip params (up to next ;)
|
||||||
|
let url_start = rest.find(';')? + 1;
|
||||||
|
let url = &rest[url_start..];
|
||||||
|
if url.is_empty() { None } else { Some(url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// --- CSI / cursor movement ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strips_cursor_movement() {
|
||||||
|
// CSI 5A = cursor up 5
|
||||||
|
let input = "before\x1B[5Aafter";
|
||||||
|
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||||
|
assert_eq!(result, "beforeafter");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strips_cursor_movement_all_directions() {
|
||||||
|
for dir in ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'] {
|
||||||
|
let input = format!("x\x1B[3{dir}y");
|
||||||
|
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
|
||||||
|
assert_eq!(result, "xy", "failed for direction {dir}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strips_erase_sequences() {
|
||||||
|
// CSI 2J = erase display
|
||||||
|
let input = "before\x1B[2Jafter";
|
||||||
|
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||||
|
assert_eq!(result, "beforeafter");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SGR preservation ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_preserves_bold_italic_underline_reset() {
|
||||||
|
let input = "\x1B[1mbold\x1B[0m \x1B[3mitalic\x1B[0m \x1B[4munderline\x1B[0m";
|
||||||
|
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||||
|
assert_eq!(result, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_preserves_standard_colors() {
|
||||||
|
// Red foreground, green background
|
||||||
|
let input = "\x1B[31mred\x1B[42m on green\x1B[0m";
|
||||||
|
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||||
|
assert_eq!(result, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_preserves_bright_colors() {
|
||||||
|
let input = "\x1B[91mbright red\x1B[0m";
|
||||||
|
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||||
|
assert_eq!(result, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_preserves_combined_safe_sgr() {
|
||||||
|
// Bold + red foreground in one sequence
|
||||||
|
let input = "\x1B[1;31mbold red\x1B[0m";
|
||||||
|
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||||
|
assert_eq!(result, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strips_unsafe_sgr() {
|
||||||
|
// SGR 8 = hidden text (not in safe list)
|
||||||
|
let input = "\x1B[8mhidden\x1B[0m";
|
||||||
|
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||||
|
// SGR 8 stripped, SGR 0 preserved
|
||||||
|
assert_eq!(result, "hidden\x1B[0m");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C1 control codes ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strips_c1_control_codes() {
|
||||||
|
// U+008D = Reverse Index, U+009B = CSI (8-bit)
|
||||||
|
let input = format!("before{}middle{}after", '\u{008D}', '\u{009B}');
|
||||||
|
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
|
||||||
|
assert_eq!(result, "beforemiddleafter");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bidi overrides ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strips_bidi_overrides() {
|
||||||
|
let input = format!(
|
||||||
|
"normal{}reversed{}end",
|
||||||
|
'\u{202E}', // RLO
|
||||||
|
'\u{202C}' // PDF
|
||||||
|
);
|
||||||
|
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
|
||||||
|
assert_eq!(result, "normalreversedend");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strips_all_bidi_chars() {
|
||||||
|
let bidi_chars = [
|
||||||
|
'\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}',
|
||||||
|
'\u{2068}', '\u{2069}',
|
||||||
|
];
|
||||||
|
for ch in bidi_chars {
|
||||||
|
let input = format!("a{ch}b");
|
||||||
|
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
|
||||||
|
assert_eq!(result, "ab", "failed for U+{:04X}", ch as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OSC sequences ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strips_osc_sequences() {
|
||||||
|
// OSC 0 (set title): ESC ] 0 ; title BEL
|
||||||
|
let input = "before\x1B]0;My Title\x07after";
|
||||||
|
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||||
|
assert_eq!(result, "beforeafter");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OSC 8 hyperlinks ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_policy_strip() {
|
||||||
|
// OSC 8 hyperlink: ESC]8;;url ST text ESC]8;; ST
|
||||||
|
let input = "click \x1B]8;;https://example.com\x07here\x1B]8;;\x07 done";
|
||||||
|
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||||
|
assert_eq!(result, "click here done");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_policy_footnote() {
|
||||||
|
let input = "click \x1B]8;;https://example.com\x07here\x1B]8;;\x07 done";
|
||||||
|
let result = sanitize_for_terminal(input, UrlPolicy::Footnote);
|
||||||
|
assert!(result.contains("here [1]"));
|
||||||
|
assert!(result.contains("[1] https://example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Redaction ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_gitlab_token() {
|
||||||
|
let redactor = RedactPattern::defaults();
|
||||||
|
let input = "My token is glpat-AbCdEfGhIjKlMnOpQrStUvWx";
|
||||||
|
let result = redactor.redact(input);
|
||||||
|
assert_eq!(result, "My token is [REDACTED]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_email() {
|
||||||
|
let redactor = RedactPattern::defaults();
|
||||||
|
let input = "Contact user@example.com for details";
|
||||||
|
let result = redactor.redact(input);
|
||||||
|
assert_eq!(result, "Contact [REDACTED] for details");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_bearer_token() {
|
||||||
|
let redactor = RedactPattern::defaults();
|
||||||
|
let input = "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI";
|
||||||
|
let result = redactor.redact(input);
|
||||||
|
assert!(result.contains("[REDACTED]"));
|
||||||
|
assert!(!result.contains("eyJ"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Edge cases ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_input() {
|
||||||
|
assert_eq!(sanitize_for_terminal("", UrlPolicy::Strip), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_safe_content_passthrough() {
|
||||||
|
let input = "Hello, world! This is normal text.\nWith newlines\tand tabs.";
|
||||||
|
assert_eq!(sanitize_for_terminal(input, UrlPolicy::Strip), input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trailing_esc() {
|
||||||
|
let input = "text\x1B";
|
||||||
|
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||||
|
assert_eq!(result, "text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_malformed_csi_does_not_eat_text() {
|
||||||
|
// ESC [ without a valid final byte before next printable
|
||||||
|
let input = "a\x1B[b";
|
||||||
|
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||||
|
// The malformed CSI is consumed but shouldn't eat "b" as text
|
||||||
|
// ESC[ is start, 'b' is final byte (0x62 is in 0x40-0x7E range)
|
||||||
|
// So this is CSI with final byte 'b' (cursor back) — gets stripped
|
||||||
|
assert_eq!(result, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_utf8_adjacent_to_escapes() {
|
||||||
|
let input = "\x1B[1m日本語\x1B[0m text";
|
||||||
|
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||||
|
assert_eq!(result, "\x1B[1m日本語\x1B[0m text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fuzz_no_panic() {
|
||||||
|
// 1000 random-ish byte sequences — must not panic
|
||||||
|
for seed in 0u16..1000 {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
for j in 0..50 {
|
||||||
|
bytes.push(((seed.wrapping_mul(31).wrapping_add(j)) & 0xFF) as u8);
|
||||||
|
}
|
||||||
|
// Best-effort UTF-8
|
||||||
|
let input = String::from_utf8_lossy(&bytes);
|
||||||
|
let _ = sanitize_for_terminal(&input, UrlPolicy::Strip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
251
crates/lore-tui/src/theme.rs
Normal file
251
crates/lore-tui/src/theme.rs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
#![allow(dead_code)] // Phase 0: types defined now, consumed in Phase 1+
|
||||||
|
|
||||||
|
//! Flexoki-based theme for the lore TUI.
|
||||||
|
//!
|
||||||
|
//! Uses FrankenTUI's `AdaptiveColor::adaptive(light, dark)` for automatic
|
||||||
|
//! light/dark mode switching. The palette is [Flexoki](https://stephango.com/flexoki)
|
||||||
|
//! by Steph Ango, designed in Oklab perceptual color space for balanced contrast.
|
||||||
|
|
||||||
|
use ftui::{AdaptiveColor, Color, PackedRgba, Style, Theme};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Flexoki palette constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Base tones
|
||||||
|
const PAPER: Color = Color::rgb(0xFF, 0xFC, 0xF0);
|
||||||
|
const BASE_50: Color = Color::rgb(0xF2, 0xF0, 0xE5);
|
||||||
|
const BASE_100: Color = Color::rgb(0xE6, 0xE4, 0xD9);
|
||||||
|
const BASE_200: Color = Color::rgb(0xCE, 0xCD, 0xC3);
|
||||||
|
const BASE_300: Color = Color::rgb(0xB7, 0xB5, 0xAC);
|
||||||
|
const BASE_400: Color = Color::rgb(0x9F, 0x9D, 0x96);
|
||||||
|
const BASE_500: Color = Color::rgb(0x87, 0x85, 0x80);
|
||||||
|
const BASE_600: Color = Color::rgb(0x6F, 0x6E, 0x69);
|
||||||
|
const BASE_700: Color = Color::rgb(0x57, 0x56, 0x53);
|
||||||
|
const BASE_800: Color = Color::rgb(0x40, 0x3E, 0x3C);
|
||||||
|
const BASE_850: Color = Color::rgb(0x34, 0x33, 0x31);
|
||||||
|
const BASE_900: Color = Color::rgb(0x28, 0x27, 0x26);
|
||||||
|
const BLACK: Color = Color::rgb(0x10, 0x0F, 0x0F);
|
||||||
|
|
||||||
|
// Accent colors — light-600 (for light mode)
|
||||||
|
const RED_600: Color = Color::rgb(0xAF, 0x30, 0x29);
|
||||||
|
const ORANGE_600: Color = Color::rgb(0xBC, 0x52, 0x15);
|
||||||
|
const YELLOW_600: Color = Color::rgb(0xAD, 0x83, 0x01);
|
||||||
|
const GREEN_600: Color = Color::rgb(0x66, 0x80, 0x0B);
|
||||||
|
const CYAN_600: Color = Color::rgb(0x24, 0x83, 0x7B);
|
||||||
|
const BLUE_600: Color = Color::rgb(0x20, 0x5E, 0xA6);
|
||||||
|
const PURPLE_600: Color = Color::rgb(0x5E, 0x40, 0x9D);
|
||||||
|
|
||||||
|
// Accent colors — dark-400 (for dark mode)
|
||||||
|
const RED_400: Color = Color::rgb(0xD1, 0x4D, 0x41);
|
||||||
|
const ORANGE_400: Color = Color::rgb(0xDA, 0x70, 0x2C);
|
||||||
|
const YELLOW_400: Color = Color::rgb(0xD0, 0xA2, 0x15);
|
||||||
|
const GREEN_400: Color = Color::rgb(0x87, 0x9A, 0x39);
|
||||||
|
const CYAN_400: Color = Color::rgb(0x3A, 0xA9, 0x9F);
|
||||||
|
const BLUE_400: Color = Color::rgb(0x43, 0x85, 0xBE);
|
||||||
|
const PURPLE_400: Color = Color::rgb(0x8B, 0x7E, 0xC8);
|
||||||
|
const MAGENTA_400: Color = Color::rgb(0xCE, 0x5D, 0x97);
|
||||||
|
|
||||||
|
// Muted fallback as PackedRgba (for Style::fg)
|
||||||
|
const MUTED_PACKED: PackedRgba = PackedRgba::rgb(0x87, 0x85, 0x80);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// build_theme
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Build the lore TUI theme with Flexoki adaptive colors.
|
||||||
|
///
|
||||||
|
/// Each of the 19 semantic slots gets an `AdaptiveColor::adaptive(light, dark)`
|
||||||
|
/// pair. FrankenTUI detects the terminal background and resolves accordingly.
|
||||||
|
#[must_use]
|
||||||
|
pub fn build_theme() -> Theme {
|
||||||
|
Theme::builder()
|
||||||
|
.primary(AdaptiveColor::adaptive(BLUE_600, BLUE_400))
|
||||||
|
.secondary(AdaptiveColor::adaptive(CYAN_600, CYAN_400))
|
||||||
|
.accent(AdaptiveColor::adaptive(PURPLE_600, PURPLE_400))
|
||||||
|
.background(AdaptiveColor::adaptive(PAPER, BLACK))
|
||||||
|
.surface(AdaptiveColor::adaptive(BASE_50, BASE_900))
|
||||||
|
.overlay(AdaptiveColor::adaptive(BASE_100, BASE_850))
|
||||||
|
.text(AdaptiveColor::adaptive(BASE_700, BASE_200))
|
||||||
|
.text_muted(AdaptiveColor::adaptive(BASE_500, BASE_500))
|
||||||
|
.text_subtle(AdaptiveColor::adaptive(BASE_400, BASE_600))
|
||||||
|
.success(AdaptiveColor::adaptive(GREEN_600, GREEN_400))
|
||||||
|
.warning(AdaptiveColor::adaptive(YELLOW_600, YELLOW_400))
|
||||||
|
.error(AdaptiveColor::adaptive(RED_600, RED_400))
|
||||||
|
.info(AdaptiveColor::adaptive(BLUE_600, BLUE_400))
|
||||||
|
.border(AdaptiveColor::adaptive(BASE_300, BASE_700))
|
||||||
|
.border_focused(AdaptiveColor::adaptive(BLUE_600, BLUE_400))
|
||||||
|
.selection_bg(AdaptiveColor::adaptive(BASE_100, BASE_800))
|
||||||
|
.selection_fg(AdaptiveColor::adaptive(BASE_700, BASE_100))
|
||||||
|
.scrollbar_track(AdaptiveColor::adaptive(BASE_50, BASE_900))
|
||||||
|
.scrollbar_thumb(AdaptiveColor::adaptive(BASE_300, BASE_700))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State colors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Map a GitLab entity state to a display color.
|
||||||
|
///
|
||||||
|
/// Returns fixed (non-adaptive) colors — state indicators should be
|
||||||
|
/// consistent regardless of light/dark mode.
|
||||||
|
#[must_use]
|
||||||
|
pub fn state_color(state: &str) -> Color {
|
||||||
|
match state {
|
||||||
|
"opened" => GREEN_400,
|
||||||
|
"closed" => RED_400,
|
||||||
|
"merged" => PURPLE_400,
|
||||||
|
"locked" => YELLOW_400,
|
||||||
|
_ => BASE_500,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event type colors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Map a timeline event type to a display color.
|
||||||
|
#[must_use]
|
||||||
|
pub fn event_color(event_type: &str) -> Color {
|
||||||
|
match event_type {
|
||||||
|
"created" => GREEN_400,
|
||||||
|
"updated" => BLUE_400,
|
||||||
|
"closed" => RED_400,
|
||||||
|
"merged" => PURPLE_400,
|
||||||
|
"commented" => CYAN_400,
|
||||||
|
"labeled" => ORANGE_400,
|
||||||
|
"milestoned" => YELLOW_400,
|
||||||
|
_ => BASE_500,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Label styling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Convert a GitLab label hex color (e.g., "#FF0000" or "FF0000") to a Style.
|
||||||
|
///
|
||||||
|
/// Falls back to muted text color if the hex string is invalid.
|
||||||
|
#[must_use]
|
||||||
|
pub fn label_style(hex_color: &str) -> Style {
|
||||||
|
let packed = parse_hex_to_packed(hex_color).unwrap_or(MUTED_PACKED);
|
||||||
|
Style::default().fg(packed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a hex color string like "#RRGGBB" or "RRGGBB" into a `PackedRgba`.
|
||||||
|
fn parse_hex_to_packed(s: &str) -> Option<PackedRgba> {
|
||||||
|
let hex = s.strip_prefix('#').unwrap_or(s);
|
||||||
|
if hex.len() != 6 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||||
|
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||||
|
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||||
|
Some(PackedRgba::rgb(r, g, b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_theme_compiles() {
|
||||||
|
let theme = build_theme();
|
||||||
|
// Resolve for dark mode — primary should be Blue-400
|
||||||
|
let resolved = theme.resolve(true);
|
||||||
|
assert_eq!(resolved.primary, BLUE_400);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_theme_light_mode() {
|
||||||
|
let theme = build_theme();
|
||||||
|
let resolved = theme.resolve(false);
|
||||||
|
assert_eq!(resolved.primary, BLUE_600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_theme_all_slots_differ_between_modes() {
|
||||||
|
let theme = build_theme();
|
||||||
|
let dark = theme.resolve(true);
|
||||||
|
let light = theme.resolve(false);
|
||||||
|
// Background should differ (Paper vs Black)
|
||||||
|
assert_ne!(dark.background, light.background);
|
||||||
|
// Text should differ
|
||||||
|
assert_ne!(dark.text, light.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_color_opened_is_green() {
|
||||||
|
assert_eq!(state_color("opened"), GREEN_400);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_color_closed_is_red() {
|
||||||
|
assert_eq!(state_color("closed"), RED_400);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_color_merged_is_purple() {
|
||||||
|
assert_eq!(state_color("merged"), PURPLE_400);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_color_unknown_returns_muted() {
|
||||||
|
assert_eq!(state_color("unknown"), BASE_500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_event_color_created_is_green() {
|
||||||
|
assert_eq!(event_color("created"), GREEN_400);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_event_color_unknown_returns_muted() {
|
||||||
|
assert_eq!(event_color("whatever"), BASE_500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_label_style_valid_hex_with_hash() {
|
||||||
|
let style = label_style("#FF0000");
|
||||||
|
assert_eq!(style.fg, Some(PackedRgba::rgb(0xFF, 0x00, 0x00)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_label_style_valid_hex_without_hash() {
|
||||||
|
let style = label_style("00FF00");
|
||||||
|
assert_eq!(style.fg, Some(PackedRgba::rgb(0x00, 0xFF, 0x00)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_label_style_lowercase_hex() {
|
||||||
|
let style = label_style("#ff0000");
|
||||||
|
assert_eq!(style.fg, Some(PackedRgba::rgb(0xFF, 0x00, 0x00)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_label_style_invalid_hex_fallback() {
|
||||||
|
let style = label_style("invalid");
|
||||||
|
assert_eq!(style.fg, Some(MUTED_PACKED));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_label_style_empty_fallback() {
|
||||||
|
let style = label_style("");
|
||||||
|
assert_eq!(style.fg, Some(MUTED_PACKED));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_hex_short_string() {
|
||||||
|
assert!(parse_hex_to_packed("#FFF").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_hex_non_hex_chars() {
|
||||||
|
assert!(parse_hex_to_packed("#GGHHII").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user