RobotMeta previously required direct struct literal construction with only elapsed_ms. This made it impossible to add optional fields without updating every call site to include them. Introduce two constructors: - RobotMeta::new(elapsed_ms) — standard meta with timing only - RobotMeta::with_base_url(elapsed_ms, base_url) — meta enriched with the GitLab instance URL, enabling consumers to construct entity links without needing config access The gitlab_base_url field uses #[serde(skip_serializing_if = "Option::is_none")] so existing JSON envelopes are byte-identical — no breaking change for any robot mode consumer. All 22 call sites across handlers, count, cron, drift, embed, generate_docs, ingest, list (mrs/notes), related, show, stats, sync_status, and who are updated from struct literals to RobotMeta::new(). Three tests verify the new constructors and trailing-slash normalization. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
593 lines
21 KiB
Rust
593 lines
21 KiB
Rust
use crate::cli::render::{self, Theme};
|
|
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;
|
|
|
|
#[derive(Debug, Default, Serialize)]
|
|
pub struct StatsResult {
|
|
pub documents: DocumentStats,
|
|
pub embeddings: EmbeddingStats,
|
|
pub fts: FtsStats,
|
|
pub queues: QueueStats,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub integrity: Option<IntegrityResult>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Serialize)]
|
|
pub struct DocumentStats {
|
|
pub total: i64,
|
|
pub issues: i64,
|
|
pub merge_requests: i64,
|
|
pub discussions: i64,
|
|
pub truncated: i64,
|
|
}
|
|
|
|
#[derive(Debug, Default, Serialize)]
|
|
pub struct EmbeddingStats {
|
|
pub embedded_documents: i64,
|
|
pub total_chunks: i64,
|
|
pub coverage_pct: f64,
|
|
}
|
|
|
|
#[derive(Debug, Default, Serialize)]
|
|
pub struct FtsStats {
|
|
pub indexed: i64,
|
|
}
|
|
|
|
#[derive(Debug, Default, Serialize)]
|
|
pub struct QueueStats {
|
|
pub dirty_sources: i64,
|
|
pub dirty_sources_failed: i64,
|
|
pub pending_discussion_fetches: i64,
|
|
pub pending_discussion_fetches_failed: i64,
|
|
pub pending_dependent_fetches: i64,
|
|
pub pending_dependent_fetches_failed: i64,
|
|
pub pending_dependent_fetches_stuck: i64,
|
|
}
|
|
|
|
#[derive(Debug, Default, Serialize)]
|
|
pub struct IntegrityResult {
|
|
pub ok: bool,
|
|
pub fts_doc_mismatch: bool,
|
|
pub orphan_embeddings: i64,
|
|
pub stale_metadata: i64,
|
|
pub orphan_state_events: i64,
|
|
pub orphan_label_events: i64,
|
|
pub orphan_milestone_events: i64,
|
|
pub queue_stuck_locks: i64,
|
|
pub queue_max_attempts: i64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub repair: Option<RepairResult>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Serialize)]
|
|
pub struct RepairResult {
|
|
pub fts_rebuilt: bool,
|
|
pub orphans_deleted: i64,
|
|
pub stale_cleared: i64,
|
|
pub dry_run: bool,
|
|
}
|
|
|
|
pub fn run_stats(config: &Config, check: bool, repair: bool, dry_run: bool) -> Result<StatsResult> {
|
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
|
let conn = create_connection(&db_path)?;
|
|
|
|
let mut result = StatsResult::default();
|
|
|
|
// Single-scan conditional aggregate: 5 sequential COUNT(*) → 1 table scan
|
|
let (total, issues, mrs, discussions, truncated) = conn
|
|
.query_row(
|
|
"SELECT COUNT(*),
|
|
COALESCE(SUM(CASE WHEN source_type = 'issue' THEN 1 END), 0),
|
|
COALESCE(SUM(CASE WHEN source_type = 'merge_request' THEN 1 END), 0),
|
|
COALESCE(SUM(CASE WHEN source_type = 'discussion' THEN 1 END), 0),
|
|
COALESCE(SUM(CASE WHEN is_truncated = 1 THEN 1 END), 0)
|
|
FROM documents",
|
|
[],
|
|
|row| {
|
|
Ok((
|
|
row.get::<_, i64>(0)?,
|
|
row.get::<_, i64>(1)?,
|
|
row.get::<_, i64>(2)?,
|
|
row.get::<_, i64>(3)?,
|
|
row.get::<_, i64>(4)?,
|
|
))
|
|
},
|
|
)
|
|
.unwrap_or((0, 0, 0, 0, 0));
|
|
result.documents.total = total;
|
|
result.documents.issues = issues;
|
|
result.documents.merge_requests = mrs;
|
|
result.documents.discussions = discussions;
|
|
result.documents.truncated = truncated;
|
|
|
|
if table_exists(&conn, "embedding_metadata") {
|
|
// Single scan: COUNT(DISTINCT) + COUNT(*) in one pass
|
|
let (embedded, chunks) = conn
|
|
.query_row(
|
|
"SELECT COUNT(DISTINCT document_id), COUNT(*)
|
|
FROM embedding_metadata WHERE last_error IS NULL",
|
|
[],
|
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
|
)
|
|
.unwrap_or((0, 0));
|
|
result.embeddings.embedded_documents = embedded;
|
|
result.embeddings.total_chunks = chunks;
|
|
result.embeddings.coverage_pct = if result.documents.total > 0 {
|
|
(embedded as f64 / result.documents.total as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
}
|
|
|
|
// FTS5 shadow table is a regular B-tree with one row per document —
|
|
// 19x faster than scanning the virtual table for COUNT(*)
|
|
result.fts.indexed = count_query(&conn, "SELECT COUNT(*) FROM documents_fts_docsize")?;
|
|
|
|
// Single scan: 2 conditional counts on dirty_sources
|
|
let (ds_pending, ds_failed) = conn
|
|
.query_row(
|
|
"SELECT COALESCE(SUM(CASE WHEN last_error IS NULL THEN 1 END), 0),
|
|
COALESCE(SUM(CASE WHEN last_error IS NOT NULL THEN 1 END), 0)
|
|
FROM dirty_sources",
|
|
[],
|
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
|
)
|
|
.unwrap_or((0, 0));
|
|
result.queues.dirty_sources = ds_pending;
|
|
result.queues.dirty_sources_failed = ds_failed;
|
|
|
|
if table_exists(&conn, "pending_discussion_fetches") {
|
|
let (pdf_pending, pdf_failed) = conn
|
|
.query_row(
|
|
"SELECT COALESCE(SUM(CASE WHEN last_error IS NULL THEN 1 END), 0),
|
|
COALESCE(SUM(CASE WHEN last_error IS NOT NULL THEN 1 END), 0)
|
|
FROM pending_discussion_fetches",
|
|
[],
|
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
|
)
|
|
.unwrap_or((0, 0));
|
|
result.queues.pending_discussion_fetches = pdf_pending;
|
|
result.queues.pending_discussion_fetches_failed = pdf_failed;
|
|
}
|
|
|
|
if table_exists(&conn, "pending_dependent_fetches") {
|
|
let (pf_pending, pf_failed, pf_stuck) = conn
|
|
.query_row(
|
|
"SELECT COALESCE(SUM(CASE WHEN last_error IS NULL THEN 1 END), 0),
|
|
COALESCE(SUM(CASE WHEN last_error IS NOT NULL THEN 1 END), 0),
|
|
COALESCE(SUM(CASE WHEN locked_at IS NOT NULL THEN 1 END), 0)
|
|
FROM pending_dependent_fetches",
|
|
[],
|
|
|row| {
|
|
Ok((
|
|
row.get::<_, i64>(0)?,
|
|
row.get::<_, i64>(1)?,
|
|
row.get::<_, i64>(2)?,
|
|
))
|
|
},
|
|
)
|
|
.unwrap_or((0, 0, 0));
|
|
result.queues.pending_dependent_fetches = pf_pending;
|
|
result.queues.pending_dependent_fetches_failed = pf_failed;
|
|
result.queues.pending_dependent_fetches_stuck = pf_stuck;
|
|
}
|
|
|
|
#[allow(clippy::field_reassign_with_default)]
|
|
if check {
|
|
let mut integrity = IntegrityResult::default();
|
|
|
|
integrity.fts_doc_mismatch = result.fts.indexed != result.documents.total;
|
|
|
|
if table_exists(&conn, "embeddings") {
|
|
integrity.orphan_embeddings = count_query(
|
|
&conn,
|
|
"SELECT COUNT(*) FROM embedding_metadata em
|
|
WHERE NOT EXISTS (SELECT 1 FROM documents d WHERE d.id = em.document_id)",
|
|
)?;
|
|
}
|
|
|
|
if table_exists(&conn, "embedding_metadata") {
|
|
integrity.stale_metadata = count_query(
|
|
&conn,
|
|
"SELECT COUNT(*) FROM embedding_metadata em
|
|
JOIN documents d ON d.id = em.document_id
|
|
WHERE em.chunk_index = 0 AND em.document_hash != d.content_hash",
|
|
)?;
|
|
}
|
|
|
|
if table_exists(&conn, "resource_state_events") {
|
|
integrity.orphan_state_events = count_query(
|
|
&conn,
|
|
"SELECT COUNT(*) FROM resource_state_events rse
|
|
WHERE (rse.issue_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM issues i WHERE i.id = rse.issue_id))
|
|
OR (rse.merge_request_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM merge_requests m WHERE m.id = rse.merge_request_id))",
|
|
)?;
|
|
}
|
|
if table_exists(&conn, "resource_label_events") {
|
|
integrity.orphan_label_events = count_query(
|
|
&conn,
|
|
"SELECT COUNT(*) FROM resource_label_events rle
|
|
WHERE (rle.issue_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM issues i WHERE i.id = rle.issue_id))
|
|
OR (rle.merge_request_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM merge_requests m WHERE m.id = rle.merge_request_id))",
|
|
)?;
|
|
}
|
|
if table_exists(&conn, "resource_milestone_events") {
|
|
integrity.orphan_milestone_events = count_query(
|
|
&conn,
|
|
"SELECT COUNT(*) FROM resource_milestone_events rme
|
|
WHERE (rme.issue_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM issues i WHERE i.id = rme.issue_id))
|
|
OR (rme.merge_request_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM merge_requests m WHERE m.id = rme.merge_request_id))",
|
|
)?;
|
|
}
|
|
|
|
if table_exists(&conn, "pending_dependent_fetches") {
|
|
integrity.queue_stuck_locks = count_query(
|
|
&conn,
|
|
"SELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL",
|
|
)?;
|
|
integrity.queue_max_attempts = count_query(
|
|
&conn,
|
|
"SELECT COALESCE(MAX(attempts), 0) FROM pending_dependent_fetches",
|
|
)?;
|
|
}
|
|
|
|
let orphan_events = integrity.orphan_state_events
|
|
+ integrity.orphan_label_events
|
|
+ integrity.orphan_milestone_events;
|
|
|
|
integrity.ok = !integrity.fts_doc_mismatch
|
|
&& integrity.orphan_embeddings == 0
|
|
&& integrity.stale_metadata == 0
|
|
&& orphan_events == 0;
|
|
|
|
if repair {
|
|
let mut repair_result = RepairResult::default();
|
|
repair_result.dry_run = dry_run;
|
|
|
|
if integrity.fts_doc_mismatch {
|
|
if !dry_run {
|
|
conn.execute(
|
|
"INSERT INTO documents_fts(documents_fts) VALUES('rebuild')",
|
|
[],
|
|
)?;
|
|
}
|
|
repair_result.fts_rebuilt = true;
|
|
}
|
|
|
|
if integrity.orphan_embeddings > 0 && table_exists(&conn, "embedding_metadata") {
|
|
if !dry_run {
|
|
let deleted = conn.execute(
|
|
"DELETE FROM embedding_metadata
|
|
WHERE NOT EXISTS (SELECT 1 FROM documents d WHERE d.id = embedding_metadata.document_id)",
|
|
[],
|
|
)?;
|
|
repair_result.orphans_deleted = deleted as i64;
|
|
|
|
if table_exists(&conn, "embeddings") {
|
|
let _ = conn.execute(
|
|
"DELETE FROM embeddings
|
|
WHERE rowid / 1000 NOT IN (SELECT id FROM documents)",
|
|
[],
|
|
);
|
|
}
|
|
} else {
|
|
repair_result.orphans_deleted = integrity.orphan_embeddings;
|
|
}
|
|
}
|
|
|
|
if integrity.stale_metadata > 0 && table_exists(&conn, "embedding_metadata") {
|
|
if !dry_run {
|
|
let cleared = conn.execute(
|
|
"DELETE FROM embedding_metadata
|
|
WHERE document_id IN (
|
|
SELECT em.document_id FROM embedding_metadata em
|
|
JOIN documents d ON d.id = em.document_id
|
|
WHERE em.chunk_index = 0 AND em.document_hash != d.content_hash
|
|
)",
|
|
[],
|
|
)?;
|
|
repair_result.stale_cleared = cleared as i64;
|
|
} else {
|
|
repair_result.stale_cleared = integrity.stale_metadata;
|
|
}
|
|
}
|
|
|
|
integrity.repair = Some(repair_result);
|
|
}
|
|
|
|
result.integrity = Some(integrity);
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
fn count_query(conn: &Connection, sql: &str) -> Result<i64> {
|
|
let count: i64 = conn.query_row(sql, [], |row| row.get(0)).unwrap_or(0);
|
|
Ok(count)
|
|
}
|
|
|
|
fn table_exists(conn: &Connection, table: &str) -> bool {
|
|
conn.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
|
|
[table],
|
|
|row| row.get::<_, i64>(0),
|
|
)
|
|
.unwrap_or(0)
|
|
> 0
|
|
}
|
|
|
|
fn section(title: &str) {
|
|
println!("{}", render::section_divider(title));
|
|
}
|
|
|
|
pub fn print_stats(result: &StatsResult) {
|
|
section("Documents");
|
|
let mut parts = vec![format!(
|
|
"{} total",
|
|
render::format_number(result.documents.total)
|
|
)];
|
|
if result.documents.issues > 0 {
|
|
parts.push(format!(
|
|
"{} issues",
|
|
render::format_number(result.documents.issues)
|
|
));
|
|
}
|
|
if result.documents.merge_requests > 0 {
|
|
parts.push(format!(
|
|
"{} MRs",
|
|
render::format_number(result.documents.merge_requests)
|
|
));
|
|
}
|
|
if result.documents.discussions > 0 {
|
|
parts.push(format!(
|
|
"{} discussions",
|
|
render::format_number(result.documents.discussions)
|
|
));
|
|
}
|
|
println!(" {}", parts.join(" \u{b7} "));
|
|
if result.documents.truncated > 0 {
|
|
println!(
|
|
" {}",
|
|
Theme::warning().render(&format!(
|
|
"{} truncated",
|
|
render::format_number(result.documents.truncated)
|
|
))
|
|
);
|
|
}
|
|
|
|
section("Search Index");
|
|
println!(
|
|
" {} FTS indexed",
|
|
render::format_number(result.fts.indexed)
|
|
);
|
|
let coverage_color = if result.embeddings.coverage_pct >= 95.0 {
|
|
Theme::success().render(&format!("{:.0}%", result.embeddings.coverage_pct))
|
|
} else if result.embeddings.coverage_pct >= 50.0 {
|
|
Theme::warning().render(&format!("{:.0}%", result.embeddings.coverage_pct))
|
|
} else {
|
|
Theme::error().render(&format!("{:.0}%", result.embeddings.coverage_pct))
|
|
};
|
|
println!(
|
|
" {} embedding coverage ({}/{})",
|
|
coverage_color,
|
|
render::format_number(result.embeddings.embedded_documents),
|
|
render::format_number(result.documents.total),
|
|
);
|
|
if result.embeddings.total_chunks > 0 {
|
|
println!(
|
|
" {}",
|
|
Theme::dim().render(&format!(
|
|
"{} chunks",
|
|
render::format_number(result.embeddings.total_chunks)
|
|
))
|
|
);
|
|
}
|
|
|
|
// Queues: only show if there's anything to report
|
|
let has_queue_activity = result.queues.dirty_sources > 0
|
|
|| result.queues.dirty_sources_failed > 0
|
|
|| result.queues.pending_discussion_fetches > 0
|
|
|| result.queues.pending_discussion_fetches_failed > 0
|
|
|| result.queues.pending_dependent_fetches > 0
|
|
|| result.queues.pending_dependent_fetches_failed > 0;
|
|
|
|
if has_queue_activity {
|
|
section("Queues");
|
|
if result.queues.dirty_sources > 0 || result.queues.dirty_sources_failed > 0 {
|
|
let mut q = Vec::new();
|
|
if result.queues.dirty_sources > 0 {
|
|
q.push(format!("{} pending", result.queues.dirty_sources));
|
|
}
|
|
if result.queues.dirty_sources_failed > 0 {
|
|
q.push(
|
|
Theme::error()
|
|
.render(&format!("{} failed", result.queues.dirty_sources_failed)),
|
|
);
|
|
}
|
|
println!(" dirty sources: {}", q.join(", "));
|
|
}
|
|
if result.queues.pending_discussion_fetches > 0
|
|
|| result.queues.pending_discussion_fetches_failed > 0
|
|
{
|
|
let mut q = Vec::new();
|
|
if result.queues.pending_discussion_fetches > 0 {
|
|
q.push(format!(
|
|
"{} pending",
|
|
result.queues.pending_discussion_fetches
|
|
));
|
|
}
|
|
if result.queues.pending_discussion_fetches_failed > 0 {
|
|
q.push(Theme::error().render(&format!(
|
|
"{} failed",
|
|
result.queues.pending_discussion_fetches_failed
|
|
)));
|
|
}
|
|
println!(" discussion fetch: {}", q.join(", "));
|
|
}
|
|
if result.queues.pending_dependent_fetches > 0
|
|
|| result.queues.pending_dependent_fetches_failed > 0
|
|
{
|
|
let mut q = Vec::new();
|
|
if result.queues.pending_dependent_fetches > 0 {
|
|
q.push(format!(
|
|
"{} pending",
|
|
result.queues.pending_dependent_fetches
|
|
));
|
|
}
|
|
if result.queues.pending_dependent_fetches_failed > 0 {
|
|
q.push(Theme::error().render(&format!(
|
|
"{} failed",
|
|
result.queues.pending_dependent_fetches_failed
|
|
)));
|
|
}
|
|
if result.queues.pending_dependent_fetches_stuck > 0 {
|
|
q.push(Theme::warning().render(&format!(
|
|
"{} stuck",
|
|
result.queues.pending_dependent_fetches_stuck
|
|
)));
|
|
}
|
|
println!(" dependent fetch: {}", q.join(", "));
|
|
}
|
|
} else {
|
|
section("Queues");
|
|
println!(" {}", Theme::success().render("all clear"));
|
|
}
|
|
|
|
if let Some(ref integrity) = result.integrity {
|
|
section("Integrity");
|
|
if integrity.ok {
|
|
println!(
|
|
" {} all checks passed",
|
|
Theme::success().render("\u{2713}")
|
|
);
|
|
} else {
|
|
if integrity.fts_doc_mismatch {
|
|
println!(
|
|
" {} FTS/document count mismatch",
|
|
Theme::error().render("\u{2717}")
|
|
);
|
|
}
|
|
if integrity.orphan_embeddings > 0 {
|
|
println!(
|
|
" {} {} orphan embeddings",
|
|
Theme::error().render("\u{2717}"),
|
|
integrity.orphan_embeddings
|
|
);
|
|
}
|
|
if integrity.stale_metadata > 0 {
|
|
println!(
|
|
" {} {} stale embedding metadata",
|
|
Theme::error().render("\u{2717}"),
|
|
integrity.stale_metadata
|
|
);
|
|
}
|
|
let orphan_events = integrity.orphan_state_events
|
|
+ integrity.orphan_label_events
|
|
+ integrity.orphan_milestone_events;
|
|
if orphan_events > 0 {
|
|
println!(
|
|
" {} {} orphan resource events",
|
|
Theme::error().render("\u{2717}"),
|
|
orphan_events
|
|
);
|
|
}
|
|
if integrity.queue_stuck_locks > 0 {
|
|
println!(
|
|
" {} {} stuck queue locks",
|
|
Theme::warning().render("!"),
|
|
integrity.queue_stuck_locks
|
|
);
|
|
}
|
|
}
|
|
|
|
if let Some(ref repair) = integrity.repair {
|
|
println!();
|
|
if repair.dry_run {
|
|
println!(
|
|
" {} {}",
|
|
Theme::bold().render("Repair"),
|
|
Theme::warning().render("(dry run)")
|
|
);
|
|
} else {
|
|
println!(" {}", Theme::bold().render("Repair"));
|
|
}
|
|
|
|
let action = if repair.dry_run {
|
|
Theme::warning().render("would fix")
|
|
} else {
|
|
Theme::success().render("fixed")
|
|
};
|
|
|
|
if repair.fts_rebuilt {
|
|
println!(" {} FTS index rebuilt", action);
|
|
}
|
|
if repair.orphans_deleted > 0 {
|
|
println!(
|
|
" {} {} orphan embeddings deleted",
|
|
action, repair.orphans_deleted
|
|
);
|
|
}
|
|
if repair.stale_cleared > 0 {
|
|
println!(
|
|
" {} {} stale metadata cleared",
|
|
action, repair.stale_cleared
|
|
);
|
|
}
|
|
if !repair.fts_rebuilt && repair.orphans_deleted == 0 && repair.stale_cleared == 0 {
|
|
println!(" {}", Theme::dim().render("nothing to repair"));
|
|
}
|
|
}
|
|
}
|
|
|
|
println!();
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct StatsJsonOutput {
|
|
ok: bool,
|
|
data: StatsResult,
|
|
meta: RobotMeta,
|
|
}
|
|
|
|
pub fn print_stats_json(result: &StatsResult, elapsed_ms: u64) {
|
|
let output = StatsJsonOutput {
|
|
ok: true,
|
|
data: StatsResult {
|
|
documents: DocumentStats { ..result.documents },
|
|
embeddings: EmbeddingStats {
|
|
..result.embeddings
|
|
},
|
|
fts: FtsStats { ..result.fts },
|
|
queues: QueueStats { ..result.queues },
|
|
integrity: result.integrity.as_ref().map(|i| IntegrityResult {
|
|
ok: i.ok,
|
|
fts_doc_mismatch: i.fts_doc_mismatch,
|
|
orphan_embeddings: i.orphan_embeddings,
|
|
stale_metadata: i.stale_metadata,
|
|
orphan_state_events: i.orphan_state_events,
|
|
orphan_label_events: i.orphan_label_events,
|
|
orphan_milestone_events: i.orphan_milestone_events,
|
|
queue_stuck_locks: i.queue_stuck_locks,
|
|
queue_max_attempts: i.queue_max_attempts,
|
|
repair: i.repair.as_ref().map(|r| RepairResult {
|
|
fts_rebuilt: r.fts_rebuilt,
|
|
orphans_deleted: r.orphans_deleted,
|
|
stale_cleared: r.stale_cleared,
|
|
dry_run: r.dry_run,
|
|
}),
|
|
}),
|
|
},
|
|
meta: RobotMeta::new(elapsed_ms),
|
|
};
|
|
match serde_json::to_string(&output) {
|
|
Ok(json) => println!("{json}"),
|
|
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
|
}
|
|
}
|