From 3a4fc96558e565d2a01fe3053d49c283021d44dd Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 6 Mar 2026 13:40:44 -0500 Subject: [PATCH] refactor(shutdown): extract 4 identical Ctrl+C handlers into core/shutdown.rs --- src/app/handlers.rs | 1998 +++++++++++++++++++++++++ src/core/mod.rs | 10 - src/core/shutdown.rs | 14 + src/main.rs | 3333 +----------------------------------------- 4 files changed, 2016 insertions(+), 3339 deletions(-) create mode 100644 src/app/handlers.rs diff --git a/src/app/handlers.rs b/src/app/handlers.rs new file mode 100644 index 0000000..2ab2dfd --- /dev/null +++ b/src/app/handlers.rs @@ -0,0 +1,1998 @@ +fn handle_issues( + config_override: Option<&str>, + args: IssuesArgs, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + let project = config.effective_project(args.project.as_deref()); + let asc = args.asc && !args.no_asc; + let has_due = args.has_due && !args.no_has_due; + let open = args.open && !args.no_open; + let order = if asc { "asc" } else { "desc" }; + + if let Some(iid) = args.iid { + let result = run_show_issue(&config, iid, project)?; + if robot_mode { + print_show_issue_json(&result, start.elapsed().as_millis() as u64); + } else { + print_show_issue(&result); + } + } else { + let state_normalized = args.state.as_deref().map(str::to_lowercase); + let filters = ListFilters { + limit: args.limit, + project, + state: state_normalized.as_deref(), + author: args.author.as_deref(), + assignee: args.assignee.as_deref(), + labels: args.label.as_deref(), + milestone: args.milestone.as_deref(), + since: args.since.as_deref(), + due_before: args.due_before.as_deref(), + has_due_date: has_due, + statuses: &args.status, + sort: &args.sort, + order, + }; + + let result = run_list_issues(&config, filters)?; + + if open { + open_issue_in_browser(&result); + } else if robot_mode { + print_list_issues_json( + &result, + start.elapsed().as_millis() as u64, + args.fields.as_deref(), + ); + } else { + print_list_issues(&result); + } + } + + Ok(()) +} + +fn handle_mrs( + config_override: Option<&str>, + args: MrsArgs, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + let project = config.effective_project(args.project.as_deref()); + let asc = args.asc && !args.no_asc; + let open = args.open && !args.no_open; + let order = if asc { "asc" } else { "desc" }; + + if let Some(iid) = args.iid { + let result = run_show_mr(&config, iid, project)?; + if robot_mode { + print_show_mr_json(&result, start.elapsed().as_millis() as u64); + } else { + print_show_mr(&result); + } + } else { + let state_normalized = args.state.as_deref().map(str::to_lowercase); + let filters = MrListFilters { + limit: args.limit, + project, + state: state_normalized.as_deref(), + author: args.author.as_deref(), + assignee: args.assignee.as_deref(), + reviewer: args.reviewer.as_deref(), + labels: args.label.as_deref(), + since: args.since.as_deref(), + draft: args.draft, + no_draft: args.no_draft, + target_branch: args.target.as_deref(), + source_branch: args.source.as_deref(), + sort: &args.sort, + order, + }; + + let result = run_list_mrs(&config, filters)?; + + if open { + open_mr_in_browser(&result); + } else if robot_mode { + print_list_mrs_json( + &result, + start.elapsed().as_millis() as u64, + args.fields.as_deref(), + ); + } else { + print_list_mrs(&result); + } + } + + Ok(()) +} + +fn handle_notes( + config_override: Option<&str>, + args: NotesArgs, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + + let order = if args.asc { "asc" } else { "desc" }; + let filters = NoteListFilters { + limit: args.limit, + project: args.project, + author: args.author, + note_type: args.note_type, + include_system: args.include_system, + for_issue_iid: args.for_issue, + for_mr_iid: args.for_mr, + note_id: args.note_id, + gitlab_note_id: args.gitlab_note_id, + discussion_id: args.discussion_id, + since: args.since, + until: args.until, + path: args.path, + contains: args.contains, + resolution: args.resolution, + sort: args.sort, + order: order.to_string(), + }; + + let result = query_notes(&conn, &filters, &config)?; + + if robot_mode { + print_list_notes_json( + &result, + start.elapsed().as_millis() as u64, + args.fields.as_deref(), + ); + } else { + print_list_notes(&result); + } + + Ok(()) +} + +async fn handle_ingest( + config_override: Option<&str>, + args: IngestArgs, + robot_mode: bool, + quiet: bool, + metrics: &MetricsLayer, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let dry_run = args.dry_run && !args.no_dry_run; + let config = Config::load(config_override)?; + let project = config.effective_project(args.project.as_deref()); + + 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, project, 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", project, full)?; + let mrs_preview = run_ingest_dry_run(&config, "mrs", project, 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 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()); + let recorder_conn = create_connection(&db_path)?; + let run_id = uuid::Uuid::new_v4().simple().to_string(); + let run_id_short = &run_id[..8]; + let recorder = SyncRunRecorder::start(&recorder_conn, &command, run_id_short)?; + + let signal = ShutdownSignal::new(); + install_ctrl_c_handler(signal.clone()); + + let ingest_result: std::result::Result<(), Box> = async { + match args.entity.as_deref() { + Some(resource_type) => { + let result = run_ingest( + &config, + resource_type, + project, + force, + full, + false, + display, + None, + &signal, + ) + .await?; + + if robot_mode { + print_ingest_summary_json(&result, start.elapsed().as_millis() as u64); + } else { + print_ingest_summary(&result); + } + } + None => { + if !robot_mode && !quiet { + println!( + "{}", + Theme::info().render("Ingesting all content (issues + merge requests)...") + ); + println!(); + } + + let issues_result = run_ingest( + &config, "issues", project, force, full, false, display, None, &signal, + ) + .await?; + + let mrs_result = run_ingest( + &config, "mrs", project, force, full, false, display, None, &signal, + ) + .await?; + + if robot_mode { + print_combined_ingest_json( + &issues_result, + &mrs_result, + start.elapsed().as_millis() as u64, + ); + } else { + print_ingest_summary(&issues_result); + print_ingest_summary(&mrs_result); + } + } + } + Ok(()) + } + .await; + + match ingest_result { + Ok(()) if signal.is_cancelled() => { + let stages = metrics.extract_timings(); + let _ = release_all_locked_jobs(&recorder_conn); + let _ = recorder.fail( + &recorder_conn, + "Interrupted by user (Ctrl+C)", + Some(&stages), + ); + if !robot_mode { + eprintln!( + "{}", + Theme::warning().render("Interrupted by Ctrl+C. Partial data has been saved.") + ); + } + Ok(()) + } + Ok(()) => { + let stages = metrics.extract_timings(); + let total_items: usize = stages.iter().map(|s| s.items_processed).sum(); + let total_errors: usize = stages.iter().map(|s| s.errors).sum(); + let _ = recorder.succeed(&recorder_conn, &stages, total_items, total_errors); + if !robot_mode && !quiet { + eprintln!( + "{}", + Theme::dim().render("Hint: Run 'lore generate-docs' to update searchable documents, then 'lore embed' for vectors.") + ); + } + Ok(()) + } + Err(e) => { + let stages = metrics.extract_timings(); + let _ = release_all_locked_jobs(&recorder_conn); + let _ = recorder.fail(&recorder_conn, &e.to_string(), Some(&stages)); + Err(e) + } + } +} + +#[derive(Serialize)] +struct CombinedIngestOutput { + ok: bool, + data: CombinedIngestData, + meta: RobotMeta, +} + +#[derive(Serialize)] +struct CombinedIngestData { + resource_type: String, + issues: CombinedIngestEntityStats, + merge_requests: CombinedIngestEntityStats, +} + +#[derive(Serialize)] +struct CombinedIngestEntityStats { + projects_synced: usize, + fetched: usize, + upserted: usize, + labels_created: usize, + discussions_fetched: usize, + notes_upserted: usize, +} + +fn print_combined_ingest_json( + issues: &lore::cli::commands::ingest::IngestResult, + mrs: &lore::cli::commands::ingest::IngestResult, + elapsed_ms: u64, +) { + let output = CombinedIngestOutput { + ok: true, + data: CombinedIngestData { + resource_type: "all".to_string(), + issues: CombinedIngestEntityStats { + projects_synced: issues.projects_synced, + fetched: issues.issues_fetched, + upserted: issues.issues_upserted, + labels_created: issues.labels_created, + discussions_fetched: issues.discussions_fetched, + notes_upserted: issues.notes_upserted, + }, + merge_requests: CombinedIngestEntityStats { + projects_synced: mrs.projects_synced, + fetched: mrs.mrs_fetched, + upserted: mrs.mrs_upserted, + labels_created: mrs.labels_created, + discussions_fetched: mrs.discussions_fetched, + notes_upserted: mrs.notes_upserted, + }, + }, + meta: RobotMeta { elapsed_ms }, + }; + + println!( + "{}", + serde_json::to_string(&output).unwrap_or_else(|e| { + format!(r#"{{"ok":false,"error":{{"code":"INTERNAL_ERROR","message":"JSON serialization failed: {e}"}}}}"#) + }) + ); +} + +#[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_or_else(|e| { + format!(r#"{{"ok":false,"error":{{"code":"INTERNAL_ERROR","message":"JSON serialization failed: {e}"}}}}"#) + }) + ); +} + +async fn handle_count( + config_override: Option<&str>, + args: CountArgs, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + + if args.entity == "events" { + let counts = run_count_events(&config)?; + if robot_mode { + print_event_count_json(&counts, start.elapsed().as_millis() as u64); + } else { + print_event_count(&counts); + } + return Ok(()); + } + + let result = run_count(&config, &args.entity, args.for_entity.as_deref())?; + if robot_mode { + print_count_json(&result, start.elapsed().as_millis() as u64); + } else { + print_count(&result); + } + Ok(()) +} + +async fn handle_sync_status_cmd( + config_override: Option<&str>, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + + let result = run_sync_status(&config)?; + if robot_mode { + print_sync_status_json(&result, start.elapsed().as_millis() as u64); + } else { + print_sync_status(&result); + } + Ok(()) +} + +#[derive(Serialize)] +struct InitOutput { + ok: bool, + data: InitOutputData, +} + +#[derive(Serialize)] +struct InitOutputData { + config_path: String, + data_dir: String, + user: InitOutputUser, + projects: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + default_project: Option, +} + +#[derive(Serialize)] +struct InitOutputUser { + username: String, + name: String, +} + +#[derive(Serialize)] +struct InitOutputProject { + path: String, + name: String, +} + +fn print_init_json(result: &InitResult) { + let output = InitOutput { + ok: true, + data: InitOutputData { + config_path: result.config_path.clone(), + data_dir: result.data_dir.clone(), + user: InitOutputUser { + username: result.user.username.clone(), + name: result.user.name.clone(), + }, + projects: result + .projects + .iter() + .map(|p| InitOutputProject { + path: p.path.clone(), + name: p.name.clone(), + }) + .collect(), + default_project: result.default_project.clone(), + }, + }; + println!( + "{}", + serde_json::to_string(&output).unwrap_or_else(|e| { + format!(r#"{{"ok":false,"error":{{"code":"INTERNAL_ERROR","message":"JSON serialization failed: {e}"}}}}"#) + }) + ); +} + +// ── Refresh JSON types ── + +#[derive(Serialize)] +struct RefreshOutput { + ok: bool, + data: RefreshOutputData, +} + +#[derive(Serialize)] +struct RefreshOutputData { + mode: &'static str, + user: InitOutputUser, + projects_registered: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + projects_failed: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + orphans_found: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + orphans_deleted: Vec, +} + +#[derive(Serialize)] +struct RefreshOutputFailure { + path: String, + error: String, +} + +fn print_refresh_json(result: &RefreshResult) { + let output = RefreshOutput { + ok: true, + data: RefreshOutputData { + mode: "refresh", + user: InitOutputUser { + username: result.user.username.clone(), + name: result.user.name.clone(), + }, + projects_registered: result + .projects_registered + .iter() + .map(|p| InitOutputProject { + path: p.path.clone(), + name: p.name.clone(), + }) + .collect(), + projects_failed: result + .projects_failed + .iter() + .map(|p| RefreshOutputFailure { + path: p.path.clone(), + error: p.error.clone(), + }) + .collect(), + orphans_found: result.orphans_found.clone(), + orphans_deleted: result.orphans_deleted.clone(), + }, + }; + println!( + "{}", + serde_json::to_string(&output).unwrap_or_else(|e| { + format!(r#"{{"ok":false,"error":{{"code":"INTERNAL_ERROR","message":"JSON serialization failed: {e}"}}}}"#) + }) + ); +} + +async fn handle_init_refresh( + config_override: Option<&str>, + non_interactive: bool, + robot_mode: bool, +) -> Result<(), Box> { + let mut result = run_init_refresh(RefreshOptions { + config_path: config_override.map(String::from), + non_interactive, + }) + .await?; + + // Handle orphan deletion prompt (interactive only) + let mut orphans_deleted: Vec = Vec::new(); + if !result.orphans_found.is_empty() && !robot_mode && !non_interactive { + println!( + "\n{}", + Theme::warning().render(&format!( + "Found {} orphan project{} in database (not in config):", + result.orphans_found.len(), + if result.orphans_found.len() == 1 { + "" + } else { + "s" + } + )) + ); + for orphan in &result.orphans_found { + println!(" {}", Theme::muted().render(&format!("• {orphan}"))); + } + println!(); + + let confirm = Confirm::new() + .with_prompt(format!( + "Delete {} orphan project{} from database?", + result.orphans_found.len(), + if result.orphans_found.len() == 1 { + "" + } else { + "s" + } + )) + .default(false) + .interact()?; + + if confirm { + let deleted = delete_orphan_projects(config_override, &result.orphans_found)?; + orphans_deleted = result.orphans_found.clone(); + println!( + "{}", + Theme::success().render(&format!(" Deleted {deleted} orphan project(s)")) + ); + } + } + result.orphans_deleted = orphans_deleted; + + if robot_mode { + print_refresh_json(&result); + return Ok(()); + } + + // Human output + println!( + "\n{}", + Theme::success().render(&format!( + "\u{2713} Authenticated as @{} ({})", + result.user.username, result.user.name + )) + ); + + if !result.projects_registered.is_empty() { + println!("\n {}", Theme::bold().render("Projects")); + for project in &result.projects_registered { + println!( + " {} {:<40} registered", + Theme::success().render("\u{2713}"), + project.path + ); + } + } + + if !result.projects_failed.is_empty() { + for failure in &result.projects_failed { + println!( + " {} {:<40} {}", + Theme::error().render("\u{2717}"), + failure.path, + failure.error + ); + } + } + + // Summary + let registered = result.projects_registered.len(); + let failed = result.projects_failed.len(); + let orphans_kept = result.orphans_found.len() - result.orphans_deleted.len(); + + let mut summary_parts: Vec = Vec::new(); + summary_parts.push(format!( + "{} project{} registered", + registered, + if registered == 1 { "" } else { "s" } + )); + if failed > 0 { + summary_parts.push(format!("{failed} failed")); + } + if !result.orphans_deleted.is_empty() { + summary_parts.push(format!( + "{} orphan(s) deleted", + result.orphans_deleted.len() + )); + } + if orphans_kept > 0 { + summary_parts.push(format!("{orphans_kept} orphan(s) kept")); + } + + println!( + "\n{}", + Theme::info().render(&format!(" {}", summary_parts.join(", "))) + ); + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn handle_init( + config_override: Option<&str>, + refresh: bool, + force: bool, + non_interactive: bool, + robot_mode: bool, + gitlab_url_flag: Option, + token_env_var_flag: Option, + projects_flag: Option, + default_project_flag: Option, +) -> Result<(), Box> { + // ── Handle --refresh mode ── + if refresh { + return handle_init_refresh(config_override, non_interactive, robot_mode).await; + } + + if robot_mode { + let missing: Vec<&str> = [ + gitlab_url_flag.is_none().then_some("--gitlab-url"), + token_env_var_flag.is_none().then_some("--token-env-var"), + projects_flag.is_none().then_some("--projects"), + ] + .into_iter() + .flatten() + .collect(); + + if !missing.is_empty() { + let output = RobotErrorWithSuggestion { + error: RobotErrorSuggestionData { + code: "MISSING_FLAGS".to_string(), + message: format!("Robot mode requires flags: {}", missing.join(", ")), + suggestion: "lore --robot init --gitlab-url https://gitlab.com --token-env-var GITLAB_TOKEN --projects group/project".to_string(), + correction: None, + valid_values: None, + }, + }; + eprintln!("{}", serde_json::to_string(&output)?); + std::process::exit(2); + } + + let project_paths: Vec = projects_flag + .unwrap() + .split(',') + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect(); + + let result = run_init( + InitInputs { + gitlab_url: gitlab_url_flag.unwrap(), + token_env_var: token_env_var_flag.unwrap(), + project_paths, + default_project: default_project_flag.clone(), + }, + InitOptions { + config_path: config_override.map(String::from), + force: true, + non_interactive: true, + }, + ) + .await?; + + print_init_json(&result); + return Ok(()); + } + + let config_path = get_config_path(config_override); + let mut confirmed_overwrite = force; + + if config_path.exists() && !force { + if non_interactive { + eprintln!( + "{}", + Theme::error().render(&format!( + "Config already exists at {}", + config_path.display() + )) + ); + eprintln!( + "{}", + Theme::info().render(" • Use --refresh to register new projects from config") + ); + eprintln!( + "{}", + Theme::info().render(" • Use --force to overwrite the config file") + ); + std::process::exit(2); + } + + println!( + "{}", + Theme::warning().render(&format!( + "Config already exists at {}", + config_path.display() + )) + ); + println!( + "{}", + Theme::info().render(" • Use --refresh to register new projects from config") + ); + println!(); + + let confirm = Confirm::new() + .with_prompt("Overwrite existing config?") + .default(false) + .interact()?; + + if !confirm { + println!("{}", Theme::warning().render("Cancelled.")); + std::process::exit(2); + } + confirmed_overwrite = true; + } + + let gitlab_url: String = if let Some(url) = gitlab_url_flag { + url + } else { + Input::new() + .with_prompt("GitLab URL") + .default("https://gitlab.com".to_string()) + .validate_with(|input: &String| -> Result<(), &str> { + if url::Url::parse(input).is_ok() { + Ok(()) + } else { + Err("Please enter a valid URL") + } + }) + .interact_text()? + }; + + let token_env_var: String = if let Some(var) = token_env_var_flag { + var + } else { + Input::new() + .with_prompt("Token environment variable name") + .default("GITLAB_TOKEN".to_string()) + .interact_text()? + }; + + let project_paths: Vec = if let Some(projects) = projects_flag { + projects + .split(',') + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect() + } else { + let project_paths_input: String = Input::new() + .with_prompt("Project paths (comma-separated, e.g., group/project)") + .validate_with(|input: &String| -> Result<(), &str> { + if input.trim().is_empty() { + Err("Please enter at least one project path") + } else { + Ok(()) + } + }) + .interact_text()?; + + project_paths_input + .split(',') + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect() + }; + + // Resolve default project: CLI flag, interactive prompt, or None + let default_project = if default_project_flag.is_some() { + default_project_flag + } else if project_paths.len() > 1 && !non_interactive { + let set_default = Confirm::new() + .with_prompt("Set a default project? (used when -p is omitted)") + .default(true) + .interact()?; + + if set_default { + let selection = dialoguer::Select::new() + .with_prompt("Default project") + .items(&project_paths) + .default(0) + .interact()?; + Some(project_paths[selection].clone()) + } else { + None + } + } else { + None + }; + + println!("{}", Theme::info().render("Validating configuration...")); + + let result = run_init( + InitInputs { + gitlab_url, + token_env_var, + project_paths, + default_project, + }, + InitOptions { + config_path: config_override.map(String::from), + force: confirmed_overwrite, + non_interactive, + }, + ) + .await?; + + println!( + "{}", + Theme::success().render(&format!( + "\n\u{2713} Authenticated as @{} ({})", + result.user.username, result.user.name + )) + ); + + for project in &result.projects { + println!( + "{}", + Theme::success().render(&format!("\u{2713} {} ({})", project.path, project.name)) + ); + } + + if let Some(ref dp) = result.default_project { + println!( + "{}", + Theme::success().render(&format!("\u{2713} Default project: {dp}")) + ); + } + + println!( + "{}", + Theme::success().render(&format!( + "\n\u{2713} Config written to {}", + result.config_path + )) + ); + println!( + "{}", + Theme::success().render(&format!( + "\u{2713} Database initialized at {}", + result.data_dir + )) + ); + println!( + "{}", + Theme::info().render("\nSetup complete! Run 'lore doctor' to verify.") + ); + + Ok(()) +} + +#[derive(Serialize)] +struct AuthTestOutput { + ok: bool, + data: AuthTestData, + meta: RobotMeta, +} + +#[derive(Serialize)] +struct AuthTestData { + authenticated: bool, + username: String, + name: String, + gitlab_url: String, +} + +async fn handle_auth_test( + config_override: Option<&str>, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + match run_auth_test(config_override).await { + Ok(result) => { + if robot_mode { + let output = AuthTestOutput { + ok: true, + data: AuthTestData { + authenticated: true, + username: result.username.clone(), + name: result.name.clone(), + gitlab_url: result.base_url.clone(), + }, + meta: RobotMeta { + elapsed_ms: start.elapsed().as_millis() as u64, + }, + }; + println!("{}", serde_json::to_string(&output)?); + } else { + println!("Authenticated as @{} ({})", result.username, result.name); + println!("GitLab: {}", result.base_url); + } + Ok(()) + } + Err(e) => { + if robot_mode { + let output = RobotErrorOutput::from(&e); + eprintln!( + "{}", + serde_json::to_string(&output).unwrap_or_else(|_| { + let msg = e.to_string().replace('\\', "\\\\").replace('"', "\\\""); + format!( + r#"{{"error":{{"code":"{}","message":"{}"}}}}"#, + e.code(), + msg + ) + }) + ); + } else { + eprintln!("{} {}", Theme::error().render("Error:"), e); + if let Some(suggestion) = e.suggestion() { + eprintln!("{} {}", Theme::warning().render("Hint:"), suggestion); + } + } + std::process::exit(e.exit_code()); + } + } +} + +#[derive(Serialize)] +struct DoctorOutput { + ok: bool, + data: DoctorData, + meta: RobotMeta, +} + +#[derive(Serialize)] +struct DoctorData { + success: bool, + checks: lore::cli::commands::DoctorChecks, +} + +async fn handle_doctor( + config_override: Option<&str>, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let result = run_doctor(config_override).await; + + if robot_mode { + let output = DoctorOutput { + ok: true, + data: DoctorData { + success: result.success, + checks: result.checks, + }, + meta: RobotMeta { + elapsed_ms: start.elapsed().as_millis() as u64, + }, + }; + println!("{}", serde_json::to_string(&output)?); + } else { + print_doctor_results(&result); + } + + if !result.success { + std::process::exit(1); + } + + Ok(()) +} + +#[derive(Serialize)] +struct VersionOutput { + ok: bool, + data: VersionData, + meta: RobotMeta, +} + +#[derive(Serialize)] +struct VersionData { + name: &'static str, + version: String, + #[serde(skip_serializing_if = "Option::is_none")] + git_hash: Option, +} + +fn handle_version(robot_mode: bool) -> Result<(), Box> { + let start = std::time::Instant::now(); + let version = env!("CARGO_PKG_VERSION").to_string(); + let git_hash = env!("GIT_HASH").to_string(); + if robot_mode { + let output = VersionOutput { + ok: true, + data: VersionData { + name: "lore", + version, + git_hash: if git_hash.is_empty() { + None + } else { + Some(git_hash) + }, + }, + meta: RobotMeta { + elapsed_ms: start.elapsed().as_millis() as u64, + }, + }; + println!("{}", serde_json::to_string(&output)?); + } else if git_hash.is_empty() { + println!("lore version {}", version); + } else { + println!("lore version {} ({})", version, git_hash); + } + Ok(()) +} + +fn handle_completions(shell: &str) -> Result<(), Box> { + use clap::CommandFactory; + use clap_complete::{Shell, generate}; + + let shell = match shell { + "bash" => Shell::Bash, + "zsh" => Shell::Zsh, + "fish" => Shell::Fish, + "powershell" => Shell::PowerShell, + other => { + return Err(format!("Unsupported shell: {other}").into()); + } + }; + + let mut cmd = Cli::command(); + generate(shell, &mut cmd, "lore", &mut std::io::stdout()); + Ok(()) +} + +fn handle_backup(robot_mode: bool) -> Result<(), Box> { + if robot_mode { + let output = RobotErrorWithSuggestion { + error: RobotErrorSuggestionData { + code: "NOT_IMPLEMENTED".to_string(), + message: "The 'backup' command is not yet implemented.".to_string(), + suggestion: "Use manual database backup: cp ~/.local/share/lore/lore.db ~/.local/share/lore/lore.db.bak".to_string(), + correction: None, + valid_values: None, + }, + }; + eprintln!("{}", serde_json::to_string(&output)?); + } else { + eprintln!( + "{} The 'backup' command is not yet implemented.", + Theme::error().render("Error:") + ); + } + std::process::exit(1); +} + +fn handle_reset(robot_mode: bool) -> Result<(), Box> { + if robot_mode { + let output = RobotErrorWithSuggestion { + error: RobotErrorSuggestionData { + code: "NOT_IMPLEMENTED".to_string(), + message: "The 'reset' command is not yet implemented.".to_string(), + suggestion: "Manually delete the database: rm ~/.local/share/lore/lore.db" + .to_string(), + correction: None, + valid_values: None, + }, + }; + eprintln!("{}", serde_json::to_string(&output)?); + } else { + eprintln!( + "{} The 'reset' command is not yet implemented.", + Theme::error().render("Error:") + ); + } + std::process::exit(1); +} + +#[derive(Serialize)] +struct MigrateOutput { + ok: bool, + data: MigrateData, + meta: RobotMeta, +} + +#[derive(Serialize)] +struct MigrateData { + before_version: i32, + after_version: i32, + migrated: bool, +} + +#[derive(Serialize)] +struct RobotErrorWithSuggestion { + error: RobotErrorSuggestionData, +} + +#[derive(Serialize)] +struct RobotErrorSuggestionData { + code: String, + message: String, + suggestion: String, + #[serde(skip_serializing_if = "Option::is_none")] + correction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + valid_values: Option>, +} + +async fn handle_migrate( + config_override: Option<&str>, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + let db_path = get_db_path(config.storage.db_path.as_deref()); + + if !db_path.exists() { + if robot_mode { + let output = RobotErrorWithSuggestion { + error: RobotErrorSuggestionData { + code: "DB_ERROR".to_string(), + message: format!("Database not found at {}", db_path.display()), + suggestion: "Run 'lore init' first".to_string(), + correction: None, + valid_values: None, + }, + }; + eprintln!("{}", serde_json::to_string(&output)?); + } else { + eprintln!( + "{}", + Theme::error().render(&format!("Database not found at {}", db_path.display())) + ); + eprintln!( + "{}", + Theme::warning().render("Run 'lore init' first to create the database.") + ); + } + std::process::exit(10); + } + + let conn = create_connection(&db_path)?; + let before_version = get_schema_version(&conn); + + if !robot_mode { + println!( + "{}", + Theme::info().render(&format!("Current schema version: {}", before_version)) + ); + } + + run_migrations(&conn)?; + + let after_version = get_schema_version(&conn); + + if robot_mode { + let output = MigrateOutput { + ok: true, + data: MigrateData { + before_version, + after_version, + migrated: after_version > before_version, + }, + meta: RobotMeta { + elapsed_ms: start.elapsed().as_millis() as u64, + }, + }; + println!("{}", serde_json::to_string(&output)?); + } else if after_version > before_version { + println!( + "{}", + Theme::success().render(&format!( + "Migrations applied: {} -> {}", + before_version, after_version + )) + ); + } else { + println!( + "{}", + Theme::success().render("Database is already up to date.") + ); + } + + Ok(()) +} + +async fn handle_stats( + config_override: Option<&str>, + args: StatsArgs, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + 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, dry_run)?; + if robot_mode { + print_stats_json(&result, start.elapsed().as_millis() as u64); + } else { + print_stats(&result); + } + Ok(()) +} + +fn handle_file_history( + config_override: Option<&str>, + args: FileHistoryArgs, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + + let project = config + .effective_project(args.project.as_deref()) + .map(String::from); + + let normalized = normalize_repo_path(&args.path); + + // Resolve bare filenames before querying (same path resolution as trace/who) + let db_path_tmp = get_db_path(config.storage.db_path.as_deref()); + let conn_tmp = create_connection(&db_path_tmp)?; + let project_id_tmp = project + .as_deref() + .map(|p| resolve_project(&conn_tmp, p)) + .transpose()?; + let pq = build_path_query(&conn_tmp, &normalized, project_id_tmp)?; + let resolved_path = if pq.is_prefix { + // Directory prefix — file-history is file-oriented, pass the raw path. + // Don't use pq.value which contains LIKE-escaped metacharacters. + normalized.trim_end_matches('/').to_string() + } else { + pq.value + }; + + let result = run_file_history( + &config, + &resolved_path, + project.as_deref(), + args.no_follow_renames, + args.merged, + args.discussions, + args.limit, + )?; + + if robot_mode { + let elapsed_ms = start.elapsed().as_millis() as u64; + print_file_history_json(&result, elapsed_ms); + } else { + print_file_history(&result); + } + Ok(()) +} + +fn handle_trace( + config_override: Option<&str>, + args: TraceArgs, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + + let (raw_path, line_requested) = parse_trace_path(&args.path); + let normalized = normalize_repo_path(&raw_path); + + if line_requested.is_some() && !robot_mode { + eprintln!( + "Note: Line-level tracing requires Tier 2 (git blame). Showing file-level results." + ); + } + + let project = config + .effective_project(args.project.as_deref()) + .map(String::from); + + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + let project_id = project + .as_deref() + .map(|p| resolve_project(&conn, p)) + .transpose()?; + + // Resolve bare filenames (e.g. "operators.ts" -> "src/utils/operators.ts") + let pq = build_path_query(&conn, &normalized, project_id)?; + let path = if pq.is_prefix { + // Directory prefix — trace is file-oriented, pass the raw path. + // Don't use pq.value which contains LIKE-escaped metacharacters. + normalized.trim_end_matches('/').to_string() + } else { + pq.value + }; + + let result = run_trace( + &conn, + project_id, + &path, + !args.no_follow_renames, + args.discussions, + args.limit, + )?; + + if robot_mode { + let elapsed_ms = start.elapsed().as_millis() as u64; + print_trace_json(&result, elapsed_ms, line_requested); + } else { + print_trace(&result); + } + Ok(()) +} + +async fn handle_timeline( + config_override: Option<&str>, + args: TimelineArgs, + robot_mode: bool, +) -> Result<(), Box> { + let config = Config::load(config_override)?; + + let params = TimelineParams { + query: args.query, + project: config + .effective_project(args.project.as_deref()) + .map(String::from), + since: args.since, + depth: args.depth, + no_mentions: args.no_mentions, + limit: args.limit, + max_seeds: args.max_seeds, + max_entities: args.max_entities, + max_evidence: args.max_evidence, + robot_mode, + }; + + let result = run_timeline(&config, ¶ms).await?; + + if robot_mode { + print_timeline_json_with_meta( + &result, + result.total_filtered_events, + params.depth, + !params.no_mentions, + args.fields.as_deref(), + ); + } else { + print_timeline(&result); + } + Ok(()) +} + +async fn handle_search( + config_override: Option<&str>, + args: SearchArgs, + robot_mode: bool, +) -> Result<(), Box> { + let config = Config::load(config_override)?; + let explain = args.explain && !args.no_explain; + + let fts_mode = match args.fts_mode.as_str() { + "raw" => lore::search::FtsQueryMode::Raw, + _ => lore::search::FtsQueryMode::Safe, + }; + + let cli_filters = SearchCliFilters { + source_type: args.source_type, + author: args.author, + project: config + .effective_project(args.project.as_deref()) + .map(String::from), + labels: args.label, + path: args.path, + since: args.since, + updated_since: args.updated_since, + limit: args.limit, + }; + + let spinner = lore::cli::progress::stage_spinner_v2( + lore::cli::render::Icons::search(), + "Search", + &format!("Searching ({})...", args.mode), + robot_mode, + ); + let start = std::time::Instant::now(); + let response = run_search( + &config, + &args.query, + cli_filters, + fts_mode, + &args.mode, + explain, + ) + .await?; + let elapsed_ms = start.elapsed().as_millis() as u64; + spinner.finish_and_clear(); + + if robot_mode { + print_search_results_json(&response, elapsed_ms, args.fields.as_deref()); + } else { + print_search_results(&response); + } + Ok(()) +} + +async fn handle_generate_docs( + config_override: Option<&str>, + args: GenerateDocsArgs, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + + let project = config.effective_project(args.project.as_deref()); + let result = run_generate_docs(&config, args.full, project, None)?; + let elapsed = start.elapsed(); + if robot_mode { + print_generate_docs_json(&result, elapsed.as_millis() as u64); + } else { + print_generate_docs(&result); + if elapsed.as_secs() >= 1 { + eprintln!( + "{}", + Theme::dim().render(&format!(" Done in {:.1}s", elapsed.as_secs_f64())) + ); + } + if result.regenerated > 0 { + eprintln!( + "{}", + Theme::dim().render( + "Hint: Run 'lore embed' to update vector embeddings for changed documents." + ) + ); + } + } + Ok(()) +} + +async fn handle_embed( + config_override: Option<&str>, + args: EmbedArgs, + robot_mode: bool, +) -> Result<(), Box> { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + let full = args.full && !args.no_full; + let retry_failed = args.retry_failed && !args.no_retry_failed; + + let signal = ShutdownSignal::new(); + install_ctrl_c_handler(signal.clone()); + + let embed_bar = lore::cli::progress::nested_progress("Embedding", 0, robot_mode); + let bar_clone = embed_bar.clone(); + let tick_started = Arc::new(AtomicBool::new(false)); + let tick_clone = Arc::clone(&tick_started); + let progress_cb: Box = Box::new(move |processed, total| { + if total > 0 { + if !tick_clone.swap(true, Ordering::Relaxed) { + bar_clone.enable_steady_tick(std::time::Duration::from_millis(100)); + } + bar_clone.set_length(total as u64); + bar_clone.set_position(processed as u64); + } + }); + + let result = run_embed(&config, full, retry_failed, Some(progress_cb), &signal).await?; + embed_bar.finish_and_clear(); + + let elapsed = start.elapsed(); + if robot_mode { + print_embed_json(&result, elapsed.as_millis() as u64); + } else { + print_embed(&result); + if elapsed.as_secs() >= 1 { + eprintln!( + "{}", + Theme::dim().render(&format!(" Done in {:.1}s", elapsed.as_secs_f64())) + ); + } + } + Ok(()) +} + +async fn handle_sync_cmd( + config_override: Option<&str>, + args: SyncArgs, + robot_mode: bool, + metrics: &MetricsLayer, +) -> Result<(), Box> { + let dry_run = args.dry_run && !args.no_dry_run; + + // Dedup and sort IIDs + let mut issue_iids = args.issue; + let mut mr_iids = args.mr; + issue_iids.sort_unstable(); + issue_iids.dedup(); + mr_iids.sort_unstable(); + mr_iids.dedup(); + + let mut config = Config::load(config_override)?; + if args.no_events { + config.sync.fetch_resource_events = false; + } + if args.no_file_changes { + config.sync.fetch_mr_file_changes = false; + } + if args.no_status { + config.sync.fetch_work_item_status = false; + } + let options = SyncOptions { + full: args.full && !args.no_full, + force: args.force && !args.no_force, + no_embed: args.no_embed, + no_docs: args.no_docs, + no_events: args.no_events, + robot_mode, + dry_run, + issue_iids, + mr_iids, + project: args.project, + preflight_only: args.preflight_only, + }; + + // Validation: preflight_only requires surgical mode + if options.preflight_only && !options.is_surgical() { + return Err("--preflight-only requires --issue or --mr".into()); + } + + // Validation: full + surgical are incompatible + if options.full && options.is_surgical() { + return Err("--full and --issue/--mr are incompatible".into()); + } + + // Validation: surgical mode requires a project (via -p or config defaultProject) + if options.is_surgical() + && config + .effective_project(options.project.as_deref()) + .is_none() + { + return Err("--issue/--mr requires -p/--project (or set defaultProject in config)".into()); + } + + // Validation: hard cap on total surgical targets + let total_targets = options.issue_iids.len() + options.mr_iids.len(); + if total_targets > SyncOptions::MAX_SURGICAL_TARGETS { + return Err(format!( + "Too many surgical targets ({total_targets}); maximum is {}", + SyncOptions::MAX_SURGICAL_TARGETS + ) + .into()); + } + + // Surgical + dry-run → treat as preflight-only + let mut options = options; + if dry_run && options.is_surgical() { + options.preflight_only = true; + } + + // Resolve effective project for surgical mode: when -p is not passed but + // defaultProject is set in config, populate options.project so the surgical + // orchestrator receives the resolved project path. + if options.is_surgical() && options.project.is_none() { + options.project = config.default_project.clone(); + } + + // For non-surgical dry run, skip recording and just show the preview + if dry_run && !options.is_surgical() { + let signal = ShutdownSignal::new(); + run_sync(&config, options, None, &signal).await?; + return Ok(()); + } + + // Acquire file lock if --lock was passed (used by cron to skip overlapping runs) + let _sync_lock = if args.lock { + match lore::core::cron::acquire_sync_lock() { + Ok(Some(guard)) => Some(guard), + Ok(None) => { + // Another sync is running — silently exit (expected for cron) + tracing::debug!("--lock: another sync is running, skipping"); + return Ok(()); + } + Err(e) => { + tracing::warn!(error = %e, "--lock: failed to acquire file lock, skipping sync"); + return Ok(()); + } + } + } else { + None + }; + + // Surgical mode: run_sync_surgical manages its own recorder, signal, and recording. + // Skip the normal recorder setup and let the dispatch handle everything. + if options.is_surgical() { + let signal = ShutdownSignal::new(); + install_ctrl_c_handler(signal.clone()); + + let start = std::time::Instant::now(); + match run_sync(&config, options, None, &signal).await { + Ok(result) => { + let elapsed = start.elapsed(); + if robot_mode { + print_sync_json(&result, elapsed.as_millis() as u64, Some(metrics)); + } else { + print_sync(&result, elapsed, Some(metrics), args.timings); + } + return Ok(()); + } + Err(e) => return Err(e.into()), + } + } + + 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(); + let run_id_short = &run_id[..8]; + let recorder = SyncRunRecorder::start(&recorder_conn, "sync", run_id_short)?; + + let signal = ShutdownSignal::new(); + install_ctrl_c_handler(signal.clone()); + + let start = std::time::Instant::now(); + match run_sync(&config, options, Some(run_id_short), &signal).await { + Ok(result) if signal.is_cancelled() => { + let elapsed = start.elapsed(); + let stages = metrics.extract_timings(); + let released = release_all_locked_jobs(&recorder_conn).unwrap_or(0); + let _ = recorder.fail( + &recorder_conn, + "Interrupted by user (Ctrl+C)", + Some(&stages), + ); + + if robot_mode { + print_sync_json(&result, elapsed.as_millis() as u64, Some(metrics)); + } else { + eprintln!(); + eprintln!( + "{}", + Theme::warning().render("Interrupted by Ctrl+C. Partial results:") + ); + print_sync(&result, elapsed, Some(metrics), args.timings); + if released > 0 { + eprintln!( + "{}", + Theme::dim().render(&format!("Released {released} locked jobs")) + ); + } + } + Ok(()) + } + Ok(result) => { + let elapsed = start.elapsed(); + let stages = metrics.extract_timings(); + let total_items = result.issues_updated + + result.mrs_updated + + result.documents_regenerated + + result.documents_embedded; + let total_errors = result.resource_events_failed; + let _ = recorder.succeed(&recorder_conn, &stages, total_items, total_errors); + + if robot_mode { + print_sync_json(&result, elapsed.as_millis() as u64, Some(metrics)); + } else { + print_sync(&result, elapsed, Some(metrics), args.timings); + } + Ok(()) + } + Err(e) => { + let stages = metrics.extract_timings(); + let _ = release_all_locked_jobs(&recorder_conn); + let _ = recorder.fail(&recorder_conn, &e.to_string(), Some(&stages)); + Err(e.into()) + } + } +} + +fn handle_cron( + config_override: Option<&str>, + args: CronArgs, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + + match args.action { + CronAction::Install { interval } => { + let result = run_cron_install(interval)?; + let elapsed_ms = start.elapsed().as_millis() as u64; + if robot_mode { + print_cron_install_json(&result, elapsed_ms); + } else { + print_cron_install(&result); + } + // Warn if no stored token — cron runs in a minimal shell with no env vars + if let Ok(config) = Config::load(config_override) + && config + .gitlab + .token + .as_ref() + .is_none_or(|t| t.trim().is_empty()) + { + if robot_mode { + eprintln!( + "{{\"warning\":\"No stored token found. Cron sync requires a stored token. Run: lore token set\"}}" + ); + } else { + eprintln!(); + eprintln!( + " {} No stored token found. Cron sync requires a stored token.", + lore::cli::render::Theme::warning() + .render(lore::cli::render::Icons::warning()), + ); + eprintln!(" Run: lore token set"); + eprintln!(); + } + } + } + CronAction::Uninstall => { + let result = run_cron_uninstall()?; + let elapsed_ms = start.elapsed().as_millis() as u64; + if robot_mode { + print_cron_uninstall_json(&result, elapsed_ms); + } else { + print_cron_uninstall(&result); + } + } + CronAction::Status => { + let config = Config::load(config_override)?; + let info = run_cron_status(&config)?; + let elapsed_ms = start.elapsed().as_millis() as u64; + if robot_mode { + print_cron_status_json(&info, elapsed_ms); + } else { + print_cron_status(&info); + } + } + } + + Ok(()) +} + +async fn handle_token( + config_override: Option<&str>, + args: TokenArgs, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + + match args.action { + TokenAction::Set { token } => { + let result = run_token_set(config_override, token).await?; + let elapsed_ms = start.elapsed().as_millis() as u64; + if robot_mode { + let output = serde_json::json!({ + "ok": true, + "data": { + "action": "set", + "username": result.username, + "config_path": result.config_path, + }, + "meta": { "elapsed_ms": elapsed_ms }, + }); + println!("{}", serde_json::to_string(&output)?); + } else { + println!( + " {} Token stored and validated (authenticated as @{})", + lore::cli::render::Theme::success().render(lore::cli::render::Icons::success()), + result.username + ); + println!( + " {} {}", + lore::cli::render::Theme::dim().render("config:"), + result.config_path + ); + println!(); + } + } + TokenAction::Show { unmask } => { + let result = run_token_show(config_override, unmask)?; + let elapsed_ms = start.elapsed().as_millis() as u64; + if robot_mode { + let output = serde_json::json!({ + "ok": true, + "data": { + "token": result.token, + "source": result.source, + }, + "meta": { "elapsed_ms": elapsed_ms }, + }); + println!("{}", serde_json::to_string(&output)?); + } else { + println!( + " {} {}", + lore::cli::render::Theme::dim().render("token:"), + result.token + ); + println!( + " {} {}", + lore::cli::render::Theme::dim().render("source:"), + result.source + ); + println!(); + } + } + } + + Ok(()) +} + +#[derive(Serialize)] +struct HealthOutput { + ok: bool, + data: HealthData, + meta: RobotMeta, +} + +#[derive(Serialize)] +struct HealthData { + healthy: bool, + config_found: bool, + db_found: bool, + schema_current: bool, + schema_version: i32, + #[serde(skip_serializing_if = "Vec::is_empty")] + actions: Vec, +} + +async fn handle_health( + config_override: Option<&str>, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config_path = get_config_path(config_override); + let config_found = config_path.exists(); + + let (db_found, schema_version, schema_current) = if config_found { + match Config::load(config_override) { + Ok(config) => { + let db_path = get_db_path(config.storage.db_path.as_deref()); + if db_path.exists() { + match create_connection(&db_path) { + Ok(conn) => { + let version = get_schema_version(&conn); + (true, version, version >= LATEST_SCHEMA_VERSION) + } + Err(_) => (true, 0, false), + } + } else { + (false, 0, false) + } + } + Err(_) => (false, 0, false), + } + } else { + (false, 0, false) + }; + + let healthy = config_found && db_found && schema_current; + + let mut actions = Vec::new(); + if !config_found { + actions.push("lore init".to_string()); + } + if !db_found && config_found { + actions.push("lore sync".to_string()); + } + if db_found && !schema_current { + actions.push("lore migrate".to_string()); + } + + if robot_mode { + let output = HealthOutput { + ok: true, + data: HealthData { + healthy, + config_found, + db_found, + schema_current, + schema_version, + actions, + }, + meta: RobotMeta { + elapsed_ms: start.elapsed().as_millis() as u64, + }, + }; + println!("{}", serde_json::to_string(&output)?); + } else { + let status = |ok: bool| { + if ok { + Theme::success().render("pass") + } else { + Theme::error().render("FAIL") + } + }; + println!( + "Config: {} ({})", + status(config_found), + config_path.display() + ); + println!("DB: {}", status(db_found)); + println!("Schema: {} (v{})", status(schema_current), schema_version); + println!(); + if healthy { + println!("{}", Theme::success().bold().render("Healthy")); + } else { + println!( + "{}", + Theme::error() + .bold() + .render("Unhealthy - run 'lore doctor' for details") + ); + } + } + + if !healthy { + std::process::exit(19); + } + + Ok(()) +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 5186330..96a883c 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -4,26 +4,16 @@ pub mod config; pub mod cron; pub mod cursor; pub mod db; -pub mod dependent_queue; pub mod error; -pub mod events_db; pub mod file_history; pub mod lock; pub mod logging; pub mod metrics; -pub mod note_parser; pub mod path_resolver; pub mod paths; -pub mod payloads; pub mod project; -pub mod references; pub mod shutdown; -pub mod sync_run; pub mod time; -pub mod timeline; -pub mod timeline_collect; -pub mod timeline_expand; -pub mod timeline_seed; pub mod trace; pub use config::Config; diff --git a/src/core/shutdown.rs b/src/core/shutdown.rs index a0f9a49..c576ced 100644 --- a/src/core/shutdown.rs +++ b/src/core/shutdown.rs @@ -1,6 +1,20 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +/// Spawn a background task that listens for Ctrl+C. +/// +/// First press: cancels `signal` and prints an interrupt message. +/// Second press: force-exits with code 130. +pub fn install_ctrl_c_handler(signal: ShutdownSignal) { + tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + eprintln!("\nInterrupted, finishing current batch... (Ctrl+C again to force quit)"); + signal.cancel(); + let _ = tokio::signal::ctrl_c().await; + std::process::exit(130); + }); +} + /// A cooperative cancellation token for graceful shutdown. /// /// Clone-able and cheaply checkable from any thread or async task. diff --git a/src/main.rs b/src/main.rs index 7e81f3f..e5d4791 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,16 +41,16 @@ use lore::cli::{ use lore::core::db::{ LATEST_SCHEMA_VERSION, create_connection, get_schema_version, run_migrations, }; -use lore::core::dependent_queue::release_all_locked_jobs; use lore::core::error::{LoreError, RobotErrorOutput}; use lore::core::logging; use lore::core::metrics::MetricsLayer; use lore::core::path_resolver::{build_path_query, normalize_repo_path}; use lore::core::paths::{get_config_path, get_db_path, get_log_dir}; use lore::core::project::resolve_project; -use lore::core::shutdown::ShutdownSignal; -use lore::core::sync_run::SyncRunRecorder; +use lore::core::shutdown::{ShutdownSignal, install_ctrl_c_handler}; use lore::core::trace::run_trace; +use lore::ingestion::storage::queue::release_all_locked_jobs; +use lore::ingestion::storage::sync_run::SyncRunRecorder; #[tokio::main] async fn main() { @@ -416,3329 +416,4 @@ async fn main() { } } -#[derive(Serialize)] -struct FallbackErrorOutput { - error: FallbackError, -} - -#[derive(Serialize)] -struct FallbackError { - code: String, - message: String, -} - -fn handle_error(e: Box, robot_mode: bool) -> ! { - if let Some(gi_error) = e.downcast_ref::() { - 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, - } - - let teaching: Vec = 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::>() - .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 { - // 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 { - 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, Option>) { - 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 '" - 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" - 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 { - 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 ", - } -} - -fn handle_issues( - config_override: Option<&str>, - args: IssuesArgs, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let config = Config::load(config_override)?; - let project = config.effective_project(args.project.as_deref()); - let asc = args.asc && !args.no_asc; - let has_due = args.has_due && !args.no_has_due; - let open = args.open && !args.no_open; - let order = if asc { "asc" } else { "desc" }; - - if let Some(iid) = args.iid { - let result = run_show_issue(&config, iid, project)?; - if robot_mode { - print_show_issue_json(&result, start.elapsed().as_millis() as u64); - } else { - print_show_issue(&result); - } - } else { - let state_normalized = args.state.as_deref().map(str::to_lowercase); - let filters = ListFilters { - limit: args.limit, - project, - state: state_normalized.as_deref(), - author: args.author.as_deref(), - assignee: args.assignee.as_deref(), - labels: args.label.as_deref(), - milestone: args.milestone.as_deref(), - since: args.since.as_deref(), - due_before: args.due_before.as_deref(), - has_due_date: has_due, - statuses: &args.status, - sort: &args.sort, - order, - }; - - let result = run_list_issues(&config, filters)?; - - if open { - open_issue_in_browser(&result); - } else if robot_mode { - print_list_issues_json( - &result, - start.elapsed().as_millis() as u64, - args.fields.as_deref(), - ); - } else { - print_list_issues(&result); - } - } - - Ok(()) -} - -fn handle_mrs( - config_override: Option<&str>, - args: MrsArgs, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let config = Config::load(config_override)?; - let project = config.effective_project(args.project.as_deref()); - let asc = args.asc && !args.no_asc; - let open = args.open && !args.no_open; - let order = if asc { "asc" } else { "desc" }; - - if let Some(iid) = args.iid { - let result = run_show_mr(&config, iid, project)?; - if robot_mode { - print_show_mr_json(&result, start.elapsed().as_millis() as u64); - } else { - print_show_mr(&result); - } - } else { - let state_normalized = args.state.as_deref().map(str::to_lowercase); - let filters = MrListFilters { - limit: args.limit, - project, - state: state_normalized.as_deref(), - author: args.author.as_deref(), - assignee: args.assignee.as_deref(), - reviewer: args.reviewer.as_deref(), - labels: args.label.as_deref(), - since: args.since.as_deref(), - draft: args.draft, - no_draft: args.no_draft, - target_branch: args.target.as_deref(), - source_branch: args.source.as_deref(), - sort: &args.sort, - order, - }; - - let result = run_list_mrs(&config, filters)?; - - if open { - open_mr_in_browser(&result); - } else if robot_mode { - print_list_mrs_json( - &result, - start.elapsed().as_millis() as u64, - args.fields.as_deref(), - ); - } else { - print_list_mrs(&result); - } - } - - Ok(()) -} - -fn handle_notes( - config_override: Option<&str>, - args: NotesArgs, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let config = Config::load(config_override)?; - let db_path = get_db_path(config.storage.db_path.as_deref()); - let conn = create_connection(&db_path)?; - - let order = if args.asc { "asc" } else { "desc" }; - let filters = NoteListFilters { - limit: args.limit, - project: args.project, - author: args.author, - note_type: args.note_type, - include_system: args.include_system, - for_issue_iid: args.for_issue, - for_mr_iid: args.for_mr, - note_id: args.note_id, - gitlab_note_id: args.gitlab_note_id, - discussion_id: args.discussion_id, - since: args.since, - until: args.until, - path: args.path, - contains: args.contains, - resolution: args.resolution, - sort: args.sort, - order: order.to_string(), - }; - - let result = query_notes(&conn, &filters, &config)?; - - if robot_mode { - print_list_notes_json( - &result, - start.elapsed().as_millis() as u64, - args.fields.as_deref(), - ); - } else { - print_list_notes(&result); - } - - Ok(()) -} - -async fn handle_ingest( - config_override: Option<&str>, - args: IngestArgs, - robot_mode: bool, - quiet: bool, - metrics: &MetricsLayer, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let dry_run = args.dry_run && !args.no_dry_run; - let config = Config::load(config_override)?; - let project = config.effective_project(args.project.as_deref()); - - 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, project, 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", project, full)?; - let mrs_preview = run_ingest_dry_run(&config, "mrs", project, 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 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()); - let recorder_conn = create_connection(&db_path)?; - let run_id = uuid::Uuid::new_v4().simple().to_string(); - let run_id_short = &run_id[..8]; - let recorder = SyncRunRecorder::start(&recorder_conn, &command, run_id_short)?; - - let signal = ShutdownSignal::new(); - let signal_for_handler = signal.clone(); - tokio::spawn(async move { - let _ = tokio::signal::ctrl_c().await; - eprintln!("\nInterrupted, finishing current batch... (Ctrl+C again to force quit)"); - signal_for_handler.cancel(); - let _ = tokio::signal::ctrl_c().await; - std::process::exit(130); - }); - - let ingest_result: std::result::Result<(), Box> = async { - match args.entity.as_deref() { - Some(resource_type) => { - let result = run_ingest( - &config, - resource_type, - project, - force, - full, - false, - display, - None, - &signal, - ) - .await?; - - if robot_mode { - print_ingest_summary_json(&result, start.elapsed().as_millis() as u64); - } else { - print_ingest_summary(&result); - } - } - None => { - if !robot_mode && !quiet { - println!( - "{}", - Theme::info().render("Ingesting all content (issues + merge requests)...") - ); - println!(); - } - - let issues_result = run_ingest( - &config, "issues", project, force, full, false, display, None, &signal, - ) - .await?; - - let mrs_result = run_ingest( - &config, "mrs", project, force, full, false, display, None, &signal, - ) - .await?; - - if robot_mode { - print_combined_ingest_json( - &issues_result, - &mrs_result, - start.elapsed().as_millis() as u64, - ); - } else { - print_ingest_summary(&issues_result); - print_ingest_summary(&mrs_result); - } - } - } - Ok(()) - } - .await; - - match ingest_result { - Ok(()) if signal.is_cancelled() => { - let stages = metrics.extract_timings(); - let _ = release_all_locked_jobs(&recorder_conn); - let _ = recorder.fail( - &recorder_conn, - "Interrupted by user (Ctrl+C)", - Some(&stages), - ); - if !robot_mode { - eprintln!( - "{}", - Theme::warning().render("Interrupted by Ctrl+C. Partial data has been saved.") - ); - } - Ok(()) - } - Ok(()) => { - let stages = metrics.extract_timings(); - let total_items: usize = stages.iter().map(|s| s.items_processed).sum(); - let total_errors: usize = stages.iter().map(|s| s.errors).sum(); - let _ = recorder.succeed(&recorder_conn, &stages, total_items, total_errors); - if !robot_mode && !quiet { - eprintln!( - "{}", - Theme::dim().render("Hint: Run 'lore generate-docs' to update searchable documents, then 'lore embed' for vectors.") - ); - } - Ok(()) - } - Err(e) => { - let stages = metrics.extract_timings(); - let _ = release_all_locked_jobs(&recorder_conn); - let _ = recorder.fail(&recorder_conn, &e.to_string(), Some(&stages)); - Err(e) - } - } -} - -#[derive(Serialize)] -struct CombinedIngestOutput { - ok: bool, - data: CombinedIngestData, - meta: RobotMeta, -} - -#[derive(Serialize)] -struct CombinedIngestData { - resource_type: String, - issues: CombinedIngestEntityStats, - merge_requests: CombinedIngestEntityStats, -} - -#[derive(Serialize)] -struct CombinedIngestEntityStats { - projects_synced: usize, - fetched: usize, - upserted: usize, - labels_created: usize, - discussions_fetched: usize, - notes_upserted: usize, -} - -fn print_combined_ingest_json( - issues: &lore::cli::commands::ingest::IngestResult, - mrs: &lore::cli::commands::ingest::IngestResult, - elapsed_ms: u64, -) { - let output = CombinedIngestOutput { - ok: true, - data: CombinedIngestData { - resource_type: "all".to_string(), - issues: CombinedIngestEntityStats { - projects_synced: issues.projects_synced, - fetched: issues.issues_fetched, - upserted: issues.issues_upserted, - labels_created: issues.labels_created, - discussions_fetched: issues.discussions_fetched, - notes_upserted: issues.notes_upserted, - }, - merge_requests: CombinedIngestEntityStats { - projects_synced: mrs.projects_synced, - fetched: mrs.mrs_fetched, - upserted: mrs.mrs_upserted, - labels_created: mrs.labels_created, - discussions_fetched: mrs.discussions_fetched, - notes_upserted: mrs.notes_upserted, - }, - }, - meta: RobotMeta { elapsed_ms }, - }; - - println!( - "{}", - serde_json::to_string(&output).unwrap_or_else(|e| { - format!(r#"{{"ok":false,"error":{{"code":"INTERNAL_ERROR","message":"JSON serialization failed: {e}"}}}}"#) - }) - ); -} - -#[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_or_else(|e| { - format!(r#"{{"ok":false,"error":{{"code":"INTERNAL_ERROR","message":"JSON serialization failed: {e}"}}}}"#) - }) - ); -} - -async fn handle_count( - config_override: Option<&str>, - args: CountArgs, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let config = Config::load(config_override)?; - - if args.entity == "events" { - let counts = run_count_events(&config)?; - if robot_mode { - print_event_count_json(&counts, start.elapsed().as_millis() as u64); - } else { - print_event_count(&counts); - } - return Ok(()); - } - - let result = run_count(&config, &args.entity, args.for_entity.as_deref())?; - if robot_mode { - print_count_json(&result, start.elapsed().as_millis() as u64); - } else { - print_count(&result); - } - Ok(()) -} - -async fn handle_sync_status_cmd( - config_override: Option<&str>, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let config = Config::load(config_override)?; - - let result = run_sync_status(&config)?; - if robot_mode { - print_sync_status_json(&result, start.elapsed().as_millis() as u64); - } else { - print_sync_status(&result); - } - Ok(()) -} - -#[derive(Serialize)] -struct InitOutput { - ok: bool, - data: InitOutputData, -} - -#[derive(Serialize)] -struct InitOutputData { - config_path: String, - data_dir: String, - user: InitOutputUser, - projects: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - default_project: Option, -} - -#[derive(Serialize)] -struct InitOutputUser { - username: String, - name: String, -} - -#[derive(Serialize)] -struct InitOutputProject { - path: String, - name: String, -} - -fn print_init_json(result: &InitResult) { - let output = InitOutput { - ok: true, - data: InitOutputData { - config_path: result.config_path.clone(), - data_dir: result.data_dir.clone(), - user: InitOutputUser { - username: result.user.username.clone(), - name: result.user.name.clone(), - }, - projects: result - .projects - .iter() - .map(|p| InitOutputProject { - path: p.path.clone(), - name: p.name.clone(), - }) - .collect(), - default_project: result.default_project.clone(), - }, - }; - println!( - "{}", - serde_json::to_string(&output).unwrap_or_else(|e| { - format!(r#"{{"ok":false,"error":{{"code":"INTERNAL_ERROR","message":"JSON serialization failed: {e}"}}}}"#) - }) - ); -} - -// ── Refresh JSON types ── - -#[derive(Serialize)] -struct RefreshOutput { - ok: bool, - data: RefreshOutputData, -} - -#[derive(Serialize)] -struct RefreshOutputData { - mode: &'static str, - user: InitOutputUser, - projects_registered: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - projects_failed: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - orphans_found: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - orphans_deleted: Vec, -} - -#[derive(Serialize)] -struct RefreshOutputFailure { - path: String, - error: String, -} - -fn print_refresh_json(result: &RefreshResult) { - let output = RefreshOutput { - ok: true, - data: RefreshOutputData { - mode: "refresh", - user: InitOutputUser { - username: result.user.username.clone(), - name: result.user.name.clone(), - }, - projects_registered: result - .projects_registered - .iter() - .map(|p| InitOutputProject { - path: p.path.clone(), - name: p.name.clone(), - }) - .collect(), - projects_failed: result - .projects_failed - .iter() - .map(|p| RefreshOutputFailure { - path: p.path.clone(), - error: p.error.clone(), - }) - .collect(), - orphans_found: result.orphans_found.clone(), - orphans_deleted: result.orphans_deleted.clone(), - }, - }; - println!( - "{}", - serde_json::to_string(&output).unwrap_or_else(|e| { - format!(r#"{{"ok":false,"error":{{"code":"INTERNAL_ERROR","message":"JSON serialization failed: {e}"}}}}"#) - }) - ); -} - -async fn handle_init_refresh( - config_override: Option<&str>, - non_interactive: bool, - robot_mode: bool, -) -> Result<(), Box> { - let mut result = run_init_refresh(RefreshOptions { - config_path: config_override.map(String::from), - non_interactive, - }) - .await?; - - // Handle orphan deletion prompt (interactive only) - let mut orphans_deleted: Vec = Vec::new(); - if !result.orphans_found.is_empty() && !robot_mode && !non_interactive { - println!( - "\n{}", - Theme::warning().render(&format!( - "Found {} orphan project{} in database (not in config):", - result.orphans_found.len(), - if result.orphans_found.len() == 1 { - "" - } else { - "s" - } - )) - ); - for orphan in &result.orphans_found { - println!(" {}", Theme::muted().render(&format!("• {orphan}"))); - } - println!(); - - let confirm = Confirm::new() - .with_prompt(format!( - "Delete {} orphan project{} from database?", - result.orphans_found.len(), - if result.orphans_found.len() == 1 { - "" - } else { - "s" - } - )) - .default(false) - .interact()?; - - if confirm { - let deleted = delete_orphan_projects(config_override, &result.orphans_found)?; - orphans_deleted = result.orphans_found.clone(); - println!( - "{}", - Theme::success().render(&format!(" Deleted {deleted} orphan project(s)")) - ); - } - } - result.orphans_deleted = orphans_deleted; - - if robot_mode { - print_refresh_json(&result); - return Ok(()); - } - - // Human output - println!( - "\n{}", - Theme::success().render(&format!( - "\u{2713} Authenticated as @{} ({})", - result.user.username, result.user.name - )) - ); - - if !result.projects_registered.is_empty() { - println!("\n {}", Theme::bold().render("Projects")); - for project in &result.projects_registered { - println!( - " {} {:<40} registered", - Theme::success().render("\u{2713}"), - project.path - ); - } - } - - if !result.projects_failed.is_empty() { - for failure in &result.projects_failed { - println!( - " {} {:<40} {}", - Theme::error().render("\u{2717}"), - failure.path, - failure.error - ); - } - } - - // Summary - let registered = result.projects_registered.len(); - let failed = result.projects_failed.len(); - let orphans_kept = result.orphans_found.len() - result.orphans_deleted.len(); - - let mut summary_parts: Vec = Vec::new(); - summary_parts.push(format!( - "{} project{} registered", - registered, - if registered == 1 { "" } else { "s" } - )); - if failed > 0 { - summary_parts.push(format!("{failed} failed")); - } - if !result.orphans_deleted.is_empty() { - summary_parts.push(format!( - "{} orphan(s) deleted", - result.orphans_deleted.len() - )); - } - if orphans_kept > 0 { - summary_parts.push(format!("{orphans_kept} orphan(s) kept")); - } - - println!( - "\n{}", - Theme::info().render(&format!(" {}", summary_parts.join(", "))) - ); - - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -async fn handle_init( - config_override: Option<&str>, - refresh: bool, - force: bool, - non_interactive: bool, - robot_mode: bool, - gitlab_url_flag: Option, - token_env_var_flag: Option, - projects_flag: Option, - default_project_flag: Option, -) -> Result<(), Box> { - // ── Handle --refresh mode ── - if refresh { - return handle_init_refresh(config_override, non_interactive, robot_mode).await; - } - - if robot_mode { - let missing: Vec<&str> = [ - gitlab_url_flag.is_none().then_some("--gitlab-url"), - token_env_var_flag.is_none().then_some("--token-env-var"), - projects_flag.is_none().then_some("--projects"), - ] - .into_iter() - .flatten() - .collect(); - - if !missing.is_empty() { - let output = RobotErrorWithSuggestion { - error: RobotErrorSuggestionData { - code: "MISSING_FLAGS".to_string(), - message: format!("Robot mode requires flags: {}", missing.join(", ")), - suggestion: "lore --robot init --gitlab-url https://gitlab.com --token-env-var GITLAB_TOKEN --projects group/project".to_string(), - correction: None, - valid_values: None, - }, - }; - eprintln!("{}", serde_json::to_string(&output)?); - std::process::exit(2); - } - - let project_paths: Vec = projects_flag - .unwrap() - .split(',') - .map(|p| p.trim().to_string()) - .filter(|p| !p.is_empty()) - .collect(); - - let result = run_init( - InitInputs { - gitlab_url: gitlab_url_flag.unwrap(), - token_env_var: token_env_var_flag.unwrap(), - project_paths, - default_project: default_project_flag.clone(), - }, - InitOptions { - config_path: config_override.map(String::from), - force: true, - non_interactive: true, - }, - ) - .await?; - - print_init_json(&result); - return Ok(()); - } - - let config_path = get_config_path(config_override); - let mut confirmed_overwrite = force; - - if config_path.exists() && !force { - if non_interactive { - eprintln!( - "{}", - Theme::error().render(&format!( - "Config already exists at {}", - config_path.display() - )) - ); - eprintln!( - "{}", - Theme::info().render(" • Use --refresh to register new projects from config") - ); - eprintln!( - "{}", - Theme::info().render(" • Use --force to overwrite the config file") - ); - std::process::exit(2); - } - - println!( - "{}", - Theme::warning().render(&format!( - "Config already exists at {}", - config_path.display() - )) - ); - println!( - "{}", - Theme::info().render(" • Use --refresh to register new projects from config") - ); - println!(); - - let confirm = Confirm::new() - .with_prompt("Overwrite existing config?") - .default(false) - .interact()?; - - if !confirm { - println!("{}", Theme::warning().render("Cancelled.")); - std::process::exit(2); - } - confirmed_overwrite = true; - } - - let gitlab_url: String = if let Some(url) = gitlab_url_flag { - url - } else { - Input::new() - .with_prompt("GitLab URL") - .default("https://gitlab.com".to_string()) - .validate_with(|input: &String| -> Result<(), &str> { - if url::Url::parse(input).is_ok() { - Ok(()) - } else { - Err("Please enter a valid URL") - } - }) - .interact_text()? - }; - - let token_env_var: String = if let Some(var) = token_env_var_flag { - var - } else { - Input::new() - .with_prompt("Token environment variable name") - .default("GITLAB_TOKEN".to_string()) - .interact_text()? - }; - - let project_paths: Vec = if let Some(projects) = projects_flag { - projects - .split(',') - .map(|p| p.trim().to_string()) - .filter(|p| !p.is_empty()) - .collect() - } else { - let project_paths_input: String = Input::new() - .with_prompt("Project paths (comma-separated, e.g., group/project)") - .validate_with(|input: &String| -> Result<(), &str> { - if input.trim().is_empty() { - Err("Please enter at least one project path") - } else { - Ok(()) - } - }) - .interact_text()?; - - project_paths_input - .split(',') - .map(|p| p.trim().to_string()) - .filter(|p| !p.is_empty()) - .collect() - }; - - // Resolve default project: CLI flag, interactive prompt, or None - let default_project = if default_project_flag.is_some() { - default_project_flag - } else if project_paths.len() > 1 && !non_interactive { - let set_default = Confirm::new() - .with_prompt("Set a default project? (used when -p is omitted)") - .default(true) - .interact()?; - - if set_default { - let selection = dialoguer::Select::new() - .with_prompt("Default project") - .items(&project_paths) - .default(0) - .interact()?; - Some(project_paths[selection].clone()) - } else { - None - } - } else { - None - }; - - println!("{}", Theme::info().render("Validating configuration...")); - - let result = run_init( - InitInputs { - gitlab_url, - token_env_var, - project_paths, - default_project, - }, - InitOptions { - config_path: config_override.map(String::from), - force: confirmed_overwrite, - non_interactive, - }, - ) - .await?; - - println!( - "{}", - Theme::success().render(&format!( - "\n\u{2713} Authenticated as @{} ({})", - result.user.username, result.user.name - )) - ); - - for project in &result.projects { - println!( - "{}", - Theme::success().render(&format!("\u{2713} {} ({})", project.path, project.name)) - ); - } - - if let Some(ref dp) = result.default_project { - println!( - "{}", - Theme::success().render(&format!("\u{2713} Default project: {dp}")) - ); - } - - println!( - "{}", - Theme::success().render(&format!( - "\n\u{2713} Config written to {}", - result.config_path - )) - ); - println!( - "{}", - Theme::success().render(&format!( - "\u{2713} Database initialized at {}", - result.data_dir - )) - ); - println!( - "{}", - Theme::info().render("\nSetup complete! Run 'lore doctor' to verify.") - ); - - Ok(()) -} - -#[derive(Serialize)] -struct AuthTestOutput { - ok: bool, - data: AuthTestData, - meta: RobotMeta, -} - -#[derive(Serialize)] -struct AuthTestData { - authenticated: bool, - username: String, - name: String, - gitlab_url: String, -} - -async fn handle_auth_test( - config_override: Option<&str>, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - match run_auth_test(config_override).await { - Ok(result) => { - if robot_mode { - let output = AuthTestOutput { - ok: true, - data: AuthTestData { - authenticated: true, - username: result.username.clone(), - name: result.name.clone(), - gitlab_url: result.base_url.clone(), - }, - meta: RobotMeta { - elapsed_ms: start.elapsed().as_millis() as u64, - }, - }; - println!("{}", serde_json::to_string(&output)?); - } else { - println!("Authenticated as @{} ({})", result.username, result.name); - println!("GitLab: {}", result.base_url); - } - Ok(()) - } - Err(e) => { - if robot_mode { - let output = RobotErrorOutput::from(&e); - eprintln!( - "{}", - serde_json::to_string(&output).unwrap_or_else(|_| { - let msg = e.to_string().replace('\\', "\\\\").replace('"', "\\\""); - format!( - r#"{{"error":{{"code":"{}","message":"{}"}}}}"#, - e.code(), - msg - ) - }) - ); - } else { - eprintln!("{} {}", Theme::error().render("Error:"), e); - if let Some(suggestion) = e.suggestion() { - eprintln!("{} {}", Theme::warning().render("Hint:"), suggestion); - } - } - std::process::exit(e.exit_code()); - } - } -} - -#[derive(Serialize)] -struct DoctorOutput { - ok: bool, - data: DoctorData, - meta: RobotMeta, -} - -#[derive(Serialize)] -struct DoctorData { - success: bool, - checks: lore::cli::commands::DoctorChecks, -} - -async fn handle_doctor( - config_override: Option<&str>, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let result = run_doctor(config_override).await; - - if robot_mode { - let output = DoctorOutput { - ok: true, - data: DoctorData { - success: result.success, - checks: result.checks, - }, - meta: RobotMeta { - elapsed_ms: start.elapsed().as_millis() as u64, - }, - }; - println!("{}", serde_json::to_string(&output)?); - } else { - print_doctor_results(&result); - } - - if !result.success { - std::process::exit(1); - } - - Ok(()) -} - -#[derive(Serialize)] -struct VersionOutput { - ok: bool, - data: VersionData, - meta: RobotMeta, -} - -#[derive(Serialize)] -struct VersionData { - name: &'static str, - version: String, - #[serde(skip_serializing_if = "Option::is_none")] - git_hash: Option, -} - -fn handle_version(robot_mode: bool) -> Result<(), Box> { - let start = std::time::Instant::now(); - let version = env!("CARGO_PKG_VERSION").to_string(); - let git_hash = env!("GIT_HASH").to_string(); - if robot_mode { - let output = VersionOutput { - ok: true, - data: VersionData { - name: "lore", - version, - git_hash: if git_hash.is_empty() { - None - } else { - Some(git_hash) - }, - }, - meta: RobotMeta { - elapsed_ms: start.elapsed().as_millis() as u64, - }, - }; - println!("{}", serde_json::to_string(&output)?); - } else if git_hash.is_empty() { - println!("lore version {}", version); - } else { - println!("lore version {} ({})", version, git_hash); - } - Ok(()) -} - -fn handle_completions(shell: &str) -> Result<(), Box> { - use clap::CommandFactory; - use clap_complete::{Shell, generate}; - - let shell = match shell { - "bash" => Shell::Bash, - "zsh" => Shell::Zsh, - "fish" => Shell::Fish, - "powershell" => Shell::PowerShell, - other => { - return Err(format!("Unsupported shell: {other}").into()); - } - }; - - let mut cmd = Cli::command(); - generate(shell, &mut cmd, "lore", &mut std::io::stdout()); - Ok(()) -} - -fn handle_backup(robot_mode: bool) -> Result<(), Box> { - if robot_mode { - let output = RobotErrorWithSuggestion { - error: RobotErrorSuggestionData { - code: "NOT_IMPLEMENTED".to_string(), - message: "The 'backup' command is not yet implemented.".to_string(), - suggestion: "Use manual database backup: cp ~/.local/share/lore/lore.db ~/.local/share/lore/lore.db.bak".to_string(), - correction: None, - valid_values: None, - }, - }; - eprintln!("{}", serde_json::to_string(&output)?); - } else { - eprintln!( - "{} The 'backup' command is not yet implemented.", - Theme::error().render("Error:") - ); - } - std::process::exit(1); -} - -fn handle_reset(robot_mode: bool) -> Result<(), Box> { - if robot_mode { - let output = RobotErrorWithSuggestion { - error: RobotErrorSuggestionData { - code: "NOT_IMPLEMENTED".to_string(), - message: "The 'reset' command is not yet implemented.".to_string(), - suggestion: "Manually delete the database: rm ~/.local/share/lore/lore.db" - .to_string(), - correction: None, - valid_values: None, - }, - }; - eprintln!("{}", serde_json::to_string(&output)?); - } else { - eprintln!( - "{} The 'reset' command is not yet implemented.", - Theme::error().render("Error:") - ); - } - std::process::exit(1); -} - -#[derive(Serialize)] -struct MigrateOutput { - ok: bool, - data: MigrateData, - meta: RobotMeta, -} - -#[derive(Serialize)] -struct MigrateData { - before_version: i32, - after_version: i32, - migrated: bool, -} - -#[derive(Serialize)] -struct RobotErrorWithSuggestion { - error: RobotErrorSuggestionData, -} - -#[derive(Serialize)] -struct RobotErrorSuggestionData { - code: String, - message: String, - suggestion: String, - #[serde(skip_serializing_if = "Option::is_none")] - correction: Option, - #[serde(skip_serializing_if = "Option::is_none")] - valid_values: Option>, -} - -async fn handle_migrate( - config_override: Option<&str>, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let config = Config::load(config_override)?; - let db_path = get_db_path(config.storage.db_path.as_deref()); - - if !db_path.exists() { - if robot_mode { - let output = RobotErrorWithSuggestion { - error: RobotErrorSuggestionData { - code: "DB_ERROR".to_string(), - message: format!("Database not found at {}", db_path.display()), - suggestion: "Run 'lore init' first".to_string(), - correction: None, - valid_values: None, - }, - }; - eprintln!("{}", serde_json::to_string(&output)?); - } else { - eprintln!( - "{}", - Theme::error().render(&format!("Database not found at {}", db_path.display())) - ); - eprintln!( - "{}", - Theme::warning().render("Run 'lore init' first to create the database.") - ); - } - std::process::exit(10); - } - - let conn = create_connection(&db_path)?; - let before_version = get_schema_version(&conn); - - if !robot_mode { - println!( - "{}", - Theme::info().render(&format!("Current schema version: {}", before_version)) - ); - } - - run_migrations(&conn)?; - - let after_version = get_schema_version(&conn); - - if robot_mode { - let output = MigrateOutput { - ok: true, - data: MigrateData { - before_version, - after_version, - migrated: after_version > before_version, - }, - meta: RobotMeta { - elapsed_ms: start.elapsed().as_millis() as u64, - }, - }; - println!("{}", serde_json::to_string(&output)?); - } else if after_version > before_version { - println!( - "{}", - Theme::success().render(&format!( - "Migrations applied: {} -> {}", - before_version, after_version - )) - ); - } else { - println!( - "{}", - Theme::success().render("Database is already up to date.") - ); - } - - Ok(()) -} - -async fn handle_stats( - config_override: Option<&str>, - args: StatsArgs, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - 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, dry_run)?; - if robot_mode { - print_stats_json(&result, start.elapsed().as_millis() as u64); - } else { - print_stats(&result); - } - Ok(()) -} - -fn handle_file_history( - config_override: Option<&str>, - args: FileHistoryArgs, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let config = Config::load(config_override)?; - - let project = config - .effective_project(args.project.as_deref()) - .map(String::from); - - let normalized = normalize_repo_path(&args.path); - - // Resolve bare filenames before querying (same path resolution as trace/who) - let db_path_tmp = get_db_path(config.storage.db_path.as_deref()); - let conn_tmp = create_connection(&db_path_tmp)?; - let project_id_tmp = project - .as_deref() - .map(|p| resolve_project(&conn_tmp, p)) - .transpose()?; - let pq = build_path_query(&conn_tmp, &normalized, project_id_tmp)?; - let resolved_path = if pq.is_prefix { - // Directory prefix — file-history is file-oriented, pass the raw path. - // Don't use pq.value which contains LIKE-escaped metacharacters. - normalized.trim_end_matches('/').to_string() - } else { - pq.value - }; - - let result = run_file_history( - &config, - &resolved_path, - project.as_deref(), - args.no_follow_renames, - args.merged, - args.discussions, - args.limit, - )?; - - if robot_mode { - let elapsed_ms = start.elapsed().as_millis() as u64; - print_file_history_json(&result, elapsed_ms); - } else { - print_file_history(&result); - } - Ok(()) -} - -fn handle_trace( - config_override: Option<&str>, - args: TraceArgs, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let config = Config::load(config_override)?; - - let (raw_path, line_requested) = parse_trace_path(&args.path); - let normalized = normalize_repo_path(&raw_path); - - if line_requested.is_some() && !robot_mode { - eprintln!( - "Note: Line-level tracing requires Tier 2 (git blame). Showing file-level results." - ); - } - - let project = config - .effective_project(args.project.as_deref()) - .map(String::from); - - let db_path = get_db_path(config.storage.db_path.as_deref()); - let conn = create_connection(&db_path)?; - let project_id = project - .as_deref() - .map(|p| resolve_project(&conn, p)) - .transpose()?; - - // Resolve bare filenames (e.g. "operators.ts" -> "src/utils/operators.ts") - let pq = build_path_query(&conn, &normalized, project_id)?; - let path = if pq.is_prefix { - // Directory prefix — trace is file-oriented, pass the raw path. - // Don't use pq.value which contains LIKE-escaped metacharacters. - normalized.trim_end_matches('/').to_string() - } else { - pq.value - }; - - let result = run_trace( - &conn, - project_id, - &path, - !args.no_follow_renames, - args.discussions, - args.limit, - )?; - - if robot_mode { - let elapsed_ms = start.elapsed().as_millis() as u64; - print_trace_json(&result, elapsed_ms, line_requested); - } else { - print_trace(&result); - } - Ok(()) -} - -async fn handle_timeline( - config_override: Option<&str>, - args: TimelineArgs, - robot_mode: bool, -) -> Result<(), Box> { - let config = Config::load(config_override)?; - - let params = TimelineParams { - query: args.query, - project: config - .effective_project(args.project.as_deref()) - .map(String::from), - since: args.since, - depth: args.depth, - no_mentions: args.no_mentions, - limit: args.limit, - max_seeds: args.max_seeds, - max_entities: args.max_entities, - max_evidence: args.max_evidence, - robot_mode, - }; - - let result = run_timeline(&config, ¶ms).await?; - - if robot_mode { - print_timeline_json_with_meta( - &result, - result.total_filtered_events, - params.depth, - !params.no_mentions, - args.fields.as_deref(), - ); - } else { - print_timeline(&result); - } - Ok(()) -} - -async fn handle_search( - config_override: Option<&str>, - args: SearchArgs, - robot_mode: bool, -) -> Result<(), Box> { - let config = Config::load(config_override)?; - let explain = args.explain && !args.no_explain; - - let fts_mode = match args.fts_mode.as_str() { - "raw" => lore::search::FtsQueryMode::Raw, - _ => lore::search::FtsQueryMode::Safe, - }; - - let cli_filters = SearchCliFilters { - source_type: args.source_type, - author: args.author, - project: config - .effective_project(args.project.as_deref()) - .map(String::from), - labels: args.label, - path: args.path, - since: args.since, - updated_since: args.updated_since, - limit: args.limit, - }; - - let spinner = lore::cli::progress::stage_spinner_v2( - lore::cli::render::Icons::search(), - "Search", - &format!("Searching ({})...", args.mode), - robot_mode, - ); - let start = std::time::Instant::now(); - let response = run_search( - &config, - &args.query, - cli_filters, - fts_mode, - &args.mode, - explain, - ) - .await?; - let elapsed_ms = start.elapsed().as_millis() as u64; - spinner.finish_and_clear(); - - if robot_mode { - print_search_results_json(&response, elapsed_ms, args.fields.as_deref()); - } else { - print_search_results(&response); - } - Ok(()) -} - -async fn handle_generate_docs( - config_override: Option<&str>, - args: GenerateDocsArgs, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let config = Config::load(config_override)?; - - let project = config.effective_project(args.project.as_deref()); - let result = run_generate_docs(&config, args.full, project, None)?; - let elapsed = start.elapsed(); - if robot_mode { - print_generate_docs_json(&result, elapsed.as_millis() as u64); - } else { - print_generate_docs(&result); - if elapsed.as_secs() >= 1 { - eprintln!( - "{}", - Theme::dim().render(&format!(" Done in {:.1}s", elapsed.as_secs_f64())) - ); - } - if result.regenerated > 0 { - eprintln!( - "{}", - Theme::dim().render( - "Hint: Run 'lore embed' to update vector embeddings for changed documents." - ) - ); - } - } - Ok(()) -} - -async fn handle_embed( - config_override: Option<&str>, - args: EmbedArgs, - robot_mode: bool, -) -> Result<(), Box> { - use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; - - let start = std::time::Instant::now(); - let config = Config::load(config_override)?; - let full = args.full && !args.no_full; - let retry_failed = args.retry_failed && !args.no_retry_failed; - - let signal = ShutdownSignal::new(); - let signal_for_handler = signal.clone(); - tokio::spawn(async move { - let _ = tokio::signal::ctrl_c().await; - eprintln!("\nInterrupted, finishing current batch... (Ctrl+C again to force quit)"); - signal_for_handler.cancel(); - let _ = tokio::signal::ctrl_c().await; - std::process::exit(130); - }); - - let embed_bar = lore::cli::progress::nested_progress("Embedding", 0, robot_mode); - let bar_clone = embed_bar.clone(); - let tick_started = Arc::new(AtomicBool::new(false)); - let tick_clone = Arc::clone(&tick_started); - let progress_cb: Box = Box::new(move |processed, total| { - if total > 0 { - if !tick_clone.swap(true, Ordering::Relaxed) { - bar_clone.enable_steady_tick(std::time::Duration::from_millis(100)); - } - bar_clone.set_length(total as u64); - bar_clone.set_position(processed as u64); - } - }); - - let result = run_embed(&config, full, retry_failed, Some(progress_cb), &signal).await?; - embed_bar.finish_and_clear(); - - let elapsed = start.elapsed(); - if robot_mode { - print_embed_json(&result, elapsed.as_millis() as u64); - } else { - print_embed(&result); - if elapsed.as_secs() >= 1 { - eprintln!( - "{}", - Theme::dim().render(&format!(" Done in {:.1}s", elapsed.as_secs_f64())) - ); - } - } - Ok(()) -} - -async fn handle_sync_cmd( - config_override: Option<&str>, - args: SyncArgs, - robot_mode: bool, - metrics: &MetricsLayer, -) -> Result<(), Box> { - let dry_run = args.dry_run && !args.no_dry_run; - - // Dedup and sort IIDs - let mut issue_iids = args.issue; - let mut mr_iids = args.mr; - issue_iids.sort_unstable(); - issue_iids.dedup(); - mr_iids.sort_unstable(); - mr_iids.dedup(); - - let mut config = Config::load(config_override)?; - if args.no_events { - config.sync.fetch_resource_events = false; - } - if args.no_file_changes { - config.sync.fetch_mr_file_changes = false; - } - if args.no_status { - config.sync.fetch_work_item_status = false; - } - let options = SyncOptions { - full: args.full && !args.no_full, - force: args.force && !args.no_force, - no_embed: args.no_embed, - no_docs: args.no_docs, - no_events: args.no_events, - robot_mode, - dry_run, - issue_iids, - mr_iids, - project: args.project, - preflight_only: args.preflight_only, - }; - - // Validation: preflight_only requires surgical mode - if options.preflight_only && !options.is_surgical() { - return Err("--preflight-only requires --issue or --mr".into()); - } - - // Validation: full + surgical are incompatible - if options.full && options.is_surgical() { - return Err("--full and --issue/--mr are incompatible".into()); - } - - // Validation: surgical mode requires a project (via -p or config defaultProject) - if options.is_surgical() - && config - .effective_project(options.project.as_deref()) - .is_none() - { - return Err("--issue/--mr requires -p/--project (or set defaultProject in config)".into()); - } - - // Validation: hard cap on total surgical targets - let total_targets = options.issue_iids.len() + options.mr_iids.len(); - if total_targets > SyncOptions::MAX_SURGICAL_TARGETS { - return Err(format!( - "Too many surgical targets ({total_targets}); maximum is {}", - SyncOptions::MAX_SURGICAL_TARGETS - ) - .into()); - } - - // Surgical + dry-run → treat as preflight-only - let mut options = options; - if dry_run && options.is_surgical() { - options.preflight_only = true; - } - - // Resolve effective project for surgical mode: when -p is not passed but - // defaultProject is set in config, populate options.project so the surgical - // orchestrator receives the resolved project path. - if options.is_surgical() && options.project.is_none() { - options.project = config.default_project.clone(); - } - - // For non-surgical dry run, skip recording and just show the preview - if dry_run && !options.is_surgical() { - let signal = ShutdownSignal::new(); - run_sync(&config, options, None, &signal).await?; - return Ok(()); - } - - // Acquire file lock if --lock was passed (used by cron to skip overlapping runs) - let _sync_lock = if args.lock { - match lore::core::cron::acquire_sync_lock() { - Ok(Some(guard)) => Some(guard), - Ok(None) => { - // Another sync is running — silently exit (expected for cron) - tracing::debug!("--lock: another sync is running, skipping"); - return Ok(()); - } - Err(e) => { - tracing::warn!(error = %e, "--lock: failed to acquire file lock, skipping sync"); - return Ok(()); - } - } - } else { - None - }; - - // Surgical mode: run_sync_surgical manages its own recorder, signal, and recording. - // Skip the normal recorder setup and let the dispatch handle everything. - if options.is_surgical() { - let signal = ShutdownSignal::new(); - let signal_for_handler = signal.clone(); - tokio::spawn(async move { - let _ = tokio::signal::ctrl_c().await; - eprintln!("\nInterrupted, finishing current batch... (Ctrl+C again to force quit)"); - signal_for_handler.cancel(); - let _ = tokio::signal::ctrl_c().await; - std::process::exit(130); - }); - - let start = std::time::Instant::now(); - match run_sync(&config, options, None, &signal).await { - Ok(result) => { - let elapsed = start.elapsed(); - if robot_mode { - print_sync_json(&result, elapsed.as_millis() as u64, Some(metrics)); - } else { - print_sync(&result, elapsed, Some(metrics), args.timings); - } - return Ok(()); - } - Err(e) => return Err(e.into()), - } - } - - 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(); - let run_id_short = &run_id[..8]; - let recorder = SyncRunRecorder::start(&recorder_conn, "sync", run_id_short)?; - - let signal = ShutdownSignal::new(); - let signal_for_handler = signal.clone(); - tokio::spawn(async move { - let _ = tokio::signal::ctrl_c().await; - eprintln!("\nInterrupted, finishing current batch... (Ctrl+C again to force quit)"); - signal_for_handler.cancel(); - let _ = tokio::signal::ctrl_c().await; - std::process::exit(130); - }); - - let start = std::time::Instant::now(); - match run_sync(&config, options, Some(run_id_short), &signal).await { - Ok(result) if signal.is_cancelled() => { - let elapsed = start.elapsed(); - let stages = metrics.extract_timings(); - let released = release_all_locked_jobs(&recorder_conn).unwrap_or(0); - let _ = recorder.fail( - &recorder_conn, - "Interrupted by user (Ctrl+C)", - Some(&stages), - ); - - if robot_mode { - print_sync_json(&result, elapsed.as_millis() as u64, Some(metrics)); - } else { - eprintln!(); - eprintln!( - "{}", - Theme::warning().render("Interrupted by Ctrl+C. Partial results:") - ); - print_sync(&result, elapsed, Some(metrics), args.timings); - if released > 0 { - eprintln!( - "{}", - Theme::dim().render(&format!("Released {released} locked jobs")) - ); - } - } - Ok(()) - } - Ok(result) => { - let elapsed = start.elapsed(); - let stages = metrics.extract_timings(); - let total_items = result.issues_updated - + result.mrs_updated - + result.documents_regenerated - + result.documents_embedded; - let total_errors = result.resource_events_failed; - let _ = recorder.succeed(&recorder_conn, &stages, total_items, total_errors); - - if robot_mode { - print_sync_json(&result, elapsed.as_millis() as u64, Some(metrics)); - } else { - print_sync(&result, elapsed, Some(metrics), args.timings); - } - Ok(()) - } - Err(e) => { - let stages = metrics.extract_timings(); - let _ = release_all_locked_jobs(&recorder_conn); - let _ = recorder.fail(&recorder_conn, &e.to_string(), Some(&stages)); - Err(e.into()) - } - } -} - -fn handle_cron( - config_override: Option<&str>, - args: CronArgs, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - - match args.action { - CronAction::Install { interval } => { - let result = run_cron_install(interval)?; - let elapsed_ms = start.elapsed().as_millis() as u64; - if robot_mode { - print_cron_install_json(&result, elapsed_ms); - } else { - print_cron_install(&result); - } - // Warn if no stored token — cron runs in a minimal shell with no env vars - if let Ok(config) = Config::load(config_override) - && config - .gitlab - .token - .as_ref() - .is_none_or(|t| t.trim().is_empty()) - { - if robot_mode { - eprintln!( - "{{\"warning\":\"No stored token found. Cron sync requires a stored token. Run: lore token set\"}}" - ); - } else { - eprintln!(); - eprintln!( - " {} No stored token found. Cron sync requires a stored token.", - lore::cli::render::Theme::warning() - .render(lore::cli::render::Icons::warning()), - ); - eprintln!(" Run: lore token set"); - eprintln!(); - } - } - } - CronAction::Uninstall => { - let result = run_cron_uninstall()?; - let elapsed_ms = start.elapsed().as_millis() as u64; - if robot_mode { - print_cron_uninstall_json(&result, elapsed_ms); - } else { - print_cron_uninstall(&result); - } - } - CronAction::Status => { - let config = Config::load(config_override)?; - let info = run_cron_status(&config)?; - let elapsed_ms = start.elapsed().as_millis() as u64; - if robot_mode { - print_cron_status_json(&info, elapsed_ms); - } else { - print_cron_status(&info); - } - } - } - - Ok(()) -} - -async fn handle_token( - config_override: Option<&str>, - args: TokenArgs, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - - match args.action { - TokenAction::Set { token } => { - let result = run_token_set(config_override, token).await?; - let elapsed_ms = start.elapsed().as_millis() as u64; - if robot_mode { - let output = serde_json::json!({ - "ok": true, - "data": { - "action": "set", - "username": result.username, - "config_path": result.config_path, - }, - "meta": { "elapsed_ms": elapsed_ms }, - }); - println!("{}", serde_json::to_string(&output)?); - } else { - println!( - " {} Token stored and validated (authenticated as @{})", - lore::cli::render::Theme::success().render(lore::cli::render::Icons::success()), - result.username - ); - println!( - " {} {}", - lore::cli::render::Theme::dim().render("config:"), - result.config_path - ); - println!(); - } - } - TokenAction::Show { unmask } => { - let result = run_token_show(config_override, unmask)?; - let elapsed_ms = start.elapsed().as_millis() as u64; - if robot_mode { - let output = serde_json::json!({ - "ok": true, - "data": { - "token": result.token, - "source": result.source, - }, - "meta": { "elapsed_ms": elapsed_ms }, - }); - println!("{}", serde_json::to_string(&output)?); - } else { - println!( - " {} {}", - lore::cli::render::Theme::dim().render("token:"), - result.token - ); - println!( - " {} {}", - lore::cli::render::Theme::dim().render("source:"), - result.source - ); - println!(); - } - } - } - - Ok(()) -} - -#[derive(Serialize)] -struct HealthOutput { - ok: bool, - data: HealthData, - meta: RobotMeta, -} - -#[derive(Serialize)] -struct HealthData { - healthy: bool, - config_found: bool, - db_found: bool, - schema_current: bool, - schema_version: i32, - #[serde(skip_serializing_if = "Vec::is_empty")] - actions: Vec, -} - -async fn handle_health( - config_override: Option<&str>, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let config_path = get_config_path(config_override); - let config_found = config_path.exists(); - - let (db_found, schema_version, schema_current) = if config_found { - match Config::load(config_override) { - Ok(config) => { - let db_path = get_db_path(config.storage.db_path.as_deref()); - if db_path.exists() { - match create_connection(&db_path) { - Ok(conn) => { - let version = get_schema_version(&conn); - (true, version, version >= LATEST_SCHEMA_VERSION) - } - Err(_) => (true, 0, false), - } - } else { - (false, 0, false) - } - } - Err(_) => (false, 0, false), - } - } else { - (false, 0, false) - }; - - let healthy = config_found && db_found && schema_current; - - let mut actions = Vec::new(); - if !config_found { - actions.push("lore init".to_string()); - } - if !db_found && config_found { - actions.push("lore sync".to_string()); - } - if db_found && !schema_current { - actions.push("lore migrate".to_string()); - } - - if robot_mode { - let output = HealthOutput { - ok: true, - data: HealthData { - healthy, - config_found, - db_found, - schema_current, - schema_version, - actions, - }, - meta: RobotMeta { - elapsed_ms: start.elapsed().as_millis() as u64, - }, - }; - println!("{}", serde_json::to_string(&output)?); - } else { - let status = |ok: bool| { - if ok { - Theme::success().render("pass") - } else { - Theme::error().render("FAIL") - } - }; - println!( - "Config: {} ({})", - status(config_found), - config_path.display() - ); - println!("DB: {}", status(db_found)); - println!("Schema: {} (v{})", status(schema_current), schema_version); - println!(); - if healthy { - println!("{}", Theme::success().bold().render("Healthy")); - } else { - println!( - "{}", - Theme::error() - .bold() - .render("Unhealthy - run 'lore doctor' for details") - ); - } - } - - if !healthy { - std::process::exit(19); - } - - Ok(()) -} - -#[derive(Serialize)] -struct RobotDocsOutput { - ok: bool, - data: RobotDocsData, -} - -#[derive(Serialize)] -struct RobotDocsData { - name: String, - version: String, - description: String, - activation: RobotDocsActivation, - quick_start: serde_json::Value, - commands: serde_json::Value, - /// Deprecated command aliases (old -> new) - aliases: serde_json::Value, - /// Pre-clap error tolerance: what the CLI auto-corrects - error_tolerance: 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, - config_notes: serde_json::Value, -} - -#[derive(Serialize)] -struct RobotDocsActivation { - flags: Vec, - env: String, - auto: String, -} - -fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box> { - let version = env!("CARGO_PKG_VERSION").to_string(); - - let commands = serde_json::json!({ - "init": { - "description": "Initialize configuration and database", - "flags": ["--force", "--non-interactive", "--gitlab-url ", "--token-env-var ", "--projects ", "--default-project "], - "robot_flags": ["--gitlab-url", "--token-env-var", "--projects", "--default-project"], - "example": "lore --robot init --gitlab-url https://gitlab.com --token-env-var GITLAB_TOKEN --projects group/project,other/repo --default-project group/project", - "response_schema": { - "ok": "bool", - "data": {"config_path": "string", "data_dir": "string", "user": {"username": "string", "name": "string"}, "projects": "[{path:string, name:string}]", "default_project": "string?"}, - "meta": {"elapsed_ms": "int"} - } - }, - "health": { - "description": "Quick pre-flight check: config, database, schema version", - "flags": [], - "example": "lore --robot health", - "response_schema": { - "ok": "bool", - "data": {"healthy": "bool", "config_found": "bool", "db_found": "bool", "schema_current": "bool", "schema_version": "int"}, - "meta": {"elapsed_ms": "int"} - } - }, - "auth": { - "description": "Verify GitLab authentication", - "flags": [], - "example": "lore --robot auth", - "response_schema": { - "ok": "bool", - "data": {"authenticated": "bool", "username": "string", "name": "string", "gitlab_url": "string"}, - "meta": {"elapsed_ms": "int"} - } - }, - "doctor": { - "description": "Full environment health check (config, auth, DB, Ollama)", - "flags": [], - "example": "lore --robot doctor", - "response_schema": { - "ok": "bool", - "data": {"success": "bool", "checks": "{config:object, auth:object, database:object, ollama:object}"}, - "meta": {"elapsed_ms": "int"} - } - }, - "ingest": { - "description": "Sync data from GitLab", - "flags": ["--project ", "--force", "--no-force", "--full", "--no-full", "--dry-run", "--no-dry-run", ""], - "example": "lore --robot ingest issues --project group/repo", - "response_schema": { - "ok": "bool", - "data": {"resource_type": "string", "projects_synced": "int", "issues_fetched?": "int", "mrs_fetched?": "int", "upserted": "int", "labels_created": "int", "discussions_fetched": "int", "notes_upserted": "int"}, - "meta": {"elapsed_ms": "int"} - } - }, - "sync": { - "description": "Full sync pipeline: ingest -> generate-docs -> embed. Supports surgical per-IID mode.", - "flags": ["--full", "--no-full", "--force", "--no-force", "--no-embed", "--no-docs", "--no-events", "--no-file-changes", "--no-status", "--dry-run", "--no-dry-run", "-t/--timings", "--lock", "--issue ", "--mr ", "-p/--project ", "--preflight-only"], - "example": "lore --robot sync", - "surgical_mode": { - "description": "Sync specific issues or MRs by IID. Runs a scoped pipeline: preflight -> TOCTOU check -> ingest -> dependents -> docs -> embed.", - "flags": ["--issue (repeatable)", "--mr (repeatable)", "-p/--project (required)", "--preflight-only"], - "examples": [ - "lore --robot sync --issue 7 -p group/project", - "lore --robot sync --issue 7 --issue 42 --mr 10 -p group/project", - "lore --robot sync --issue 7 -p group/project --preflight-only" - ], - "constraints": ["--issue/--mr requires -p/--project (or defaultProject in config)", "--full and --issue/--mr are incompatible", "--preflight-only requires --issue or --mr", "Max 100 total targets"], - "entity_result_outcomes": ["synced", "skipped_stale", "not_found", "preflight_failed", "error"] - }, - "response_schema": { - "normal": { - "ok": "bool", - "data": {"issues_updated": "int", "mrs_updated": "int", "documents_regenerated": "int", "documents_embedded": "int", "resource_events_synced": "int", "resource_events_failed": "int"}, - "meta": {"elapsed_ms": "int", "stages?": "[{name:string, elapsed_ms:int, items_processed:int}]"} - }, - "surgical": { - "ok": "bool", - "data": {"surgical_mode": "true", "surgical_iids": "{issues:[int], merge_requests:[int]}", "entity_results": "[{entity_type:string, iid:int, outcome:string, error?:string, toctou_reason?:string}]", "preflight_only?": "bool", "issues_updated": "int", "mrs_updated": "int", "documents_regenerated": "int", "documents_embedded": "int", "discussions_fetched": "int"}, - "meta": {"elapsed_ms": "int"} - } - } - }, - "issues": { - "description": "List or show issues", - "flags": ["", "-n/--limit", "--fields ", "-s/--state", "--status ", "-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", - "notes": { - "status_filter": "--status filters by work item status NAME (case-insensitive). Valid values are in meta.available_statuses of any issues list response.", - "status_name": "status_name is the board column label (e.g. 'In review', 'Blocked'). This is the canonical status identifier for filtering." - }, - "response_schema": { - "list": { - "ok": "bool", - "data": {"issues": "[{iid:int, title:string, state:string, author_username:string, labels:[string], assignees:[string], discussion_count:int, unresolved_count:int, created_at_iso:string, updated_at_iso:string, web_url:string?, project_path:string, status_name:string?}]", "total_count": "int", "showing": "int"}, - "meta": {"elapsed_ms": "int", "available_statuses": "[string] — all distinct status names in the database, for use with --status filter"} - }, - "show": { - "ok": "bool", - "data": "IssueDetail (full entity with description, discussions, notes, events)", - "meta": {"elapsed_ms": "int"} - } - }, - "example_output": {"list": {"ok":true,"data":{"issues":[{"iid":3864,"title":"Switch Health Card","state":"opened","status_name":"In progress","labels":["customer:BNSF"],"assignees":["teernisse"],"discussion_count":12,"updated_at_iso":"2026-02-12T..."}],"total_count":1,"showing":1},"meta":{"elapsed_ms":42}}}, - "fields_presets": {"minimal": ["iid", "title", "state", "updated_at_iso"]} - }, - "mrs": { - "description": "List or show merge requests", - "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", - "response_schema": { - "list": { - "ok": "bool", - "data": {"mrs": "[{iid:int, title:string, state:string, author_username:string, labels:[string], draft:bool, target_branch:string, source_branch:string, discussion_count:int, unresolved_count:int, created_at_iso:string, updated_at_iso:string, web_url:string?, project_path:string, reviewers:[string]}]", "total_count": "int", "showing": "int"}, - "meta": {"elapsed_ms": "int"} - }, - "show": { - "ok": "bool", - "data": "MrDetail (full entity with description, discussions, notes, events)", - "meta": {"elapsed_ms": "int"} - } - }, - "example_output": {"list": {"ok":true,"data":{"mrs":[{"iid":200,"title":"Add throw time chart","state":"opened","draft":false,"author_username":"teernisse","target_branch":"main","source_branch":"feat/throw-time","reviewers":["cseiber"],"discussion_count":5,"updated_at_iso":"2026-02-11T..."}],"total_count":1,"showing":1},"meta":{"elapsed_ms":38}}}, - "fields_presets": {"minimal": ["iid", "title", "state", "updated_at_iso"]} - }, - "search": { - "description": "Search indexed documents (lexical, hybrid, semantic)", - "flags": ["", "--mode", "--type", "--author", "-p/--project", "--label", "--path", "--since", "--updated-since", "-n/--limit", "--fields ", "--explain", "--no-explain", "--fts-mode"], - "example": "lore --robot search 'authentication bug' --mode hybrid --limit 10", - "response_schema": { - "ok": "bool", - "data": {"results": "[{document_id:int, source_type:string, title:string, snippet:string, score:float, url:string?, author:string?, created_at:string?, updated_at:string?, project_path:string, labels:[string], paths:[string]}]", "total_results": "int", "query": "string", "mode": "string", "warnings": "[string]"}, - "meta": {"elapsed_ms": "int"} - }, - "example_output": {"ok":true,"data":{"query":"throw time","mode":"hybrid","total_results":3,"results":[{"document_id":42,"source_type":"issue","title":"Switch Health Card","score":0.92,"snippet":"...throw time data from BNSF...","project_path":"vs/typescript-code"}],"warnings":[]},"meta":{"elapsed_ms":85}}, - "fields_presets": {"minimal": ["document_id", "title", "source_type", "score"]} - }, - "count": { - "description": "Count entities in local database", - "flags": ["", "-f/--for "], - "example": "lore --robot count issues", - "response_schema": { - "ok": "bool", - "data": {"entity": "string", "count": "int", "system_excluded?": "int", "breakdown?": {"opened": "int", "closed": "int", "merged?": "int", "locked?": "int"}}, - "meta": {"elapsed_ms": "int"} - } - }, - "stats": { - "description": "Show document and index statistics", - "flags": ["--check", "--no-check", "--repair", "--dry-run", "--no-dry-run"], - "example": "lore --robot stats", - "response_schema": { - "ok": "bool", - "data": {"total_documents": "int", "indexed_documents": "int", "embedded_documents": "int", "stale_documents": "int", "integrity?": "object"}, - "meta": {"elapsed_ms": "int"} - } - }, - "status": { - "description": "Show sync state (cursors, last sync times)", - "flags": [], - "example": "lore --robot status", - "response_schema": { - "ok": "bool", - "data": {"projects": "[{path:string, issues_cursor:string?, mrs_cursor:string?, last_sync:string?}]"}, - "meta": {"elapsed_ms": "int"} - } - }, - "generate-docs": { - "description": "Generate searchable documents from ingested data", - "flags": ["--full", "-p/--project "], - "example": "lore --robot generate-docs --full", - "response_schema": { - "ok": "bool", - "data": {"generated": "int", "updated": "int", "unchanged": "int", "deleted": "int"}, - "meta": {"elapsed_ms": "int"} - } - }, - "embed": { - "description": "Generate vector embeddings for documents via Ollama", - "flags": ["--full", "--no-full", "--retry-failed", "--no-retry-failed"], - "example": "lore --robot embed", - "response_schema": { - "ok": "bool", - "data": {"embedded": "int", "skipped": "int", "failed": "int", "total_chunks": "int"}, - "meta": {"elapsed_ms": "int"} - } - }, - "migrate": { - "description": "Run pending database migrations", - "flags": [], - "example": "lore --robot migrate", - "response_schema": { - "ok": "bool", - "data": {"before_version": "int", "after_version": "int", "migrated": "bool"}, - "meta": {"elapsed_ms": "int"} - } - }, - "version": { - "description": "Show version information", - "flags": [], - "example": "lore --robot version", - "response_schema": { - "ok": "bool", - "data": {"version": "string", "git_hash?": "string"}, - "meta": {"elapsed_ms": "int"} - } - }, - "completions": { - "description": "Generate shell completions", - "flags": [""], - "example": "lore completions bash > ~/.local/share/bash-completion/completions/lore" - }, - "timeline": { - "description": "Chronological timeline of events matching a keyword query or entity reference", - "flags": ["", "-p/--project", "--since ", "--depth ", "--no-mentions", "-n/--limit", "--fields ", "--max-seeds", "--max-entities", "--max-evidence"], - "query_syntax": { - "search": "Any text -> hybrid search seeding (FTS5 + vector)", - "entity_direct": "issue:N, i:N, mr:N, m:N -> direct entity seeding (no search, no Ollama)" - }, - "example": "lore --robot timeline issue:42", - "response_schema": { - "ok": "bool", - "data": {"entities": "[{type:string, iid:int, title:string, project_path:string}]", "events": "[{timestamp:string, type:string, entity_type:string, entity_iid:int, detail:string}]", "total_events": "int"}, - "meta": {"elapsed_ms": "int", "search_mode": "string (hybrid|lexical|direct)"} - }, - "fields_presets": {"minimal": ["timestamp", "type", "entity_iid", "detail"]} - }, - "who": { - "description": "People intelligence: experts, workload, active discussions, overlap, review patterns", - "flags": ["", "--path ", "--active", "--overlap ", "--reviews", "--since ", "-p/--project", "-n/--limit", "--fields ", "--detail", "--no-detail", "--as-of ", "--explain-score", "--include-bots", "--include-closed", "--all-history"], - "modes": { - "expert": "lore who -- Who knows about this area? (also: --path for root files)", - "workload": "lore who -- What is someone working on?", - "reviews": "lore who --reviews -- Review pattern analysis", - "active": "lore who --active -- Active unresolved discussions", - "overlap": "lore who --overlap -- Who else is touching these files?" - }, - "example": "lore --robot who src/features/auth/", - "response_schema": { - "ok": "bool", - "data": { - "mode": "string", - "input": {"target": "string|null", "path": "string|null", "project": "string|null", "since": "string|null", "limit": "int"}, - "resolved_input": {"mode": "string", "project_id": "int|null", "project_path": "string|null", "since_ms": "int", "since_iso": "string", "since_mode": "string (default|explicit|none)", "limit": "int"}, - "...": "mode-specific fields" - }, - "meta": {"elapsed_ms": "int"} - }, - "example_output": {"expert": {"ok":true,"data":{"mode":"expert","result":{"experts":[{"username":"teernisse","score":42,"note_count":15,"diff_note_count":8}]}},"meta":{"elapsed_ms":65}}}, - "fields_presets": { - "expert_minimal": ["username", "score"], - "workload_minimal": ["entity_type", "iid", "title", "state"], - "active_minimal": ["entity_type", "iid", "title", "participants"] - } - }, - "trace": { - "description": "Trace why code was introduced: file -> MR -> issue -> discussion. Follows rename chains by default.", - "flags": ["", "-p/--project ", "--discussions", "--no-follow-renames", "-n/--limit "], - "example": "lore --robot trace src/main.rs -p group/repo", - "response_schema": { - "ok": "bool", - "data": {"path": "string", "resolved_paths": "[string]", "trace_chains": "[{mr_iid:int, mr_title:string, mr_state:string, mr_author:string, change_type:string, merged_at_iso:string?, updated_at_iso:string, web_url:string?, issues:[{iid:int, title:string, state:string, reference_type:string, web_url:string?}], discussions:[{discussion_id:string, mr_iid:int, author_username:string, body_snippet:string, path:string, created_at_iso:string}]}]"}, - "meta": {"tier": "string (api_only)", "line_requested": "int?", "elapsed_ms": "int", "total_chains": "int", "renames_followed": "bool"} - } - }, - "file-history": { - "description": "Show MRs that touched a file, with rename chain resolution and optional DiffNote discussions", - "flags": ["", "-p/--project ", "--discussions", "--no-follow-renames", "--merged", "-n/--limit "], - "example": "lore --robot file-history src/main.rs -p group/repo", - "response_schema": { - "ok": "bool", - "data": {"path": "string", "rename_chain": "[string]?", "merge_requests": "[{iid:int, title:string, state:string, author_username:string, change_type:string, merged_at_iso:string?, updated_at_iso:string, merge_commit_sha:string?, web_url:string?}]", "discussions": "[{discussion_id:string, author_username:string, body_snippet:string, path:string, created_at_iso:string}]?"}, - "meta": {"elapsed_ms": "int", "total_mrs": "int", "renames_followed": "bool", "paths_searched": "int"} - } - }, - "drift": { - "description": "Detect discussion divergence from original issue intent", - "flags": ["", "", "--threshold <0.0-1.0>", "-p/--project "], - "example": "lore --robot drift issues 42 --threshold 0.4", - "response_schema": { - "ok": "bool", - "data": {"entity_type": "string", "iid": "int", "title": "string", "threshold": "float", "divergent_discussions": "[{discussion_id:string, similarity:float, snippet:string}]"}, - "meta": {"elapsed_ms": "int"} - } - }, - "notes": { - "description": "List notes from discussions with rich filtering", - "flags": ["--limit/-n ", "--author/-a ", "--note-type ", "--contains ", "--for-issue ", "--for-mr ", "-p/--project ", "--since ", "--until ", "--path ", "--resolution ", "--sort ", "--asc", "--include-system", "--note-id ", "--gitlab-note-id ", "--discussion-id ", "--fields ", "--open"], - "robot_flags": ["--format json", "--fields minimal"], - "example": "lore --robot notes --author jdefting --since 1y --format json --fields minimal", - "response_schema": { - "ok": "bool", - "data": {"notes": "[NoteListRowJson]", "total_count": "int", "showing": "int"}, - "meta": {"elapsed_ms": "int"} - } - }, - "cron": { - "description": "Manage cron-based automatic syncing (Unix only)", - "subcommands": { - "install": {"flags": ["--interval "], "default_interval": 8}, - "uninstall": {"flags": []}, - "status": {"flags": []} - }, - "example": "lore --robot cron status", - "response_schema": { - "ok": "bool", - "data": {"action": "string (install|uninstall|status)", "installed?": "bool", "interval_minutes?": "int", "entry?": "string", "log_path?": "string", "replaced?": "bool", "was_installed?": "bool", "last_run_iso?": "string"}, - "meta": {"elapsed_ms": "int"} - } - }, - "token": { - "description": "Manage stored GitLab token", - "subcommands": { - "set": {"flags": ["--token "], "note": "Reads from stdin if --token omitted in non-interactive mode"}, - "show": {"flags": ["--unmask"]} - }, - "example": "lore --robot token show", - "response_schema": { - "ok": "bool", - "data": {"action": "string (set|show)", "token_masked?": "string", "token?": "string", "valid?": "bool", "username?": "string"}, - "meta": {"elapsed_ms": "int"} - } - }, - "me": { - "description": "Personal work dashboard: open issues, authored/reviewing MRs, @mentioned-in items, activity feed, and cursor-based since-last-check inbox with computed attention states", - "flags": ["--issues", "--mrs", "--mentions", "--activity", "--since ", "-p/--project ", "--all", "--user ", "--fields ", "--reset-cursor"], - "example": "lore --robot me", - "response_schema": { - "ok": "bool", - "data": { - "username": "string", - "since_iso": "string?", - "summary": {"project_count": "int", "open_issue_count": "int", "authored_mr_count": "int", "reviewing_mr_count": "int", "mentioned_in_count": "int", "needs_attention_count": "int"}, - "since_last_check": "{cursor_iso:string, total_event_count:int, groups:[{entity_type:string, entity_iid:int, entity_title:string, project:string, events:[{timestamp_iso:string, event_type:string, actor:string?, summary:string, body_preview:string?}]}]}?", - "open_issues": "[{project:string, iid:int, title:string, state:string, attention_state:string, attention_reason:string, status_name:string?, labels:[string], updated_at_iso:string, web_url:string?}]", - "open_mrs_authored": "[{project:string, iid:int, title:string, state:string, attention_state:string, attention_reason:string, draft:bool, detailed_merge_status:string?, author_username:string?, labels:[string], updated_at_iso:string, web_url:string?}]", - "reviewing_mrs": "[same as open_mrs_authored]", - "mentioned_in": "[{entity_type:string, project:string, iid:int, title:string, state:string, attention_state:string, attention_reason:string, updated_at_iso:string, web_url:string?}]", - "activity": "[{timestamp_iso:string, event_type:string, entity_type:string, entity_iid:int, project:string, actor:string?, is_own:bool, summary:string, body_preview:string?}]" - }, - "meta": {"elapsed_ms": "int"} - }, - "fields_presets": { - "me_items_minimal": ["iid", "title", "attention_state", "attention_reason", "updated_at_iso"], - "me_mentions_minimal": ["entity_type", "iid", "title", "state", "attention_state", "attention_reason", "updated_at_iso"], - "me_activity_minimal": ["timestamp_iso", "event_type", "entity_iid", "actor"] - }, - "notes": { - "attention_states": "needs_attention | not_started | awaiting_response | stale | not_ready", - "event_types": "note | status_change | label_change | assign | unassign | review_request | milestone_change", - "section_flags": "If none of --issues/--mrs/--mentions/--activity specified, all sections returned", - "since_default": "1d for activity feed", - "issue_filter": "Only In Progress / In Review status issues shown", - "since_last_check": "Cursor-based inbox showing events since last run. Null on first run (no cursor yet). Groups events by entity (issue/MR). Sources: others' comments on your items, @mentions, assignment/review-request notes. Cursor auto-advances after each run. Use --reset-cursor to clear.", - "cursor_persistence": "Stored per user in ~/.local/share/lore/me_cursor_.json. --project filters display only for since-last-check; cursor still advances for all projects for that user." - } - }, - "robot-docs": { - "description": "This command (agent self-discovery manifest)", - "flags": ["--brief"], - "example": "lore robot-docs --brief" - } - }); - - let quick_start = serde_json::json!({ - "glab_equivalents": [ - { "glab": "glab issue list", "lore": "lore -J issues -n 50", "note": "Richer: includes labels, status, closing MRs, discussion counts" }, - { "glab": "glab issue view 123", "lore": "lore -J issues 123", "note": "Includes full discussions, work-item status, cross-references" }, - { "glab": "glab issue list -l bug", "lore": "lore -J issues --label bug", "note": "AND logic for multiple --label flags" }, - { "glab": "glab mr list", "lore": "lore -J mrs", "note": "Includes draft status, reviewers, discussion counts" }, - { "glab": "glab mr view 456", "lore": "lore -J mrs 456", "note": "Includes discussions, review threads, source/target branches" }, - { "glab": "glab mr list -s opened", "lore": "lore -J mrs -s opened", "note": "States: opened, merged, closed, locked, all" }, - { "glab": "glab api '/projects/:id/issues'", "lore": "lore -J issues -p project", "note": "Fuzzy project matching (suffix or substring)" } - ], - "lore_exclusive": [ - "search: FTS5 + vector hybrid search across all entities", - "who: Expert/workload/reviews analysis per file path or person", - "timeline: Chronological event reconstruction across entities", - "trace: Code provenance chains (file -> MR -> issue -> discussion)", - "file-history: MR history per file with rename resolution", - "notes: Rich note listing with author, type, resolution, path, and discussion filters", - "stats: Database statistics with document/note/discussion counts", - "count: Entity counts with state breakdowns", - "embed: Generate vector embeddings for semantic search via Ollama", - "cron: Automated sync scheduling (Unix)", - "token: Secure token management with masked display", - "me: Personal work dashboard with attention states, activity feed, cursor-based since-last-check inbox, and needs-attention triage" - ], - "read_write_split": "lore = ALL reads (issues, MRs, search, who, timeline, intelligence). glab = ALL writes (create, update, approve, merge, CI/CD)." - }); - - // --brief: strip response_schema and example_output from every command (~60% smaller) - let mut commands = commands; - if brief { - strip_schemas(&mut commands); - } - - let exit_codes = serde_json::json!({ - "0": "Success", - "1": "Internal error", - "2": "Usage error (invalid flags or arguments)", - "3": "Config invalid", - "4": "Token not set", - "5": "GitLab auth failed", - "6": "Resource not found", - "7": "Rate limited", - "8": "Network error", - "9": "Database locked", - "10": "Database error", - "11": "Migration failed", - "12": "I/O error", - "13": "Transform error", - "14": "Ollama unavailable", - "15": "Ollama model not found", - "16": "Embedding failed", - "17": "Not found", - "18": "Ambiguous match", - "19": "Health check failed", - "20": "Config not found" - }); - - let workflows = serde_json::json!({ - "first_setup": [ - "lore --robot init --gitlab-url https://gitlab.com --token-env-var GITLAB_TOKEN --projects group/project", - "lore --robot doctor", - "lore --robot sync" - ], - "daily_sync": [ - "lore --robot sync" - ], - "search": [ - "lore --robot search 'query' --mode hybrid" - ], - "pre_flight": [ - "lore --robot health" - ], - "temporal_intelligence": [ - "lore --robot sync", - "lore --robot timeline '' --since 30d", - "lore --robot timeline '' --depth 2" - ], - "people_intelligence": [ - "lore --robot who src/path/to/feature/", - "lore --robot who @username", - "lore --robot who @username --reviews", - "lore --robot who --active --since 7d", - "lore --robot who --overlap src/path/", - "lore --robot who --path README.md" - ], - "surgical_sync": [ - "lore --robot sync --issue 7 -p group/project", - "lore --robot sync --issue 7 --mr 10 -p group/project", - "lore --robot sync --issue 7 -p group/project --preflight-only" - ], - "personal_dashboard": [ - "lore --robot me", - "lore --robot me --issues", - "lore --robot me --activity --since 7d", - "lore --robot me --project group/repo", - "lore --robot me --fields minimal", - "lore --robot me --reset-cursor" - ] - }); - - // Phase 3: Deprecated command aliases - let aliases = serde_json::json!({ - "deprecated_commands": { - "list issues": "issues", - "list mrs": "mrs", - "show issue ": "issues ", - "show mr ": "mrs ", - "auth-test": "auth", - "sync-status": "status" - }, - "command_aliases": { - "issue": "issues", - "mr": "mrs", - "merge-requests": "mrs", - "merge-request": "mrs", - "mergerequests": "mrs", - "mergerequest": "mrs", - "generate-docs": "generate-docs", - "generatedocs": "generate-docs", - "gendocs": "generate-docs", - "gen-docs": "generate-docs", - "robot-docs": "robot-docs", - "robotdocs": "robot-docs" - }, - "pre_clap_aliases": { - "note": "Underscore/no-separator forms auto-corrected before parsing", - "merge_requests": "mrs", - "merge_request": "mrs", - "mergerequests": "mrs", - "mergerequest": "mrs", - "generate_docs": "generate-docs", - "generatedocs": "generate-docs", - "gendocs": "generate-docs", - "gen-docs": "generate-docs", - "robot-docs": "robot-docs", - "robotdocs": "robot-docs" - }, - "prefix_matching": "Enabled via infer_subcommands. Unambiguous prefixes work: 'iss' -> issues, 'time' -> timeline, 'sea' -> search" - }); - - let error_tolerance = serde_json::json!({ - "note": "The CLI auto-corrects common mistakes before parsing. Corrections are applied silently with a teaching note on stderr.", - "auto_corrections": [ - {"type": "single_dash_long_flag", "example": "-robot -> --robot", "mode": "all"}, - {"type": "case_normalization", "example": "--Robot -> --robot, --State -> --state", "mode": "all"}, - {"type": "flag_prefix", "example": "--proj -> --project (when unambiguous)", "mode": "all"}, - {"type": "fuzzy_flag", "example": "--projct -> --project", "mode": "all (threshold 0.9 in robot, 0.8 in human)"}, - {"type": "subcommand_alias", "example": "merge_requests -> mrs, robotdocs -> robot-docs", "mode": "all"}, - {"type": "subcommand_fuzzy", "example": "issuess -> issues, timline -> timeline, serach -> search", "mode": "all (threshold 0.85)"}, - {"type": "flag_as_subcommand", "example": "--robot-docs -> robot-docs, --generate-docs -> generate-docs", "mode": "all"}, - {"type": "value_normalization", "example": "--state Opened -> --state opened", "mode": "all"}, - {"type": "value_fuzzy", "example": "--state opend -> --state opened", "mode": "all"}, - {"type": "prefix_matching", "example": "lore iss -> lore issues, lore time -> lore timeline", "mode": "all (via clap infer_subcommands)"} - ], - "teaching_notes": "Auto-corrections emit a JSON warning on stderr: {\"warning\":{\"type\":\"ARG_CORRECTED\",\"corrections\":[...],\"teaching\":[...]}}" - }); - - // 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 config_notes = serde_json::json!({ - "defaultProject": { - "type": "string?", - "description": "Fallback project path used when -p/--project is omitted. Must match a configured project path (exact or suffix). CLI -p always overrides.", - "example": "group/project" - } - }); - - let output = RobotDocsOutput { - ok: true, - data: RobotDocsData { - name: "lore".to_string(), - version, - description: "Local GitLab data management with semantic search".to_string(), - activation: RobotDocsActivation { - flags: vec!["--robot".to_string(), "-J".to_string(), "--json".to_string()], - env: "LORE_ROBOT=1".to_string(), - auto: "Non-TTY stdout".to_string(), - }, - quick_start, - commands, - aliases, - error_tolerance, - exit_codes, - clap_error_codes, - error_format: "stderr JSON: {\"error\":{\"code\":\"...\",\"message\":\"...\",\"suggestion\":\"...\",\"actions\":[\"...\"]}}".to_string(), - workflows, - config_notes, - }, - }; - - if robot_mode { - println!("{}", serde_json::to_string(&output)?); - } else { - println!("{}", serde_json::to_string_pretty(&output)?); - } - - Ok(()) -} - -fn handle_who( - config_override: Option<&str>, - mut args: WhoArgs, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let config = Config::load(config_override)?; - if args.project.is_none() { - args.project = config.default_project.clone(); - } - let run = run_who(&config, &args)?; - let elapsed_ms = start.elapsed().as_millis() as u64; - - if robot_mode { - print_who_json(&run, &args, elapsed_ms); - } else { - print_who_human(&run.result, run.resolved_input.project_path.as_deref()); - } - Ok(()) -} - -fn handle_me( - config_override: Option<&str>, - args: MeArgs, - robot_mode: bool, -) -> Result<(), Box> { - let config = Config::load(config_override)?; - run_me(&config, &args, robot_mode)?; - Ok(()) -} - -async fn handle_drift( - config_override: Option<&str>, - entity_type: &str, - iid: i64, - threshold: f32, - project: Option<&str>, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let config = Config::load(config_override)?; - let effective_project = config.effective_project(project); - let response = run_drift(&config, entity_type, iid, threshold, effective_project).await?; - let elapsed_ms = start.elapsed().as_millis() as u64; - - if robot_mode { - print_drift_json(&response, elapsed_ms); - } else { - print_drift_human(&response); - } - Ok(()) -} - -async fn handle_related( - config_override: Option<&str>, - query_or_type: &str, - iid: Option, - limit: usize, - project: Option<&str>, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let config = Config::load(config_override)?; - let effective_project = config.effective_project(project); - let response = run_related(&config, query_or_type, iid, limit, effective_project).await?; - let elapsed_ms = start.elapsed().as_millis() as u64; - - if robot_mode { - print_related_json(&response, elapsed_ms); - } else { - print_related_human(&response); - } - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -async fn handle_list_compat( - config_override: Option<&str>, - entity: &str, - limit: usize, - project_filter: Option<&str>, - state_filter: Option<&str>, - author_filter: Option<&str>, - assignee_filter: Option<&str>, - label_filter: Option<&[String]>, - milestone_filter: Option<&str>, - since_filter: Option<&str>, - due_before_filter: Option<&str>, - has_due_date: bool, - sort: &str, - order: &str, - open_browser: bool, - json_output: bool, - draft: bool, - no_draft: bool, - reviewer_filter: Option<&str>, - target_branch_filter: Option<&str>, - source_branch_filter: Option<&str>, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let config = Config::load(config_override)?; - let project_filter = config.effective_project(project_filter); - - let state_normalized = state_filter.map(str::to_lowercase); - match entity { - "issues" => { - let filters = ListFilters { - limit, - project: project_filter, - state: state_normalized.as_deref(), - author: author_filter, - assignee: assignee_filter, - labels: label_filter, - milestone: milestone_filter, - since: since_filter, - due_before: due_before_filter, - has_due_date, - statuses: &[], - sort, - order, - }; - - let result = run_list_issues(&config, filters)?; - - if open_browser { - open_issue_in_browser(&result); - } else if json_output { - print_list_issues_json(&result, start.elapsed().as_millis() as u64, None); - } else { - print_list_issues(&result); - } - - Ok(()) - } - "mrs" => { - let filters = MrListFilters { - limit, - project: project_filter, - state: state_normalized.as_deref(), - author: author_filter, - assignee: assignee_filter, - reviewer: reviewer_filter, - labels: label_filter, - since: since_filter, - draft, - no_draft, - target_branch: target_branch_filter, - source_branch: source_branch_filter, - sort, - order, - }; - - let result = run_list_mrs(&config, filters)?; - - if open_browser { - open_mr_in_browser(&result); - } else if json_output { - print_list_mrs_json(&result, start.elapsed().as_millis() as u64, None); - } else { - print_list_mrs(&result); - } - - Ok(()) - } - _ => { - eprintln!( - "{}", - Theme::error().render(&format!("Unknown entity: {entity}")) - ); - std::process::exit(1); - } - } -} - -async fn handle_show_compat( - config_override: Option<&str>, - entity: &str, - iid: i64, - project_filter: Option<&str>, - robot_mode: bool, -) -> Result<(), Box> { - let start = std::time::Instant::now(); - let config = Config::load(config_override)?; - let project_filter = config.effective_project(project_filter); - - match entity { - "issue" => { - let result = run_show_issue(&config, iid, project_filter)?; - if robot_mode { - print_show_issue_json(&result, start.elapsed().as_millis() as u64); - } else { - print_show_issue(&result); - } - Ok(()) - } - "mr" => { - let result = run_show_mr(&config, iid, project_filter)?; - if robot_mode { - print_show_mr_json(&result, start.elapsed().as_millis() as u64); - } else { - print_show_mr(&result); - } - Ok(()) - } - _ => { - eprintln!( - "{}", - Theme::error().render(&format!("Unknown entity: {entity}")) - ); - std::process::exit(1); - } - } -} +include!("app/dispatch.rs");