refactor(who): split 2598-line who.rs into per-mode modules
Split the monolithic who.rs into a who/ directory module with 7 focused files. The 5 query modes (expert, workload, reviews, active, overlap) share no query-level code — only types and a few small helpers — making this a clean mechanical extraction. New structure: who/types.rs — all pub result structs/enums (~185 lines) who/mod.rs — dispatch, shared helpers, JSON envelope (~428 lines) who/expert.rs — query + render + json for expert mode (~839 lines) who/workload.rs — query + render + json for workload mode (~370 lines) who/reviews.rs — query + render + json for reviews mode (~214 lines) who/active.rs — query + render + json for active mode (~299 lines) who/overlap.rs — query + render + json for overlap mode (~323 lines) Token savings: an agent working on any single mode now loads ~400-960 lines instead of 2,598 (63-85% reduction). Public API unchanged — parent mod.rs re-exports are identical. Test re-exports use #[cfg(test)] use (not pub use) to avoid visibility conflicts with pub(super) items in submodules. All 79 who tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
428
src/cli/commands/who/mod.rs
Normal file
428
src/cli/commands/who/mod.rs
Normal file
@@ -0,0 +1,428 @@
|
||||
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 <file-path> OR lore who --path <path>
|
||||
Expert { path: String },
|
||||
/// lore who <username>
|
||||
Workload { username: &'a str },
|
||||
/// lore who <username> --reviews
|
||||
Reviews { username: &'a str },
|
||||
/// lore who --active
|
||||
Active,
|
||||
/// lore who --overlap <path>
|
||||
Overlap { path: String },
|
||||
}
|
||||
|
||||
fn resolve_mode<'a>(args: &'a WhoArgs) -> Result<WhoMode<'a>> {
|
||||
// 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 <path>.\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 <path>` or `lore who <path/with/slash>`).".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Entry Point ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Main entry point. Resolves mode + resolved inputs once, then dispatches.
|
||||
pub fn run_who(config: &Config, args: &WhoArgs) -> Result<WhoRun> {
|
||||
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"
|
||||
};
|
||||
|
||||
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 limit = usize::from(args.limit);
|
||||
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 limit = usize::from(args.limit);
|
||||
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 limit = usize::from(args.limit);
|
||||
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 limit = usize::from(args.limit);
|
||||
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<String> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
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;
|
||||
Reference in New Issue
Block a user