#![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 chrono::{DateTime, Utc}; 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, Hash)] 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 }, /// 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: DateTime }, } // --------------------------------------------------------------------------- // 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, }, IssueListFilterChanged(String), IssueListSortChanged, IssueSelected(EntityKey), // --- MR list --- MrListLoaded { generation: u64, rows: Vec, }, MrListFilterChanged(String), MrSelected(EntityKey), // --- Issue detail --- IssueDetailLoaded { generation: u64, key: EntityKey, detail: Box, }, // --- MR detail --- MrDetailLoaded { generation: u64, key: EntityKey, detail: Box, }, // --- Discussions (shared by issue + MR detail) --- DiscussionsLoaded { generation: u64, discussions: Vec, }, // --- Search --- SearchQueryChanged(String), SearchRequestStarted { generation: u64, query: String, }, SearchExecuted { generation: u64, results: Vec, }, SearchResultSelected(EntityKey), SearchModeChanged, SearchCapabilitiesLoaded, // --- Timeline --- TimelineLoaded { generation: u64, events: Vec, }, TimelineEntitySelected(EntityKey), // --- Who (people) --- WhoResultLoaded { generation: u64, result: Box, }, 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, }, // --- Global actions --- Error(AppError), ShowHelp, ShowCliEquivalent, OpenInBrowser, BlurTextInput, ScrollToTopCurrentScreen, Quit, } /// Convert terminal events into messages. /// /// FrankenTUI requires `From` on the message type so the runtime /// can inject terminal events into the model's update loop. impl From 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, } /// 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, } /// 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)))); } }