diff --git a/Cargo.lock b/Cargo.lock index ce46e0a..a8a1b3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1129,6 +1129,7 @@ dependencies = [ "serde_json", "sha2", "sqlite-vec", + "strsim", "tempfile", "thiserror", "tokio", diff --git a/Cargo.toml b/Cargo.toml index aa3ea01..0efa027 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ flate2 = "1" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } regex = "1" +strsim = "0.11" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index bb0eb41..f8caa85 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -17,10 +17,13 @@ pub use count::{ print_count, print_count_json, print_event_count, print_event_count_json, run_count, run_count_events, }; -pub use doctor::{print_doctor_results, run_doctor}; +pub use doctor::{DoctorChecks, print_doctor_results, run_doctor}; pub use embed::{print_embed, print_embed_json, run_embed}; pub use generate_docs::{print_generate_docs, print_generate_docs_json, run_generate_docs}; -pub use ingest::{IngestDisplay, print_ingest_summary, print_ingest_summary_json, run_ingest}; +pub use ingest::{ + DryRunPreview, IngestDisplay, print_dry_run_preview, print_dry_run_preview_json, + print_ingest_summary, print_ingest_summary_json, run_ingest, run_ingest_dry_run, +}; pub use init::{InitInputs, InitOptions, InitResult, run_init}; pub use list::{ ListFilters, MrListFilters, open_issue_in_browser, open_mr_in_browser, print_list_issues, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d6a1897..5af1f88 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,71 +6,127 @@ use std::io::IsTerminal; #[derive(Parser)] #[command(name = "lore")] -#[command(version, about, long_about = None)] +#[command(version, about = "Local GitLab data management with semantic search", long_about = None)] +#[command(subcommand_required = false)] pub struct Cli { - #[arg(short = 'c', long, global = true)] + /// Path to config file + #[arg(short = 'c', long, global = true, help = "Path to config file")] pub config: Option, - #[arg(long, global = true, env = "LORE_ROBOT")] + /// Machine-readable JSON output (auto-enabled when piped) + #[arg( + long, + global = true, + env = "LORE_ROBOT", + help = "Machine-readable JSON output (auto-enabled when piped)" + )] pub robot: bool, - #[arg(short = 'J', long = "json", global = true)] + /// JSON output (global shorthand) + #[arg( + short = 'J', + long = "json", + global = true, + help = "JSON output (global shorthand)" + )] pub json: bool, - #[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto")] + /// Color output: auto (default), always, or never + #[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto", help = "Color output: auto (default), always, or never")] pub color: String, - #[arg(short = 'q', long, global = true)] + /// Suppress non-essential output + #[arg( + short = 'q', + long, + global = true, + overrides_with = "no_quiet", + help = "Suppress non-essential output" + )] pub quiet: bool, - #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)] + #[arg( + long = "no-quiet", + global = true, + hide = true, + overrides_with = "quiet" + )] + pub no_quiet: bool, + + /// Increase log verbosity (-v, -vv, -vvv) + #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true, help = "Increase log verbosity (-v, -vv, -vvv)")] pub verbose: u8, - #[arg(long = "log-format", global = true, value_parser = ["text", "json"], default_value = "text")] + /// Log format for stderr output: text (default) or json + #[arg(long = "log-format", global = true, value_parser = ["text", "json"], default_value = "text", help = "Log format for stderr output: text (default) or json")] pub log_format: String, #[command(subcommand)] - pub command: Commands, + pub command: Option, } impl Cli { pub fn is_robot_mode(&self) -> bool { self.robot || self.json || !std::io::stdout().is_terminal() } + + /// Detect robot mode from environment before parsing succeeds. + /// Used for structured error output when clap parsing fails. + pub fn detect_robot_mode_from_env() -> bool { + let args: Vec = std::env::args().collect(); + args.iter() + .any(|a| a == "--robot" || a == "-J" || a == "--json") + || std::env::var("LORE_ROBOT").is_ok() + || !std::io::stdout().is_terminal() + } } #[derive(Subcommand)] #[allow(clippy::large_enum_variant)] pub enum Commands { + /// List or show issues Issues(IssuesArgs), + /// List or show merge requests Mrs(MrsArgs), + /// Ingest data from GitLab Ingest(IngestArgs), + /// Count entities in local database Count(CountArgs), + /// Show sync state Status, + /// Verify GitLab authentication Auth, + /// Check environment health Doctor, + /// Show version information Version, + /// Initialize configuration and database Init { + /// Skip overwrite confirmation #[arg(short = 'f', long)] force: bool, + /// Fail if prompts would be shown #[arg(long)] non_interactive: bool, + /// GitLab base URL (required in robot mode) #[arg(long)] gitlab_url: Option, + /// Environment variable name holding GitLab token (required in robot mode) #[arg(long)] token_env_var: Option, + /// Comma-separated project paths (required in robot mode) #[arg(long)] projects: Option, }, @@ -84,26 +140,41 @@ pub enum Commands { yes: bool, }, + /// Search indexed documents Search(SearchArgs), + /// Show document and index statistics Stats(StatsArgs), + /// Generate searchable documents from ingested data #[command(name = "generate-docs")] GenerateDocs(GenerateDocsArgs), + /// Generate vector embeddings for documents via Ollama Embed(EmbedArgs), + /// Run full sync pipeline: ingest -> generate-docs -> embed Sync(SyncArgs), + /// Run pending database migrations Migrate, + /// Quick health check: config, database, schema version Health, + /// Machine-readable command manifest for agent self-discovery #[command(name = "robot-docs")] RobotDocs, - #[command(hide = true)] + /// Generate shell completions + #[command(long_about = "Generate shell completions for lore.\n\n\ + Installation:\n \ + bash: lore completions bash > ~/.local/share/bash-completion/completions/lore\n \ + zsh: lore completions zsh > ~/.zfunc/_lore && echo 'fpath+=~/.zfunc' >> ~/.zshrc\n \ + fish: lore completions fish > ~/.config/fish/completions/lore.fish\n \ + pwsh: lore completions powershell >> $PROFILE")] Completions { + /// Shell to generate completions for #[arg(value_parser = ["bash", "zsh", "fish", "powershell"])] shell: String, }, @@ -171,8 +242,10 @@ pub enum Commands { #[derive(Parser)] pub struct IssuesArgs { + /// Issue IID (omit to list, provide to show details) pub iid: Option, + /// Maximum results #[arg( short = 'n', long = "limit", @@ -181,30 +254,43 @@ pub struct IssuesArgs { )] pub limit: usize, + /// Select output fields (comma-separated: iid,title,state,author,labels,updated) + #[arg(long, help_heading = "Output", value_delimiter = ',')] + pub fields: Option>, + + /// Filter by state (opened, closed, all) #[arg(short = 's', long, help_heading = "Filters")] pub state: Option, + /// Filter by project path #[arg(short = 'p', long, help_heading = "Filters")] pub project: Option, + /// Filter by author username #[arg(short = 'a', long, help_heading = "Filters")] pub author: Option, + /// Filter by assignee username #[arg(short = 'A', long, help_heading = "Filters")] pub assignee: Option, + /// Filter by label (repeatable, AND logic) #[arg(short = 'l', long, help_heading = "Filters")] pub label: Option>, + /// Filter by milestone title #[arg(short = 'm', long, help_heading = "Filters")] pub milestone: Option, + /// Filter by time (7d, 2w, 1m, or YYYY-MM-DD) #[arg(long, help_heading = "Filters")] pub since: Option, + /// Filter by due date (before this date, YYYY-MM-DD) #[arg(long = "due-before", help_heading = "Filters")] pub due_before: Option, + /// Show only issues with a due date #[arg( long = "has-due", help_heading = "Filters", @@ -215,15 +301,18 @@ pub struct IssuesArgs { #[arg(long = "no-has-due", hide = true, overrides_with = "has_due")] pub no_has_due: bool, + /// Sort field (updated, created, iid) #[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated", help_heading = "Sorting")] pub sort: String, + /// Sort ascending (default: descending) #[arg(long, help_heading = "Sorting", overrides_with = "no_asc")] pub asc: bool, #[arg(long = "no-asc", hide = true, overrides_with = "asc")] pub no_asc: bool, + /// Open first matching item in browser #[arg( short = 'o', long, @@ -238,8 +327,10 @@ pub struct IssuesArgs { #[derive(Parser)] pub struct MrsArgs { + /// MR IID (omit to list, provide to show details) pub iid: Option, + /// Maximum results #[arg( short = 'n', long = "limit", @@ -248,27 +339,39 @@ pub struct MrsArgs { )] pub limit: usize, + /// Select output fields (comma-separated: iid,title,state,author,labels,updated) + #[arg(long, help_heading = "Output", value_delimiter = ',')] + pub fields: Option>, + + /// Filter by state (opened, merged, closed, locked, all) #[arg(short = 's', long, help_heading = "Filters")] pub state: Option, + /// Filter by project path #[arg(short = 'p', long, help_heading = "Filters")] pub project: Option, + /// Filter by author username #[arg(short = 'a', long, help_heading = "Filters")] pub author: Option, + /// Filter by assignee username #[arg(short = 'A', long, help_heading = "Filters")] pub assignee: Option, + /// Filter by reviewer username #[arg(short = 'r', long, help_heading = "Filters")] pub reviewer: Option, + /// Filter by label (repeatable, AND logic) #[arg(short = 'l', long, help_heading = "Filters")] pub label: Option>, + /// Filter by time (7d, 2w, 1m, or YYYY-MM-DD) #[arg(long, help_heading = "Filters")] pub since: Option, + /// Show only draft MRs #[arg( short = 'd', long, @@ -277,6 +380,7 @@ pub struct MrsArgs { )] pub draft: bool, + /// Exclude draft MRs #[arg( short = 'D', long = "no-draft", @@ -285,21 +389,26 @@ pub struct MrsArgs { )] pub no_draft: bool, + /// Filter by target branch #[arg(long, help_heading = "Filters")] pub target: Option, + /// Filter by source branch #[arg(long, help_heading = "Filters")] pub source: Option, + /// Sort field (updated, created, iid) #[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated", help_heading = "Sorting")] pub sort: String, + /// Sort ascending (default: descending) #[arg(long, help_heading = "Sorting", overrides_with = "no_asc")] pub asc: bool, #[arg(long = "no-asc", hide = true, overrides_with = "asc")] pub no_asc: bool, + /// Open first matching item in browser #[arg( short = 'o', long, @@ -314,65 +423,95 @@ pub struct MrsArgs { #[derive(Parser)] pub struct IngestArgs { + /// Entity to ingest (issues, mrs). Omit to ingest everything #[arg(value_parser = ["issues", "mrs"])] pub entity: Option, + /// Filter to single project #[arg(short = 'p', long)] pub project: Option, + /// Override stale sync lock #[arg(short = 'f', long, overrides_with = "no_force")] pub force: bool, #[arg(long = "no-force", hide = true, overrides_with = "force")] pub no_force: bool, + /// Full re-sync: reset cursors and fetch all data from scratch #[arg(long, overrides_with = "no_full")] pub full: bool, #[arg(long = "no-full", hide = true, overrides_with = "full")] pub no_full: bool, + + /// Preview what would be synced without making changes + #[arg(long, overrides_with = "no_dry_run")] + pub dry_run: bool, + + #[arg(long = "no-dry-run", hide = true, overrides_with = "dry_run")] + pub no_dry_run: bool, } #[derive(Parser)] pub struct StatsArgs { + /// Run integrity checks #[arg(long, overrides_with = "no_check")] pub check: bool, #[arg(long = "no-check", hide = true, overrides_with = "check")] pub no_check: bool, + /// Repair integrity issues (auto-enables --check) #[arg(long)] pub repair: bool, + + /// Preview what would be repaired without making changes (requires --repair) + #[arg(long, overrides_with = "no_dry_run")] + pub dry_run: bool, + + #[arg(long = "no-dry-run", hide = true, overrides_with = "dry_run")] + pub no_dry_run: bool, } #[derive(Parser)] pub struct SearchArgs { + /// Search query string pub query: String, + /// Search mode (lexical, hybrid, semantic) #[arg(long, default_value = "hybrid", value_parser = ["lexical", "hybrid", "semantic"], help_heading = "Output")] pub mode: String, + /// Filter by source type (issue, mr, discussion) #[arg(long = "type", value_name = "TYPE", value_parser = ["issue", "mr", "discussion"], help_heading = "Filters")] pub source_type: Option, + /// Filter by author username #[arg(long, help_heading = "Filters")] pub author: Option, + /// Filter by project path #[arg(short = 'p', long, help_heading = "Filters")] pub project: Option, + /// Filter by label (repeatable, AND logic) #[arg(long, action = clap::ArgAction::Append, help_heading = "Filters")] pub label: Vec, + /// Filter by file path (trailing / for prefix match) #[arg(long, help_heading = "Filters")] pub path: Option, + /// Filter by created after (7d, 2w, or YYYY-MM-DD) #[arg(long, help_heading = "Filters")] pub after: Option, + /// Filter by updated after (7d, 2w, or YYYY-MM-DD) #[arg(long = "updated-after", help_heading = "Filters")] pub updated_after: Option, + /// Maximum results (default 20, max 100) #[arg( short = 'n', long = "limit", @@ -381,57 +520,75 @@ pub struct SearchArgs { )] pub limit: usize, + /// Show ranking explanation per result #[arg(long, help_heading = "Output", overrides_with = "no_explain")] pub explain: bool, #[arg(long = "no-explain", hide = true, overrides_with = "explain")] pub no_explain: bool, + /// FTS query mode: safe (default) or raw #[arg(long = "fts-mode", default_value = "safe", value_parser = ["safe", "raw"], help_heading = "Output")] pub fts_mode: String, } #[derive(Parser)] pub struct GenerateDocsArgs { + /// Full rebuild: seed all entities into dirty queue, then drain #[arg(long)] pub full: bool, + /// Filter to single project #[arg(short = 'p', long)] pub project: Option, } #[derive(Parser)] pub struct SyncArgs { + /// Reset cursors, fetch everything #[arg(long, overrides_with = "no_full")] pub full: bool, #[arg(long = "no-full", hide = true, overrides_with = "full")] pub no_full: bool, + /// Override stale lock #[arg(long, overrides_with = "no_force")] pub force: bool, #[arg(long = "no-force", hide = true, overrides_with = "force")] pub no_force: bool, + /// Skip embedding step #[arg(long)] pub no_embed: bool, + /// Skip document regeneration #[arg(long)] pub no_docs: bool, + /// Skip resource event fetching (overrides config) #[arg(long = "no-events")] pub no_events: bool, + + /// Preview what would be synced without making changes + #[arg(long, overrides_with = "no_dry_run")] + pub dry_run: bool, + + #[arg(long = "no-dry-run", hide = true, overrides_with = "dry_run")] + pub no_dry_run: bool, } #[derive(Parser)] pub struct EmbedArgs { + /// Re-embed all documents (clears existing embeddings first) #[arg(long, overrides_with = "no_full")] pub full: bool, #[arg(long = "no-full", hide = true, overrides_with = "full")] pub no_full: bool, + /// Retry previously failed embeddings #[arg(long, overrides_with = "no_retry_failed")] pub retry_failed: bool, @@ -441,9 +598,11 @@ pub struct EmbedArgs { #[derive(Parser)] pub struct CountArgs { + /// Entity type to count (issues, mrs, discussions, notes, events) #[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])] pub entity: String, + /// Parent type filter: issue or mr (for discussions/notes) #[arg(short = 'f', long = "for", value_parser = ["issue", "mr"])] pub for_entity: Option, } diff --git a/src/main.rs b/src/main.rs index e444355..5564230 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use clap::Parser; use console::style; use dialoguer::{Confirm, Input}; use serde::Serialize; +use strsim::jaro_winkler; use tracing_subscriber::Layer; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; @@ -10,13 +11,14 @@ use lore::Config; use lore::cli::commands::{ IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters, SearchCliFilters, SyncOptions, open_issue_in_browser, open_mr_in_browser, print_count, - print_count_json, print_doctor_results, print_embed, print_embed_json, print_event_count, - print_event_count_json, print_generate_docs, print_generate_docs_json, print_ingest_summary, - print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs, - print_list_mrs_json, print_search_results, print_search_results_json, print_show_issue, - print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json, - print_sync, print_sync_json, print_sync_status, print_sync_status_json, run_auth_test, - run_count, run_count_events, run_doctor, run_embed, run_generate_docs, run_ingest, run_init, + print_count_json, print_doctor_results, print_dry_run_preview, print_dry_run_preview_json, + print_embed, print_embed_json, print_event_count, print_event_count_json, print_generate_docs, + print_generate_docs_json, print_ingest_summary, print_ingest_summary_json, print_list_issues, + print_list_issues_json, print_list_mrs, print_list_mrs_json, print_search_results, + print_search_results_json, print_show_issue, print_show_issue_json, print_show_mr, + print_show_mr_json, print_stats, print_stats_json, print_sync, print_sync_json, + print_sync_status, print_sync_status_json, run_auth_test, run_count, run_count_events, + run_doctor, run_embed, run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, }; @@ -40,7 +42,15 @@ async fn main() { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } - let cli = Cli::parse(); + // Phase 1: Early robot mode detection for structured clap errors + let robot_mode_early = Cli::detect_robot_mode_from_env(); + + let cli = match Cli::try_parse() { + Ok(cli) => cli, + Err(e) => { + handle_clap_error(e, robot_mode_early); + } + }; let robot_mode = cli.is_robot_mode(); let logging_config = lore::Config::load(cli.config.as_deref()) @@ -127,15 +137,29 @@ async fn main() { let quiet = cli.quiet; let result = match cli.command { - Commands::Issues(args) => handle_issues(cli.config.as_deref(), args, robot_mode), - Commands::Mrs(args) => handle_mrs(cli.config.as_deref(), args, robot_mode), - Commands::Search(args) => handle_search(cli.config.as_deref(), args, robot_mode).await, - Commands::Stats(args) => handle_stats(cli.config.as_deref(), args, robot_mode).await, - Commands::Embed(args) => handle_embed(cli.config.as_deref(), args, robot_mode).await, - Commands::Sync(args) => { + // Phase 2: Handle no-args case - in robot mode, output robot-docs; otherwise show help + None => { + if robot_mode { + handle_robot_docs(robot_mode) + } else { + use clap::CommandFactory; + let mut cmd = Cli::command(); + cmd.print_help().ok(); + println!(); + Ok(()) + } + } + Some(Commands::Issues(args)) => handle_issues(cli.config.as_deref(), args, robot_mode), + Some(Commands::Mrs(args)) => handle_mrs(cli.config.as_deref(), args, robot_mode), + Some(Commands::Search(args)) => { + handle_search(cli.config.as_deref(), args, robot_mode).await + } + Some(Commands::Stats(args)) => handle_stats(cli.config.as_deref(), args, robot_mode).await, + Some(Commands::Embed(args)) => handle_embed(cli.config.as_deref(), args, robot_mode).await, + Some(Commands::Sync(args)) => { handle_sync_cmd(cli.config.as_deref(), args, robot_mode, &metrics_layer).await } - Commands::Ingest(args) => { + Some(Commands::Ingest(args)) => { handle_ingest( cli.config.as_deref(), args, @@ -145,19 +169,19 @@ async fn main() { ) .await } - Commands::Count(args) => handle_count(cli.config.as_deref(), args, robot_mode).await, - Commands::Status => handle_sync_status_cmd(cli.config.as_deref(), robot_mode).await, - Commands::Auth => handle_auth_test(cli.config.as_deref(), robot_mode).await, - Commands::Doctor => handle_doctor(cli.config.as_deref(), robot_mode).await, - Commands::Version => handle_version(robot_mode), - Commands::Completions { shell } => handle_completions(&shell), - Commands::Init { + Some(Commands::Count(args)) => handle_count(cli.config.as_deref(), args, robot_mode).await, + Some(Commands::Status) => handle_sync_status_cmd(cli.config.as_deref(), robot_mode).await, + Some(Commands::Auth) => handle_auth_test(cli.config.as_deref(), robot_mode).await, + Some(Commands::Doctor) => handle_doctor(cli.config.as_deref(), robot_mode).await, + Some(Commands::Version) => handle_version(robot_mode), + Some(Commands::Completions { shell }) => handle_completions(&shell), + Some(Commands::Init { force, non_interactive, gitlab_url, token_env_var, projects, - } => { + }) => { handle_init( cli.config.as_deref(), force, @@ -169,16 +193,16 @@ async fn main() { ) .await } - Commands::GenerateDocs(args) => { + Some(Commands::GenerateDocs(args)) => { handle_generate_docs(cli.config.as_deref(), args, robot_mode).await } - Commands::Backup => handle_backup(robot_mode), - Commands::Reset { yes: _ } => handle_reset(robot_mode), - Commands::Migrate => handle_migrate(cli.config.as_deref(), robot_mode).await, - Commands::Health => handle_health(cli.config.as_deref(), robot_mode).await, - Commands::RobotDocs => handle_robot_docs(robot_mode), + Some(Commands::Backup) => handle_backup(robot_mode), + Some(Commands::Reset { yes: _ }) => handle_reset(robot_mode), + Some(Commands::Migrate) => handle_migrate(cli.config.as_deref(), robot_mode).await, + Some(Commands::Health) => handle_health(cli.config.as_deref(), robot_mode).await, + Some(Commands::RobotDocs) => handle_robot_docs(robot_mode), - Commands::List { + Some(Commands::List { entity, limit, project, @@ -198,7 +222,7 @@ async fn main() { reviewer, target_branch, source_branch, - } => { + }) => { if !robot_mode { eprintln!( "{}", @@ -231,11 +255,11 @@ async fn main() { ) .await } - Commands::Show { + Some(Commands::Show { entity, iid, project, - } => { + }) => { if !robot_mode { eprintln!( "{}", @@ -255,7 +279,7 @@ async fn main() { ) .await } - Commands::AuthTest => { + Some(Commands::AuthTest) => { if !robot_mode { eprintln!( "{}", @@ -264,7 +288,7 @@ async fn main() { } handle_auth_test(cli.config.as_deref(), robot_mode).await } - Commands::SyncStatus => { + Some(Commands::SyncStatus) => { if !robot_mode { eprintln!( "{}", @@ -338,11 +362,143 @@ fn handle_error(e: Box, robot_mode: bool) -> ! { std::process::exit(1); } +/// Phase 1 & 4: Handle clap parsing errors with structured JSON output in robot mode. +/// Also includes fuzzy command matching to suggest similar commands. +fn handle_clap_error(e: clap::Error, robot_mode: bool) -> ! { + 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 message = e + .to_string() + .lines() + .next() + .unwrap_or("Parse error") + .to_string(); + + // Phase 4: Try to suggest similar command for unknown commands + let suggestion = if e.kind() == ErrorKind::InvalidSubcommand { + if let Some(invalid_cmd) = extract_invalid_subcommand(&e) { + suggest_similar_command(&invalid_cmd) + } else { + "Run 'lore robot-docs' for valid commands".to_string() + } + } else { + "Run 'lore robot-docs' for valid commands".to_string() + }; + + let output = RobotErrorWithSuggestion { + error: RobotErrorSuggestionData { + code: error_code.to_string(), + message, + suggestion, + }, + }; + 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 { + // 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 +} + +/// Phase 4: Suggest similar command using fuzzy matching +fn suggest_similar_command(invalid: &str) -> String { + const VALID_COMMANDS: &[&str] = &[ + "issues", + "mrs", + "search", + "sync", + "ingest", + "count", + "status", + "auth", + "doctor", + "version", + "init", + "stats", + "generate-docs", + "embed", + "migrate", + "health", + "robot-docs", + "completions", + ]; + + let invalid_lower = invalid.to_lowercase(); + + // Find the best match using Jaro-Winkler similarity + let best_match = VALID_COMMANDS + .iter() + .map(|cmd| (*cmd, jaro_winkler(&invalid_lower, cmd))) + .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 + { + return format!( + "Did you mean 'lore {}'? Run 'lore robot-docs' for all commands", + cmd + ); + } + + "Run 'lore robot-docs' for valid commands".to_string() +} + fn handle_issues( config_override: Option<&str>, args: IssuesArgs, robot_mode: bool, ) -> Result<(), Box> { + // Warn about unimplemented --fields + if args.fields.is_some() && !robot_mode { + eprintln!( + "{}", + style("warning: --fields is not yet implemented, showing all fields").yellow() + ); + } + let config = Config::load(config_override)?; let asc = args.asc && !args.no_asc; let has_due = args.has_due && !args.no_has_due; @@ -391,6 +547,14 @@ fn handle_mrs( args: MrsArgs, robot_mode: bool, ) -> Result<(), Box> { + // Warn about unimplemented --fields + if args.fields.is_some() && !robot_mode { + eprintln!( + "{}", + style("warning: --fields is not yet implemented, showing all fields").yellow() + ); + } + let config = Config::load(config_override)?; let asc = args.asc && !args.no_asc; let open = args.open && !args.no_open; @@ -442,16 +606,47 @@ async fn handle_ingest( quiet: bool, metrics: &MetricsLayer, ) -> Result<(), Box> { + let dry_run = args.dry_run && !args.no_dry_run; let config = Config::load(config_override)?; + + let force = args.force && !args.no_force; + let full = args.full && !args.no_full; + + // Handle dry run mode - show preview without making any changes + if dry_run { + match args.entity.as_deref() { + Some(resource_type) => { + let preview = + run_ingest_dry_run(&config, resource_type, args.project.as_deref(), full)?; + if robot_mode { + print_dry_run_preview_json(&preview); + } else { + print_dry_run_preview(&preview); + } + } + None => { + let issues_preview = + run_ingest_dry_run(&config, "issues", args.project.as_deref(), full)?; + let mrs_preview = + run_ingest_dry_run(&config, "mrs", args.project.as_deref(), full)?; + if robot_mode { + print_combined_dry_run_json(&issues_preview, &mrs_preview); + } else { + print_dry_run_preview(&issues_preview); + println!(); + print_dry_run_preview(&mrs_preview); + } + } + } + return Ok(()); + } + let display = if robot_mode || quiet { IngestDisplay::silent() } else { IngestDisplay::interactive() }; - let force = args.force && !args.no_force; - let full = args.full && !args.no_full; - let entity_label = args.entity.as_deref().unwrap_or("all"); let command = format!("ingest:{entity_label}"); let db_path = get_db_path(config.storage.db_path.as_deref()); @@ -469,6 +664,7 @@ async fn handle_ingest( args.project.as_deref(), force, full, + false, display, None, ) @@ -495,6 +691,7 @@ async fn handle_ingest( args.project.as_deref(), force, full, + false, display, None, ) @@ -506,6 +703,7 @@ async fn handle_ingest( args.project.as_deref(), force, full, + false, display, None, ) @@ -592,6 +790,35 @@ fn print_combined_ingest_json( println!("{}", serde_json::to_string(&output).unwrap()); } +#[derive(Serialize)] +struct CombinedDryRunOutput { + ok: bool, + dry_run: bool, + data: CombinedDryRunData, +} + +#[derive(Serialize)] +struct CombinedDryRunData { + issues: lore::cli::commands::DryRunPreview, + merge_requests: lore::cli::commands::DryRunPreview, +} + +fn print_combined_dry_run_json( + issues: &lore::cli::commands::DryRunPreview, + mrs: &lore::cli::commands::DryRunPreview, +) { + let output = CombinedDryRunOutput { + ok: true, + dry_run: true, + data: CombinedDryRunData { + issues: issues.clone(), + merge_requests: mrs.clone(), + }, + }; + + println!("{}", serde_json::to_string(&output).unwrap()); +} + async fn handle_count( config_override: Option<&str>, args: CountArgs, @@ -921,6 +1148,18 @@ async fn handle_auth_test( } } +#[derive(Serialize)] +struct DoctorOutput { + ok: bool, + data: DoctorData, +} + +#[derive(Serialize)] +struct DoctorData { + success: bool, + checks: lore::cli::commands::DoctorChecks, +} + async fn handle_doctor( config_override: Option<&str>, robot_mode: bool, @@ -928,7 +1167,14 @@ async fn handle_doctor( let result = run_doctor(config_override).await; if robot_mode { - println!("{}", serde_json::to_string_pretty(&result)?); + let output = DoctorOutput { + ok: true, + data: DoctorData { + success: result.success, + checks: result.checks, + }, + }; + println!("{}", serde_json::to_string(&output)?); } else { print_doctor_results(&result); } @@ -1133,9 +1379,10 @@ async fn handle_stats( args: StatsArgs, robot_mode: bool, ) -> Result<(), Box> { + let dry_run = args.dry_run && !args.no_dry_run; let config = Config::load(config_override)?; let check = (args.check && !args.no_check) || args.repair; - let result = run_stats(&config, check, args.repair)?; + let result = run_stats(&config, check, args.repair, dry_run)?; if robot_mode { print_stats_json(&result); } else { @@ -1219,6 +1466,8 @@ async fn handle_sync_cmd( robot_mode: bool, metrics: &MetricsLayer, ) -> Result<(), Box> { + let dry_run = args.dry_run && !args.no_dry_run; + let mut config = Config::load(config_override)?; if args.no_events { config.sync.fetch_resource_events = false; @@ -1230,8 +1479,15 @@ async fn handle_sync_cmd( no_docs: args.no_docs, no_events: args.no_events, robot_mode, + dry_run, }; + // For dry_run, skip recording and just show the preview + if dry_run { + run_sync(&config, options, None).await?; + return Ok(()); + } + let db_path = get_db_path(config.storage.db_path.as_deref()); let recorder_conn = create_connection(&db_path)?; let run_id = uuid::Uuid::new_v4().simple().to_string(); @@ -1371,7 +1627,11 @@ struct RobotDocsData { description: String, activation: RobotDocsActivation, commands: serde_json::Value, + /// Deprecated command aliases (old -> new) + aliases: serde_json::Value, exit_codes: serde_json::Value, + /// Error codes emitted by clap parse failures + clap_error_codes: serde_json::Value, error_format: String, workflows: serde_json::Value, } @@ -1410,37 +1670,37 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box> }, "ingest": { "description": "Sync data from GitLab", - "flags": ["--project ", "--force", "--full", ""], + "flags": ["--project ", "--force", "--no-force", "--full", "--no-full", "--dry-run", "--no-dry-run", ""], "example": "lore --robot ingest issues --project group/repo" }, "sync": { "description": "Full sync pipeline: ingest -> generate-docs -> embed", - "flags": ["--full", "--force", "--no-embed", "--no-docs"], + "flags": ["--full", "--no-full", "--force", "--no-force", "--no-embed", "--no-docs", "--no-events", "--dry-run", "--no-dry-run"], "example": "lore --robot sync" }, "issues": { "description": "List or show issues", - "flags": ["", "--limit", "--state", "--project", "--author", "--assignee", "--label", "--milestone", "--since", "--due-before", "--has-due", "--sort", "--asc"], + "flags": ["", "-n/--limit", "--fields ", "-s/--state", "-p/--project", "-a/--author", "-A/--assignee", "-l/--label", "-m/--milestone", "--since", "--due-before", "--has-due", "--no-has-due", "--sort", "--asc", "--no-asc", "-o/--open", "--no-open"], "example": "lore --robot issues --state opened --limit 10" }, "mrs": { "description": "List or show merge requests", - "flags": ["", "--limit", "--state", "--project", "--author", "--assignee", "--reviewer", "--label", "--since", "--draft", "--no-draft", "--target", "--source", "--sort", "--asc"], + "flags": ["", "-n/--limit", "--fields ", "-s/--state", "-p/--project", "-a/--author", "-A/--assignee", "-r/--reviewer", "-l/--label", "--since", "-d/--draft", "-D/--no-draft", "--target", "--source", "--sort", "--asc", "--no-asc", "-o/--open", "--no-open"], "example": "lore --robot mrs --state opened" }, "search": { "description": "Search indexed documents (lexical, hybrid, semantic)", - "flags": ["", "--mode", "--type", "--author", "--project", "--label", "--path", "--after", "--updated-after", "--limit", "--explain", "--fts-mode"], + "flags": ["", "--mode", "--type", "--author", "-p/--project", "--label", "--path", "--after", "--updated-after", "-n/--limit", "--explain", "--no-explain", "--fts-mode"], "example": "lore --robot search 'authentication bug' --mode hybrid --limit 10" }, "count": { "description": "Count entities in local database", - "flags": ["", "--for "], + "flags": ["", "-f/--for "], "example": "lore --robot count issues" }, "stats": { "description": "Show document and index statistics", - "flags": ["--check", "--repair"], + "flags": ["--check", "--no-check", "--repair", "--dry-run", "--no-dry-run"], "example": "lore --robot stats" }, "status": { @@ -1450,12 +1710,12 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box> }, "generate-docs": { "description": "Generate searchable documents from ingested data", - "flags": ["--full", "--project "], + "flags": ["--full", "-p/--project "], "example": "lore --robot generate-docs --full" }, "embed": { "description": "Generate vector embeddings for documents via Ollama", - "flags": ["--full", "--retry-failed"], + "flags": ["--full", "--no-full", "--retry-failed", "--no-retry-failed"], "example": "lore --robot embed" }, "migrate": { @@ -1468,6 +1728,11 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box> "flags": [], "example": "lore --robot version" }, + "completions": { + "description": "Generate shell completions", + "flags": [""], + "example": "lore completions bash > ~/.local/share/bash-completion/completions/lore" + }, "robot-docs": { "description": "This command (agent self-discovery manifest)", "flags": [], @@ -1515,6 +1780,30 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box> ] }); + // Phase 3: Deprecated command aliases + let aliases = serde_json::json!({ + "list issues": "issues", + "list mrs": "mrs", + "show issue ": "issues ", + "show mr ": "mrs ", + "auth-test": "auth", + "sync-status": "status" + }); + + // Phase 3: Clap error codes (emitted by handle_clap_error) + let clap_error_codes = serde_json::json!({ + "UNKNOWN_COMMAND": "Unrecognized subcommand (includes fuzzy suggestion)", + "UNKNOWN_FLAG": "Unrecognized command-line flag", + "MISSING_REQUIRED": "Required argument not provided", + "INVALID_VALUE": "Invalid value for argument", + "TOO_MANY_VALUES": "Too many values provided", + "TOO_FEW_VALUES": "Too few values provided", + "ARGUMENT_CONFLICT": "Conflicting arguments", + "MISSING_COMMAND": "No subcommand provided (in non-robot mode, shows help)", + "HELP_REQUESTED": "Help or version flag used", + "PARSE_ERROR": "General parse error" + }); + let output = RobotDocsOutput { ok: true, data: RobotDocsData { @@ -1527,7 +1816,9 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box> auto: "Non-TTY stdout".to_string(), }, commands, + aliases, exit_codes, + clap_error_codes, error_format: "stderr JSON: {\"error\":{\"code\":\"...\",\"message\":\"...\",\"suggestion\":\"...\"}}".to_string(), workflows, }, @@ -1639,14 +1930,14 @@ async fn handle_show_compat( entity: &str, iid: i64, project_filter: Option<&str>, - json: bool, + robot_mode: bool, ) -> Result<(), Box> { let config = Config::load(config_override)?; match entity { "issue" => { let result = run_show_issue(&config, iid, project_filter)?; - if json { + if robot_mode { print_show_issue_json(&result); } else { print_show_issue(&result); @@ -1655,7 +1946,7 @@ async fn handle_show_compat( } "mr" => { let result = run_show_mr(&config, iid, project_filter)?; - if json { + if robot_mode { print_show_mr_json(&result); } else { print_show_mr(&result);