479 lines
17 KiB
Rust
479 lines
17 KiB
Rust
#[derive(Serialize)]
|
|
struct FallbackErrorOutput {
|
|
error: FallbackError,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct FallbackError {
|
|
code: String,
|
|
message: 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(),
|
|
},
|
|
};
|
|
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(),
|
|
},
|
|
};
|
|
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>",
|
|
}
|
|
}
|
|
|