mod active; mod expert; mod overlap; mod reviews; pub mod types; mod workload; pub use types::*; // Re-export submodule functions for tests (tests use `use super::*`). #[cfg(test)] use active::query_active; #[cfg(test)] use expert::{build_expert_sql_v2, half_life_decay, query_expert}; #[cfg(test)] use overlap::{format_overlap_role, query_overlap}; #[cfg(test)] use reviews::{normalize_review_prefix, query_reviews}; #[cfg(test)] use workload::query_workload; use rusqlite::Connection; use serde::Serialize; use crate::Config; use crate::cli::WhoArgs; use crate::cli::render::Theme; use crate::cli::robot::RobotMeta; use crate::core::db::create_connection; use crate::core::error::{LoreError, Result}; use crate::core::path_resolver::normalize_repo_path; use crate::core::paths::get_db_path; use crate::core::project::resolve_project; use crate::core::time::{ms_to_iso, now_ms, parse_since, parse_since_from}; #[cfg(test)] use crate::core::config::ScoringConfig; #[cfg(test)] use crate::core::path_resolver::{SuffixResult, build_path_query, escape_like, suffix_probe}; // ─── Mode Discrimination ──────────────────────────────────────────────────── /// Determines which query mode to run based on args. /// Path variants own their strings because path normalization produces new `String`s. /// Username variants borrow from args since no normalization is needed. enum WhoMode<'a> { /// lore who OR lore who --path Expert { path: String }, /// lore who Workload { username: &'a str }, /// lore who --reviews Reviews { username: &'a str }, /// lore who --active Active, /// lore who --overlap Overlap { path: String }, } fn resolve_mode<'a>(args: &'a WhoArgs) -> Result> { // Explicit --path flag always wins (handles root files like README.md, // LICENSE, Makefile -- anything without a / that can't be auto-detected) if let Some(p) = &args.path { return Ok(WhoMode::Expert { path: normalize_repo_path(p), }); } if args.active { return Ok(WhoMode::Active); } if let Some(path) = &args.overlap { return Ok(WhoMode::Overlap { path: normalize_repo_path(path), }); } if let Some(target) = &args.target { let clean = target.strip_prefix('@').unwrap_or(target); if args.reviews { return Ok(WhoMode::Reviews { username: clean }); } // Disambiguation: if target contains '/', it's a file path. // GitLab usernames never contain '/'. // Root files (no '/') require --path. if clean.contains('/') { return Ok(WhoMode::Expert { path: normalize_repo_path(clean), }); } return Ok(WhoMode::Workload { username: clean }); } Err(LoreError::Other( "Provide a username, file path, --active, or --overlap .\n\n\ Examples:\n \ lore who src/features/auth/\n \ lore who @username\n \ lore who --active\n \ lore who --overlap src/features/\n \ lore who --path README.md\n \ lore who --path Makefile" .to_string(), )) } fn validate_mode_flags(mode: &WhoMode<'_>, args: &WhoArgs) -> Result<()> { if args.detail && !matches!(mode, WhoMode::Expert { .. }) { return Err(LoreError::Other( "--detail is only supported in expert mode (`lore who --path ` or `lore who `).".to_string(), )); } Ok(()) } // ─── Entry Point ───────────────────────────────────────────────────────────── /// Main entry point. Resolves mode + resolved inputs once, then dispatches. pub fn run_who(config: &Config, args: &WhoArgs) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; let project_id = args .project .as_deref() .map(|p| resolve_project(&conn, p)) .transpose()?; let project_path = project_id .map(|id| lookup_project_path(&conn, id)) .transpose()?; let mode = resolve_mode(args)?; validate_mode_flags(&mode, args)?; // since_mode semantics: // - expert/reviews/active/overlap: default window applies if args.since is None -> "default" // - workload: no default window; args.since None => "none" let since_mode_for_defaulted = if args.since.is_some() { "explicit" } else { "default" }; let since_mode_for_workload = if args.since.is_some() { "explicit" } else { "none" }; let limit = args.limit.map_or(usize::MAX, usize::from); match mode { WhoMode::Expert { path } => { // Compute as_of first so --since durations are relative to it. let as_of_ms = match &args.as_of { Some(v) => parse_since(v).ok_or_else(|| { LoreError::Other(format!( "Invalid --as-of value: '{v}'. Use a duration (30d, 6m) or date (2024-01-15)" )) })?, None => now_ms(), }; let since_ms = if args.all_history { 0 } else { resolve_since_from(args.since.as_deref(), "24m", as_of_ms)? }; let result = expert::query_expert( &conn, &path, project_id, since_ms, as_of_ms, limit, &config.scoring, args.detail, args.explain_score, args.include_bots, )?; Ok(WhoRun { resolved_input: WhoResolvedInput { mode: "expert".to_string(), project_id, project_path, since_ms: Some(since_ms), since_iso: Some(ms_to_iso(since_ms)), since_mode: since_mode_for_defaulted.to_string(), limit: args.limit, }, result: WhoResult::Expert(result), }) } WhoMode::Workload { username } => { let since_ms = args .since .as_deref() .map(resolve_since_required) .transpose()?; let result = workload::query_workload( &conn, username, project_id, since_ms, limit, args.include_closed, )?; Ok(WhoRun { resolved_input: WhoResolvedInput { mode: "workload".to_string(), project_id, project_path, since_ms, since_iso: since_ms.map(ms_to_iso), since_mode: since_mode_for_workload.to_string(), limit: args.limit, }, result: WhoResult::Workload(result), }) } WhoMode::Reviews { username } => { let since_ms = resolve_since(args.since.as_deref(), "6m")?; let result = reviews::query_reviews(&conn, username, project_id, since_ms)?; Ok(WhoRun { resolved_input: WhoResolvedInput { mode: "reviews".to_string(), project_id, project_path, since_ms: Some(since_ms), since_iso: Some(ms_to_iso(since_ms)), since_mode: since_mode_for_defaulted.to_string(), limit: args.limit, }, result: WhoResult::Reviews(result), }) } WhoMode::Active => { let since_ms = resolve_since(args.since.as_deref(), "7d")?; let result = active::query_active(&conn, project_id, since_ms, limit, args.include_closed)?; Ok(WhoRun { resolved_input: WhoResolvedInput { mode: "active".to_string(), project_id, project_path, since_ms: Some(since_ms), since_iso: Some(ms_to_iso(since_ms)), since_mode: since_mode_for_defaulted.to_string(), limit: args.limit, }, result: WhoResult::Active(result), }) } WhoMode::Overlap { path } => { let since_ms = resolve_since(args.since.as_deref(), "30d")?; let result = overlap::query_overlap(&conn, &path, project_id, since_ms, limit)?; Ok(WhoRun { resolved_input: WhoResolvedInput { mode: "overlap".to_string(), project_id, project_path, since_ms: Some(since_ms), since_iso: Some(ms_to_iso(since_ms)), since_mode: since_mode_for_defaulted.to_string(), limit: args.limit, }, result: WhoResult::Overlap(result), }) } } } // ─── Helpers ───────────────────────────────────────────────────────────────── /// Look up the project path for a resolved project ID. fn lookup_project_path(conn: &Connection, project_id: i64) -> Result { conn.query_row( "SELECT path_with_namespace FROM projects WHERE id = ?1", rusqlite::params![project_id], |row| row.get(0), ) .map_err(|e| LoreError::Other(format!("Failed to look up project path: {e}"))) } /// Parse --since with a default fallback. fn resolve_since(input: Option<&str>, default: &str) -> Result { let s = input.unwrap_or(default); parse_since(s).ok_or_else(|| { LoreError::Other(format!( "Invalid --since value: '{s}'. Use a duration (7d, 2w, 6m) or date (2024-01-15)" )) }) } /// Parse --since with a default fallback, relative to a reference timestamp. /// Durations (7d, 2w, 6m) are computed from `reference_ms` instead of now. fn resolve_since_from(input: Option<&str>, default: &str, reference_ms: i64) -> Result { let s = input.unwrap_or(default); parse_since_from(s, reference_ms).ok_or_else(|| { LoreError::Other(format!( "Invalid --since value: '{s}'. Use a duration (7d, 2w, 6m) or date (2024-01-15)" )) }) } /// Parse --since without a default (returns error if invalid). fn resolve_since_required(input: &str) -> Result { parse_since(input).ok_or_else(|| { LoreError::Other(format!( "Invalid --since value: '{input}'. Use a duration (7d, 2w, 6m) or date (2024-01-15)" )) }) } // ─── Human Output ──────────────────────────────────────────────────────────── pub fn print_who_human(result: &WhoResult, project_path: Option<&str>) { match result { WhoResult::Expert(r) => expert::print_expert_human(r, project_path), WhoResult::Workload(r) => workload::print_workload_human(r), WhoResult::Reviews(r) => reviews::print_reviews_human(r), WhoResult::Active(r) => active::print_active_human(r, project_path), WhoResult::Overlap(r) => overlap::print_overlap_human(r, project_path), } } /// Print a dim hint when results aggregate across all projects. pub(super) fn print_scope_hint(project_path: Option<&str>) { if project_path.is_none() { println!( " {}", Theme::dim().render("(aggregated across all projects; use -p to scope)") ); } } // ─── Robot JSON Output ─────────────────────────────────────────────────────── pub fn print_who_json(run: &WhoRun, args: &WhoArgs, elapsed_ms: u64) { let (mode, data) = match &run.result { WhoResult::Expert(r) => ("expert", expert::expert_to_json(r)), WhoResult::Workload(r) => ("workload", workload::workload_to_json(r)), WhoResult::Reviews(r) => ("reviews", reviews::reviews_to_json(r)), WhoResult::Active(r) => ("active", active::active_to_json(r)), WhoResult::Overlap(r) => ("overlap", overlap::overlap_to_json(r)), }; // Raw CLI args -- what the user typed let input = serde_json::json!({ "target": args.target, "path": args.path, "project": args.project, "since": args.since, "limit": args.limit, "detail": args.detail, "as_of": args.as_of, "explain_score": args.explain_score, "include_bots": args.include_bots, "all_history": args.all_history, }); // Resolved/computed values -- what actually ran let resolved_input = serde_json::json!({ "mode": run.resolved_input.mode, "project_id": run.resolved_input.project_id, "project_path": run.resolved_input.project_path, "since_ms": run.resolved_input.since_ms, "since_iso": run.resolved_input.since_iso, "since_mode": run.resolved_input.since_mode, "limit": run.resolved_input.limit, }); let output = WhoJsonEnvelope { ok: true, data: WhoJsonData { mode: mode.to_string(), input, resolved_input, result: data, }, meta: RobotMeta { elapsed_ms }, }; let mut value = serde_json::to_value(&output).unwrap_or_else(|e| { serde_json::json!({"ok":false,"error":{"code":"INTERNAL_ERROR","message":format!("JSON serialization failed: {e}")}}) }); if let Some(f) = &args.fields { let preset_key = format!("who_{mode}"); let expanded = crate::cli::robot::expand_fields_preset(f, &preset_key); // Each who mode uses a different array key; try all possible keys for key in &[ "experts", "assigned_issues", "authored_mrs", "review_mrs", "categories", "discussions", "users", ] { crate::cli::robot::filter_fields(&mut value, key, &expanded); } } match serde_json::to_string(&value) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } } #[derive(Serialize)] struct WhoJsonEnvelope { ok: bool, data: WhoJsonData, meta: RobotMeta, } #[derive(Serialize)] struct WhoJsonData { mode: String, input: serde_json::Value, resolved_input: serde_json::Value, #[serde(flatten)] result: serde_json::Value, } // ─── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] #[path = "../who_tests.rs"] mod tests;