diff --git a/src/cli/commands/count.rs b/src/cli/commands/count.rs index 5a1c3b4..0b3056e 100644 --- a/src/cli/commands/count.rs +++ b/src/cli/commands/count.rs @@ -3,6 +3,7 @@ use rusqlite::Connection; use serde::Serialize; use crate::Config; +use crate::cli::robot::RobotMeta; use crate::core::db::create_connection; use crate::core::error::Result; use crate::core::events_db::{self, EventCounts}; @@ -196,6 +197,7 @@ fn format_number(n: i64) -> String { struct CountJsonOutput { ok: bool, data: CountJsonData, + meta: RobotMeta, } #[derive(Serialize)] @@ -228,6 +230,7 @@ pub fn run_count_events(config: &Config) -> Result { struct EventCountJsonOutput { ok: bool, data: EventCountJsonData, + meta: RobotMeta, } #[derive(Serialize)] @@ -245,7 +248,7 @@ struct EventTypeCounts { total: usize, } -pub fn print_event_count_json(counts: &EventCounts) { +pub fn print_event_count_json(counts: &EventCounts, elapsed_ms: u64) { let output = EventCountJsonOutput { ok: true, data: EventCountJsonData { @@ -266,6 +269,7 @@ pub fn print_event_count_json(counts: &EventCounts) { }, total: counts.total(), }, + meta: RobotMeta { elapsed_ms }, }; println!("{}", serde_json::to_string(&output).unwrap()); @@ -317,7 +321,7 @@ pub fn print_event_count(counts: &EventCounts) { ); } -pub fn print_count_json(result: &CountResult) { +pub fn print_count_json(result: &CountResult, elapsed_ms: u64) { let breakdown = result.state_breakdown.as_ref().map(|b| CountJsonBreakdown { opened: b.opened, closed: b.closed, @@ -333,6 +337,7 @@ pub fn print_count_json(result: &CountResult) { system_excluded: result.system_count, breakdown, }, + meta: RobotMeta { elapsed_ms }, }; println!("{}", serde_json::to_string(&output).unwrap()); diff --git a/src/cli/commands/embed.rs b/src/cli/commands/embed.rs index fd7c241..f9ed993 100644 --- a/src/cli/commands/embed.rs +++ b/src/cli/commands/embed.rs @@ -2,6 +2,7 @@ use console::style; use serde::Serialize; use crate::Config; +use crate::cli::robot::RobotMeta; use crate::core::db::{LATEST_SCHEMA_VERSION, create_connection, get_schema_version}; use crate::core::error::{LoreError, Result}; use crate::core::paths::get_db_path; @@ -112,12 +113,14 @@ pub fn print_embed(result: &EmbedCommandResult) { struct EmbedJsonOutput<'a> { ok: bool, data: &'a EmbedCommandResult, + meta: RobotMeta, } -pub fn print_embed_json(result: &EmbedCommandResult) { +pub fn print_embed_json(result: &EmbedCommandResult, elapsed_ms: u64) { let output = EmbedJsonOutput { ok: true, data: result, + meta: RobotMeta { elapsed_ms }, }; println!("{}", serde_json::to_string(&output).unwrap()); } diff --git a/src/cli/commands/generate_docs.rs b/src/cli/commands/generate_docs.rs index 0132b5f..557df32 100644 --- a/src/cli/commands/generate_docs.rs +++ b/src/cli/commands/generate_docs.rs @@ -4,6 +4,7 @@ use serde::Serialize; use tracing::info; use crate::Config; +use crate::cli::robot::RobotMeta; use crate::core::db::create_connection; use crate::core::error::Result; use crate::core::paths::get_db_path; @@ -150,6 +151,7 @@ pub fn print_generate_docs(result: &GenerateDocsResult) { struct GenerateDocsJsonOutput { ok: bool, data: GenerateDocsJsonData, + meta: RobotMeta, } #[derive(Serialize)] @@ -162,7 +164,7 @@ struct GenerateDocsJsonData { errored: usize, } -pub fn print_generate_docs_json(result: &GenerateDocsResult) { +pub fn print_generate_docs_json(result: &GenerateDocsResult, elapsed_ms: u64) { let output = GenerateDocsJsonOutput { ok: true, data: GenerateDocsJsonData { @@ -180,6 +182,7 @@ pub fn print_generate_docs_json(result: &GenerateDocsResult) { unchanged: result.unchanged, errored: result.errored, }, + meta: RobotMeta { elapsed_ms }, }; println!("{}", serde_json::to_string(&output).unwrap()); } diff --git a/src/cli/commands/ingest.rs b/src/cli/commands/ingest.rs index 9897bdb..0d625d7 100644 --- a/src/cli/commands/ingest.rs +++ b/src/cli/commands/ingest.rs @@ -9,6 +9,7 @@ use serde::Serialize; use tracing::Instrument; use crate::Config; +use crate::cli::robot::RobotMeta; use crate::core::db::create_connection; use crate::core::error::{LoreError, Result}; use crate::core::lock::{AppLock, LockOptions}; @@ -732,6 +733,7 @@ fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) { struct IngestJsonOutput { ok: bool, data: IngestJsonData, + meta: RobotMeta, } #[derive(Serialize)] @@ -768,7 +770,7 @@ struct IngestMrStats { diffnotes_count: usize, } -pub fn print_ingest_summary_json(result: &IngestResult) { +pub fn print_ingest_summary_json(result: &IngestResult, elapsed_ms: u64) { let (issues, merge_requests) = if result.resource_type == "issues" { ( Some(IngestIssueStats { @@ -807,6 +809,7 @@ pub fn print_ingest_summary_json(result: &IngestResult) { resource_events_fetched: result.resource_events_fetched, resource_events_failed: result.resource_events_failed, }, + meta: RobotMeta { elapsed_ms }, }; println!("{}", serde_json::to_string(&output).unwrap()); diff --git a/src/cli/commands/list.rs b/src/cli/commands/list.rs index 7312c9b..a8ccfd5 100644 --- a/src/cli/commands/list.rs +++ b/src/cli/commands/list.rs @@ -3,6 +3,7 @@ use rusqlite::Connection; use serde::Serialize; use crate::Config; +use crate::cli::robot::{RobotMeta, expand_fields_preset, filter_fields}; use crate::core::db::create_connection; use crate::core::error::{LoreError, Result}; use crate::core::paths::get_db_path; @@ -734,9 +735,20 @@ pub fn print_list_issues(result: &ListResult) { println!("{table}"); } -pub fn print_list_issues_json(result: &ListResult) { +pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) { let json_result = ListResultJson::from(result); - match serde_json::to_string_pretty(&json_result) { + let meta = RobotMeta { elapsed_ms }; + let output = serde_json::json!({ + "ok": true, + "data": json_result, + "meta": meta, + }); + let mut output = output; + if let Some(f) = fields { + let expanded = expand_fields_preset(f, "issues"); + filter_fields(&mut output, "issues", &expanded); + } + match serde_json::to_string(&output) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } @@ -819,9 +831,20 @@ pub fn print_list_mrs(result: &MrListResult) { println!("{table}"); } -pub fn print_list_mrs_json(result: &MrListResult) { +pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) { let json_result = MrListResultJson::from(result); - match serde_json::to_string_pretty(&json_result) { + let meta = RobotMeta { elapsed_ms }; + let output = serde_json::json!({ + "ok": true, + "data": json_result, + "meta": meta, + }); + let mut output = output; + if let Some(f) = fields { + let expanded = expand_fields_preset(f, "mrs"); + filter_fields(&mut output, "mrs", &expanded); + } + match serde_json::to_string(&output) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } diff --git a/src/cli/commands/show.rs b/src/cli/commands/show.rs index c680270..54f0c0b 100644 --- a/src/cli/commands/show.rs +++ b/src/cli/commands/show.rs @@ -3,6 +3,7 @@ use rusqlite::Connection; use serde::Serialize; use crate::Config; +use crate::cli::robot::RobotMeta; use crate::core::db::create_connection; use crate::core::error::{LoreError, Result}; use crate::core::paths::get_db_path; @@ -1042,17 +1043,29 @@ impl From<&MrNoteDetail> for MrNoteDetailJson { } } -pub fn print_show_issue_json(issue: &IssueDetail) { +pub fn print_show_issue_json(issue: &IssueDetail, elapsed_ms: u64) { let json_result = IssueDetailJson::from(issue); - match serde_json::to_string_pretty(&json_result) { + let meta = RobotMeta { elapsed_ms }; + let output = serde_json::json!({ + "ok": true, + "data": json_result, + "meta": meta, + }); + match serde_json::to_string(&output) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } } -pub fn print_show_mr_json(mr: &MrDetail) { +pub fn print_show_mr_json(mr: &MrDetail, elapsed_ms: u64) { let json_result = MrDetailJson::from(mr); - match serde_json::to_string_pretty(&json_result) { + let meta = RobotMeta { elapsed_ms }; + let output = serde_json::json!({ + "ok": true, + "data": json_result, + "meta": meta, + }); + match serde_json::to_string(&output) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } diff --git a/src/cli/commands/stats.rs b/src/cli/commands/stats.rs index 2c44b80..9c4115b 100644 --- a/src/cli/commands/stats.rs +++ b/src/cli/commands/stats.rs @@ -3,6 +3,7 @@ use rusqlite::Connection; use serde::Serialize; use crate::Config; +use crate::cli::robot::RobotMeta; use crate::core::db::create_connection; use crate::core::error::Result; use crate::core::paths::get_db_path; @@ -441,9 +442,10 @@ pub fn print_stats(result: &StatsResult) { struct StatsJsonOutput { ok: bool, data: StatsResult, + meta: RobotMeta, } -pub fn print_stats_json(result: &StatsResult) { +pub fn print_stats_json(result: &StatsResult, elapsed_ms: u64) { let output = StatsJsonOutput { ok: true, data: StatsResult { @@ -471,6 +473,7 @@ pub fn print_stats_json(result: &StatsResult) { }), }), }, + meta: RobotMeta { elapsed_ms }, }; println!("{}", serde_json::to_string(&output).unwrap()); } diff --git a/src/cli/commands/sync_status.rs b/src/cli/commands/sync_status.rs index 69f4708..03640d8 100644 --- a/src/cli/commands/sync_status.rs +++ b/src/cli/commands/sync_status.rs @@ -3,6 +3,7 @@ use rusqlite::Connection; use serde::Serialize; use crate::Config; +use crate::cli::robot::RobotMeta; use crate::core::db::create_connection; use crate::core::error::Result; use crate::core::metrics::StageTiming; @@ -190,6 +191,7 @@ fn format_number(n: i64) -> String { struct SyncStatusJsonOutput { ok: bool, data: SyncStatusJsonData, + meta: RobotMeta, } #[derive(Serialize)] @@ -238,7 +240,7 @@ struct SummaryJsonInfo { system_notes: i64, } -pub fn print_sync_status_json(result: &SyncStatusResult) { +pub fn print_sync_status_json(result: &SyncStatusResult, elapsed_ms: u64) { let runs = result .runs .iter() @@ -284,6 +286,7 @@ pub fn print_sync_status_json(result: &SyncStatusResult) { system_notes: result.summary.system_note_count, }, }, + meta: RobotMeta { elapsed_ms }, }; println!("{}", serde_json::to_string(&output).unwrap()); diff --git a/src/cli/robot.rs b/src/cli/robot.rs new file mode 100644 index 0000000..6335f92 --- /dev/null +++ b/src/cli/robot.rs @@ -0,0 +1,44 @@ +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct RobotMeta { + pub elapsed_ms: u64, +} + +/// Filter JSON object fields in-place for `--fields` support. +/// Retains only the specified field names on each item in the list array. +pub fn filter_fields(value: &mut serde_json::Value, list_key: &str, fields: &[String]) { + if fields.is_empty() { + return; + } + if let Some(items) = value + .get_mut("data") + .and_then(|d| d.get_mut(list_key)) + .and_then(|v| v.as_array_mut()) + { + for item in items { + if let Some(obj) = item.as_object_mut() { + obj.retain(|k, _| fields.iter().any(|f| f == k)); + } + } + } +} + +/// Expand the `minimal` preset into concrete field names. +pub fn expand_fields_preset(fields: &[String], entity: &str) -> Vec { + if fields.len() == 1 && fields[0] == "minimal" { + match entity { + "issues" => ["iid", "title", "state", "updated_at_iso"] + .iter() + .map(|s| (*s).to_string()) + .collect(), + "mrs" => ["iid", "title", "state", "updated_at_iso"] + .iter() + .map(|s| (*s).to_string()) + .collect(), + _ => fields.to_vec(), + } + } else { + fields.to_vec() + } +} diff --git a/src/core/error.rs b/src/core/error.rs index 54cbbe8..9f501df 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -239,11 +239,32 @@ impl LoreError { self.code().exit_code() } + pub fn actions(&self) -> Vec<&'static str> { + match self { + Self::ConfigNotFound { .. } => vec!["lore init"], + Self::ConfigInvalid { .. } => vec!["lore init --force"], + Self::GitLabAuthFailed => { + vec!["export GITLAB_TOKEN=glpat-xxx", "lore auth"] + } + Self::TokenNotSet { .. } => vec!["export GITLAB_TOKEN=glpat-xxx"], + Self::OllamaUnavailable { .. } => vec!["ollama serve"], + Self::OllamaModelNotFound { .. } => vec!["ollama pull nomic-embed-text"], + Self::DatabaseLocked { .. } => vec!["lore ingest --force"], + Self::EmbeddingsNotBuilt => vec!["lore embed"], + Self::EmbeddingFailed { .. } => vec!["lore embed --retry-failed"], + Self::MigrationFailed { .. } => vec!["lore migrate"], + Self::GitLabNetworkError { .. } => vec!["lore doctor"], + _ => vec![], + } + } + pub fn to_robot_error(&self) -> RobotError { + let actions = self.actions().into_iter().map(String::from).collect(); RobotError { code: self.code().to_string(), message: self.to_string(), suggestion: self.suggestion().map(String::from), + actions, } } } @@ -254,6 +275,8 @@ pub struct RobotError { pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub suggestion: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub actions: Vec, } #[derive(Debug, Serialize)]