pub mod queries; pub mod render_human; pub mod render_robot; pub mod types; use std::collections::HashSet; use rusqlite::Connection; use crate::Config; use crate::cli::MeArgs; use crate::core::cursor; use crate::core::db::create_connection; use crate::core::error::{LoreError, Result}; use crate::core::paths::get_db_path; use crate::core::project::resolve_project; use crate::core::time::parse_since; use self::queries::{ query_activity, query_authored_mrs, query_mentioned_in, query_open_issues, query_reviewing_mrs, query_since_last_check, }; use self::types::{AttentionState, MeDashboard, MeSummary, SinceLastCheck}; /// Default activity lookback: 1 day in milliseconds. const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 1; const MS_PER_DAY: i64 = 24 * 60 * 60 * 1000; /// Recency window for closed/merged items in the "Mentioned In" section: 7 days. const RECENCY_WINDOW_MS: i64 = 7 * MS_PER_DAY; /// Only show mentions from notes created within this window (30 days). const MENTION_WINDOW_MS: i64 = 30 * MS_PER_DAY; /// Resolve the effective username from CLI flag or config. /// /// Precedence: `--user` flag > `config.gitlab.username` > error (AC-1.2). pub fn resolve_username<'a>(args: &'a MeArgs, config: &'a Config) -> Result<&'a str> { if let Some(ref user) = args.user { return Ok(user.as_str()); } if let Some(ref username) = config.gitlab.username { return Ok(username.as_str()); } Err(LoreError::ConfigInvalid { details: "No GitLab username configured. Set gitlab.username in config.json or pass --user .".to_string(), }) } /// Resolve the project scope for the dashboard. /// /// Returns a list of project IDs to filter by. An empty vec means "all projects". /// /// Precedence (AC-8): /// - `--project` and `--all` both set → error (AC-8.4, clap also enforces this) /// - `--all` → empty vec (all projects) /// - `--project` → resolve to single project ID via fuzzy match /// - config.default_project → resolve that /// - no default → empty vec (all projects) pub fn resolve_project_scope( conn: &Connection, args: &MeArgs, config: &Config, ) -> Result> { if args.all { return Ok(Vec::new()); } if let Some(ref project) = args.project { let id = resolve_project(conn, project)?; return Ok(vec![id]); } if let Some(ref dp) = config.default_project { let id = resolve_project(conn, dp)?; return Ok(vec![id]); } Ok(Vec::new()) } /// Run the `lore me` personal dashboard command. /// /// Orchestrates: username resolution → project scope → query execution → /// summary computation → dashboard assembly → rendering. pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> { let start = std::time::Instant::now(); let username = resolve_username(args, config)?; // 0. Handle --reset-cursor early return if args.reset_cursor { cursor::reset_cursor(username) .map_err(|e| LoreError::Other(format!("reset cursor: {e}")))?; let elapsed_ms = start.elapsed().as_millis() as u64; if robot_mode { render_robot::print_cursor_reset_json(elapsed_ms)?; } else { println!("Cursor reset for @{username}. Next `lore me` will establish a new baseline."); } return Ok(()); } // 1. Open DB let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; // 2. Check for synced data (AC-10.2) let has_data: bool = conn .query_row("SELECT EXISTS(SELECT 1 FROM projects LIMIT 1)", [], |row| { row.get(0) }) .unwrap_or(false); if !has_data { return Err(LoreError::NotFound( "No synced data found. Run `lore sync` first to fetch your GitLab data.".to_string(), )); } // 3. Resolve project scope let project_ids = resolve_project_scope(&conn, args, config)?; let single_project = project_ids.len() == 1; // 4. Parse --since (default 1d for activity feed) let since_ms = match args.since.as_deref() { Some(raw) => parse_since(raw).ok_or_else(|| { LoreError::Other(format!( "Invalid --since value '{raw}'. Expected: 7d, 2w, 3m, YYYY-MM-DD, or Unix-ms timestamp." )) })?, None => crate::core::time::now_ms() - DEFAULT_ACTIVITY_SINCE_DAYS * MS_PER_DAY, }; // 5. Determine which sections to query let show_all = args.show_all_sections(); let want_issues = show_all || args.issues; let want_mrs = show_all || args.mrs; let want_activity = show_all || args.activity; let want_mentions = show_all || args.mentions; // 6. Run queries for requested sections let open_issues = if want_issues { query_open_issues(&conn, username, &project_ids)? } else { Vec::new() }; let open_mrs_authored = if want_mrs { query_authored_mrs(&conn, username, &project_ids)? } else { Vec::new() }; let reviewing_mrs = if want_mrs { query_reviewing_mrs(&conn, username, &project_ids)? } else { Vec::new() }; let mentioned_in = if want_mentions { let recency_cutoff = crate::core::time::now_ms() - RECENCY_WINDOW_MS; let mention_cutoff = crate::core::time::now_ms() - MENTION_WINDOW_MS; query_mentioned_in( &conn, username, &project_ids, recency_cutoff, mention_cutoff, )? } else { Vec::new() }; let activity = if want_activity { query_activity(&conn, username, &project_ids, since_ms)? } else { Vec::new() }; // 6b. Since-last-check (cursor-based inbox) let cursor_ms = cursor::read_cursor(username); // Capture global watermark BEFORE project filtering so --project doesn't // permanently skip events from other projects. let mut global_watermark: Option = None; let since_last_check = if let Some(prev_cursor) = cursor_ms { let groups = query_since_last_check(&conn, username, prev_cursor)?; // Watermark from ALL groups (unfiltered) — this is the true high-water mark global_watermark = groups.iter().map(|g| g.latest_timestamp).max(); // If --project was passed, filter groups by project for display only let groups = if !project_ids.is_empty() { filter_groups_by_project_ids(&conn, &groups, &project_ids) } else { groups }; let total = groups.iter().map(|g| g.events.len()).sum(); Some(SinceLastCheck { cursor_ms: prev_cursor, groups, total_event_count: total, }) } else { None // First run — no section shown }; // 7. Compute summary let needs_attention_count = open_issues .iter() .filter(|i| i.attention_state == AttentionState::NeedsAttention) .count() + open_mrs_authored .iter() .filter(|m| m.attention_state == AttentionState::NeedsAttention) .count() + reviewing_mrs .iter() .filter(|m| m.attention_state == AttentionState::NeedsAttention) .count() + mentioned_in .iter() .filter(|m| m.attention_state == AttentionState::NeedsAttention) .count(); // Count distinct projects across all items let mut project_paths: HashSet<&str> = HashSet::new(); for i in &open_issues { project_paths.insert(&i.project_path); } for m in &open_mrs_authored { project_paths.insert(&m.project_path); } for m in &reviewing_mrs { project_paths.insert(&m.project_path); } for m in &mentioned_in { project_paths.insert(&m.project_path); } let summary = MeSummary { project_count: project_paths.len(), open_issue_count: open_issues.len(), authored_mr_count: open_mrs_authored.len(), reviewing_mr_count: reviewing_mrs.len(), mentioned_in_count: mentioned_in.len(), needs_attention_count, }; // 8. Assemble dashboard let dashboard = MeDashboard { username: username.to_string(), since_ms: Some(since_ms), summary, open_issues, open_mrs_authored, reviewing_mrs, mentioned_in, activity, since_last_check, }; // 9. Render let elapsed_ms = start.elapsed().as_millis() as u64; if robot_mode { let fields = args.fields.as_deref(); render_robot::print_me_json(&dashboard, elapsed_ms, fields, &config.gitlab.base_url)?; } else if show_all { render_human::print_me_dashboard(&dashboard, single_project); } else { render_human::print_me_dashboard_filtered( &dashboard, single_project, want_issues, want_mrs, want_mentions, want_activity, ); } // 10. Advance cursor AFTER successful render (watermark pattern) // Uses max event timestamp from UNFILTERED results so --project filtering // doesn't permanently skip events from other projects. let watermark = global_watermark.unwrap_or_else(crate::core::time::now_ms); cursor::write_cursor(username, watermark) .map_err(|e| LoreError::Other(format!("write cursor: {e}")))?; Ok(()) } /// Filter since-last-check groups to only those matching the given project IDs. /// Used when --project narrows the display scope (cursor is still global). fn filter_groups_by_project_ids( conn: &Connection, groups: &[types::SinceCheckGroup], project_ids: &[i64], ) -> Vec { // Resolve project IDs to paths for matching let paths: HashSet = project_ids .iter() .filter_map(|pid| { conn.query_row( "SELECT path_with_namespace FROM projects WHERE id = ?1", rusqlite::params![pid], |row| row.get::<_, String>(0), ) .ok() }) .collect(); groups .iter() .filter(|g| paths.contains(&g.project_path)) .cloned() .collect() } #[cfg(test)] mod tests { use super::*; use crate::core::config::{ EmbeddingConfig, GitLabConfig, LoggingConfig, ProjectConfig, ScoringConfig, StorageConfig, SyncConfig, }; use crate::core::db::{create_connection, run_migrations}; use std::path::Path; fn test_config(username: Option<&str>) -> Config { Config { gitlab: GitLabConfig { base_url: "https://gitlab.example.com".to_string(), token_env_var: "GITLAB_TOKEN".to_string(), token: None, username: username.map(String::from), }, projects: vec![ProjectConfig { path: "group/project".to_string(), }], default_project: None, sync: SyncConfig::default(), storage: StorageConfig::default(), embedding: EmbeddingConfig::default(), logging: LoggingConfig::default(), scoring: ScoringConfig::default(), } } fn test_args(user: Option<&str>) -> MeArgs { MeArgs { issues: false, mrs: false, activity: false, mentions: false, since: None, project: None, all: false, user: user.map(String::from), fields: None, reset_cursor: false, } } #[test] fn resolve_username_cli_flag_wins() { let config = test_config(Some("config-user")); let args = test_args(Some("cli-user")); let result = resolve_username(&args, &config).unwrap(); assert_eq!(result, "cli-user"); } #[test] fn resolve_username_falls_back_to_config() { let config = test_config(Some("config-user")); let args = test_args(None); let result = resolve_username(&args, &config).unwrap(); assert_eq!(result, "config-user"); } #[test] fn resolve_username_errors_when_both_absent() { let config = test_config(None); let args = test_args(None); let err = resolve_username(&args, &config).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("username"), "unexpected error: {msg}"); assert!(msg.contains("--user"), "should suggest --user flag: {msg}"); } fn test_config_with_default_project( username: Option<&str>, default_project: Option<&str>, ) -> Config { Config { gitlab: GitLabConfig { base_url: "https://gitlab.example.com".to_string(), token_env_var: "GITLAB_TOKEN".to_string(), token: None, username: username.map(String::from), }, projects: vec![ ProjectConfig { path: "group/project".to_string(), }, ProjectConfig { path: "other/repo".to_string(), }, ], default_project: default_project.map(String::from), sync: SyncConfig::default(), storage: StorageConfig::default(), embedding: EmbeddingConfig::default(), logging: LoggingConfig::default(), scoring: ScoringConfig::default(), } } fn setup_test_db() -> Connection { let conn = create_connection(Path::new(":memory:")).unwrap(); run_migrations(&conn).unwrap(); conn.execute( "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (1, 'group/project', 'https://gitlab.example.com/group/project')", [], ) .unwrap(); conn.execute( "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (2, 'other/repo', 'https://gitlab.example.com/other/repo')", [], ) .unwrap(); conn } #[test] fn resolve_project_scope_all_flag_returns_empty() { let conn = setup_test_db(); let config = test_config(Some("jdoe")); let mut args = test_args(None); args.all = true; let ids = resolve_project_scope(&conn, &args, &config).unwrap(); assert!(ids.is_empty(), "expected empty for --all, got {ids:?}"); } #[test] fn resolve_project_scope_project_flag_resolves() { let conn = setup_test_db(); let config = test_config(Some("jdoe")); let mut args = test_args(None); args.project = Some("group/project".to_string()); let ids = resolve_project_scope(&conn, &args, &config).unwrap(); assert_eq!(ids.len(), 1); } #[test] fn resolve_project_scope_default_project() { let conn = setup_test_db(); let config = test_config_with_default_project(Some("jdoe"), Some("other/repo")); let args = test_args(None); let ids = resolve_project_scope(&conn, &args, &config).unwrap(); assert_eq!(ids.len(), 1); } #[test] fn resolve_project_scope_no_default_returns_empty() { let conn = setup_test_db(); let config = test_config(Some("jdoe")); let args = test_args(None); let ids = resolve_project_scope(&conn, &args, &config).unwrap(); assert!(ids.is_empty(), "expected empty, got {ids:?}"); } #[test] fn resolve_project_scope_project_flag_fuzzy_match() { let conn = setup_test_db(); let config = test_config(Some("jdoe")); let mut args = test_args(None); args.project = Some("project".to_string()); let ids = resolve_project_scope(&conn, &args, &config).unwrap(); assert_eq!(ids.len(), 1); } #[test] fn resolve_project_scope_all_overrides_default_project() { let conn = setup_test_db(); let config = test_config_with_default_project(Some("jdoe"), Some("group/project")); let mut args = test_args(None); args.all = true; let ids = resolve_project_scope(&conn, &args, &config).unwrap(); assert!( ids.is_empty(), "expected --all to override default_project, got {ids:?}" ); } #[test] fn resolve_project_scope_project_flag_overrides_default() { let conn = setup_test_db(); let config = test_config_with_default_project(Some("jdoe"), Some("group/project")); let mut args = test_args(None); args.project = Some("other/repo".to_string()); let ids = resolve_project_scope(&conn, &args, &config).unwrap(); assert_eq!(ids.len(), 1, "expected --project to override default"); // Verify it resolved the explicit project, not the default let resolved_path: String = conn .query_row( "SELECT path_with_namespace FROM projects WHERE id = ?1", rusqlite::params![ids[0]], |row| row.get(0), ) .unwrap(); assert_eq!(resolved_path, "other/repo"); } #[test] fn resolve_project_scope_unknown_project_errors() { let conn = setup_test_db(); let config = test_config(Some("jdoe")); let mut args = test_args(None); args.project = Some("nonexistent/project".to_string()); let err = resolve_project_scope(&conn, &args, &config).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("not found"), "expected not found error: {msg}"); } #[test] fn show_all_sections_true_when_no_flags() { let args = test_args(None); assert!(args.show_all_sections()); } #[test] fn show_all_sections_false_with_issues_flag() { let mut args = test_args(None); args.issues = true; assert!(!args.show_all_sections()); } }