Files
gitlore/src/app/errors.rs
teernisse 06889ec85a fix(explain): address review findings — N+1 queries, duplicate decisions, silent errors
1. fetch_open_threads: replace N+1 loop (2 queries per thread) with a
   single query using correlated subqueries for note_count and started_by.
2. extract_key_decisions: track consumed notes so the same note is not
   matched to multiple events, preventing duplicate decision entries.
3. build_timeline_excerpt_from_pipeline: log tracing::warn on seed/collect
   failures instead of silently returning empty timeline.
2026-03-10 16:43:06 -04:00

487 lines
17 KiB
Rust

#[derive(Serialize)]
struct FallbackErrorOutput {
error: FallbackError,
}
#[derive(Serialize)]
struct FallbackError {
code: String,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
suggestion: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
actions: Vec<String>,
}
fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
if let Some(gi_error) = e.downcast_ref::<LoreError>() {
if robot_mode {
let output = RobotErrorOutput::from(gi_error);
eprintln!(
"{}",
serde_json::to_string(&output).unwrap_or_else(|_| {
let fallback = FallbackErrorOutput {
error: FallbackError {
code: "INTERNAL_ERROR".to_string(),
message: gi_error.to_string(),
suggestion: None,
actions: Vec::new(),
},
};
serde_json::to_string(&fallback)
.unwrap_or_else(|_| r#"{"error":{"code":"INTERNAL_ERROR","message":"Serialization failed"}}"#.to_string())
})
);
std::process::exit(gi_error.exit_code());
} else {
eprintln!();
eprintln!(
" {} {}",
Theme::error().render(Icons::error()),
Theme::error().bold().render(&gi_error.to_string())
);
if let Some(suggestion) = gi_error.suggestion() {
eprintln!();
eprintln!(" {suggestion}");
}
let actions = gi_error.actions();
if !actions.is_empty() {
eprintln!();
for action in &actions {
eprintln!(
" {} {}",
Theme::dim().render("\u{2192}"),
Theme::bold().render(action)
);
}
}
eprintln!();
std::process::exit(gi_error.exit_code());
}
}
if robot_mode {
let output = FallbackErrorOutput {
error: FallbackError {
code: "INTERNAL_ERROR".to_string(),
message: e.to_string(),
suggestion: None,
actions: Vec::new(),
},
};
eprintln!(
"{}",
serde_json::to_string(&output).unwrap_or_else(|_| {
r#"{"error":{"code":"INTERNAL_ERROR","message":"Serialization failed"}}"#
.to_string()
})
);
} else {
eprintln!();
eprintln!(
" {} {}",
Theme::error().render(Icons::error()),
Theme::error().bold().render(&e.to_string())
);
eprintln!();
}
std::process::exit(1);
}
/// Emit stderr warnings for any corrections applied during Phase 1.5.
fn emit_correction_warnings(result: &CorrectionResult, robot_mode: bool) {
if robot_mode {
#[derive(Serialize)]
struct CorrectionWarning<'a> {
warning: CorrectionWarningInner<'a>,
}
#[derive(Serialize)]
struct CorrectionWarningInner<'a> {
r#type: &'static str,
corrections: &'a [autocorrect::Correction],
teaching: Vec<String>,
}
let teaching: Vec<String> = result
.corrections
.iter()
.map(autocorrect::format_teaching_note)
.collect();
let warning = CorrectionWarning {
warning: CorrectionWarningInner {
r#type: "ARG_CORRECTED",
corrections: &result.corrections,
teaching,
},
};
if let Ok(json) = serde_json::to_string(&warning) {
eprintln!("{json}");
}
} else {
for c in &result.corrections {
eprintln!(
"{} {}",
Theme::warning().render("Auto-corrected:"),
autocorrect::format_teaching_note(c)
);
}
}
}
/// Phase 1 & 4: Handle clap parsing errors with structured JSON output in robot mode.
/// Also includes fuzzy command matching and flag-level suggestions.
fn handle_clap_error(e: clap::Error, robot_mode: bool, corrections: &CorrectionResult) -> ! {
use clap::error::ErrorKind;
// Always let clap handle --help and --version normally (print and exit 0).
// These are intentional user actions, not errors, even when stdout is redirected.
if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
e.exit()
}
if robot_mode {
let error_code = map_clap_error_kind(e.kind());
let full_msg = e.to_string();
let message = full_msg
.lines()
.take(3)
.collect::<Vec<_>>()
.join("; ")
.trim()
.to_string();
let (suggestion, correction, valid_values) = match e.kind() {
// Phase 4: Suggest similar command for unknown subcommands
ErrorKind::InvalidSubcommand => {
let suggestion = if let Some(invalid_cmd) = extract_invalid_subcommand(&e) {
suggest_similar_command(&invalid_cmd)
} else {
"Run 'lore robot-docs' for valid commands".to_string()
};
(suggestion, None, None)
}
// Flag-level fuzzy matching for unknown flags
ErrorKind::UnknownArgument => {
let invalid_flag = extract_invalid_flag(&e);
let similar = invalid_flag
.as_deref()
.and_then(|flag| autocorrect::suggest_similar_flag(flag, &corrections.args));
let suggestion = if let Some(ref s) = similar {
format!("Did you mean '{s}'? Run 'lore robot-docs' for all flags")
} else {
"Run 'lore robot-docs' for valid flags".to_string()
};
(suggestion, similar, None)
}
// Value-level suggestions for invalid enum values
ErrorKind::InvalidValue => {
let (flag, valid_vals) = extract_invalid_value_context(&e);
let suggestion = if let Some(vals) = &valid_vals {
format!(
"Valid values: {}. Run 'lore robot-docs' for details",
vals.join(", ")
)
} else if let Some(ref f) = flag {
if let Some(vals) = autocorrect::valid_values_for_flag(f) {
format!("Valid values for {f}: {}", vals.join(", "))
} else {
"Run 'lore robot-docs' for valid values".to_string()
}
} else {
"Run 'lore robot-docs' for valid values".to_string()
};
let vals_vec = valid_vals.or_else(|| {
flag.as_deref()
.and_then(autocorrect::valid_values_for_flag)
.map(|v| v.iter().map(|s| (*s).to_string()).collect())
});
(suggestion, None, vals_vec)
}
ErrorKind::MissingRequiredArgument => {
let suggestion = format!(
"A required argument is missing. {}",
if let Some(subcmd) = extract_subcommand_from_context(&e) {
format!(
"Example: {}. Run 'lore {subcmd} --help' for required arguments",
command_example(&subcmd)
)
} else {
"Run 'lore robot-docs' for command reference".to_string()
}
);
(suggestion, None, None)
}
ErrorKind::MissingSubcommand => {
let suggestion =
"No command specified. Common commands: issues, mrs, search, sync, \
timeline, who, me. Run 'lore robot-docs' for the full list"
.to_string();
(suggestion, None, None)
}
ErrorKind::TooFewValues | ErrorKind::TooManyValues => {
let suggestion = if let Some(subcmd) = extract_subcommand_from_context(&e) {
format!(
"Example: {}. Run 'lore {subcmd} --help' for usage",
command_example(&subcmd)
)
} else {
"Run 'lore robot-docs' for command reference".to_string()
};
(suggestion, None, None)
}
_ => (
"Run 'lore robot-docs' for valid commands".to_string(),
None,
None,
),
};
let output = RobotErrorWithSuggestion {
error: RobotErrorSuggestionData {
code: error_code.to_string(),
message,
suggestion,
correction,
valid_values,
},
};
eprintln!(
"{}",
serde_json::to_string(&output).unwrap_or_else(|_| {
r#"{"error":{"code":"PARSE_ERROR","message":"Parse error"}}"#.to_string()
})
);
std::process::exit(2);
} else {
e.exit()
}
}
/// Map clap ErrorKind to semantic error codes
fn map_clap_error_kind(kind: clap::error::ErrorKind) -> &'static str {
use clap::error::ErrorKind;
match kind {
ErrorKind::InvalidSubcommand => "UNKNOWN_COMMAND",
ErrorKind::UnknownArgument => "UNKNOWN_FLAG",
ErrorKind::MissingRequiredArgument => "MISSING_REQUIRED",
ErrorKind::InvalidValue => "INVALID_VALUE",
ErrorKind::ValueValidation => "INVALID_VALUE",
ErrorKind::TooManyValues => "TOO_MANY_VALUES",
ErrorKind::TooFewValues => "TOO_FEW_VALUES",
ErrorKind::ArgumentConflict => "ARGUMENT_CONFLICT",
ErrorKind::MissingSubcommand => "MISSING_COMMAND",
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => "HELP_REQUESTED",
_ => "PARSE_ERROR",
}
}
/// Extract the invalid subcommand from a clap error (Phase 4)
fn extract_invalid_subcommand(e: &clap::Error) -> Option<String> {
// Parse the error message to find the invalid subcommand
// Format is typically: "error: unrecognized subcommand 'foo'"
let msg = e.to_string();
if let Some(start) = msg.find('\'')
&& let Some(end) = msg[start + 1..].find('\'')
{
return Some(msg[start + 1..start + 1 + end].to_string());
}
None
}
/// Extract the invalid flag from a clap UnknownArgument error.
/// Format is typically: "error: unexpected argument '--xyzzy' found"
fn extract_invalid_flag(e: &clap::Error) -> Option<String> {
let msg = e.to_string();
if let Some(start) = msg.find('\'')
&& let Some(end) = msg[start + 1..].find('\'')
{
let value = &msg[start + 1..start + 1 + end];
if value.starts_with('-') {
return Some(value.to_string());
}
}
None
}
/// Extract flag name and valid values from a clap InvalidValue error.
/// Returns (flag_name, valid_values_if_listed_in_error).
fn extract_invalid_value_context(e: &clap::Error) -> (Option<String>, Option<Vec<String>>) {
let msg = e.to_string();
// Try to find the flag name from "[possible values: ...]" pattern or from the arg info
// Clap format: "error: invalid value 'opend' for '--state <STATE>'"
let flag = if let Some(for_pos) = msg.find("for '") {
let after_for = &msg[for_pos + 5..];
if let Some(end) = after_for.find('\'') {
let raw = &after_for[..end];
// Strip angle-bracket value placeholder: "--state <STATE>" -> "--state"
Some(raw.split_whitespace().next().unwrap_or(raw).to_string())
} else {
None
}
} else {
None
};
// Try to extract possible values from the error message
// Clap format: "[possible values: opened, closed, merged, locked, all]"
let valid_values = if let Some(pv_pos) = msg.find("[possible values: ") {
let after_pv = &msg[pv_pos + 18..];
after_pv.find(']').map(|end| {
after_pv[..end]
.split(", ")
.map(|s| s.trim().to_string())
.collect()
})
} else {
// Fall back to our static registry
flag.as_deref()
.and_then(autocorrect::valid_values_for_flag)
.map(|v| v.iter().map(|s| (*s).to_string()).collect())
};
(flag, valid_values)
}
/// Extract the subcommand context from a clap error for better suggestions.
/// Looks at the error message to find which command was being invoked.
fn extract_subcommand_from_context(e: &clap::Error) -> Option<String> {
let msg = e.to_string();
let known = [
"issues",
"mrs",
"notes",
"search",
"sync",
"ingest",
"count",
"status",
"auth",
"doctor",
"stats",
"timeline",
"who",
"me",
"drift",
"related",
"trace",
"file-history",
"generate-docs",
"embed",
"token",
"cron",
"init",
"migrate",
];
for cmd in known {
if msg.contains(&format!("lore {cmd}")) || msg.contains(&format!("'{cmd}'")) {
return Some(cmd.to_string());
}
}
None
}
/// Phase 4: Suggest similar command using fuzzy matching
fn suggest_similar_command(invalid: &str) -> String {
// Primary commands + common aliases for fuzzy matching
const VALID_COMMANDS: &[(&str, &str)] = &[
("issues", "issues"),
("issue", "issues"),
("mrs", "mrs"),
("mr", "mrs"),
("merge-requests", "mrs"),
("search", "search"),
("find", "search"),
("query", "search"),
("sync", "sync"),
("ingest", "ingest"),
("count", "count"),
("status", "status"),
("auth", "auth"),
("doctor", "doctor"),
("version", "version"),
("init", "init"),
("stats", "stats"),
("stat", "stats"),
("generate-docs", "generate-docs"),
("embed", "embed"),
("migrate", "migrate"),
("health", "health"),
("robot-docs", "robot-docs"),
("completions", "completions"),
("timeline", "timeline"),
("who", "who"),
("notes", "notes"),
("note", "notes"),
("drift", "drift"),
("file-history", "file-history"),
("trace", "trace"),
("related", "related"),
("me", "me"),
("token", "token"),
("cron", "cron"),
// Hidden but may be known to agents
("list", "list"),
("show", "show"),
("reset", "reset"),
("backup", "backup"),
];
let invalid_lower = invalid.to_lowercase();
// Find the best match using Jaro-Winkler similarity
let best_match = VALID_COMMANDS
.iter()
.map(|(alias, canonical)| (*canonical, jaro_winkler(&invalid_lower, alias)))
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
if let Some((cmd, score)) = best_match
&& score > 0.7
{
let example = command_example(cmd);
return format!(
"Did you mean 'lore {cmd}'? Example: {example}. Run 'lore robot-docs' for all commands"
);
}
"Run 'lore robot-docs' for valid commands. Common: issues, mrs, search, sync, timeline, who"
.to_string()
}
/// Return a contextual usage example for a command.
fn command_example(cmd: &str) -> &'static str {
match cmd {
"issues" => "lore --robot issues -n 10",
"mrs" => "lore --robot mrs -n 10",
"search" => "lore --robot search \"auth bug\"",
"sync" => "lore --robot sync",
"ingest" => "lore --robot ingest issues",
"notes" => "lore --robot notes --for-issue 123",
"count" => "lore --robot count issues",
"status" => "lore --robot status",
"stats" => "lore --robot stats",
"timeline" => "lore --robot timeline \"auth flow\"",
"who" => "lore --robot who --path src/",
"health" => "lore --robot health",
"generate-docs" => "lore --robot generate-docs",
"embed" => "lore --robot embed",
"robot-docs" => "lore robot-docs",
"trace" => "lore --robot trace src/main.rs",
"init" => "lore init",
"related" => "lore --robot related issues 42 -n 5",
"me" => "lore --robot me",
"drift" => "lore --robot drift issues 42",
"file-history" => "lore --robot file-history src/main.rs",
"token" => "lore --robot token show",
"cron" => "lore --robot cron status",
"auth" => "lore --robot auth",
"doctor" => "lore --robot doctor",
"migrate" => "lore --robot migrate",
"completions" => "lore completions bash",
_ => "lore --robot <command>",
}
}