use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const CONSOLE_METRICS_METHOD: &str = "console_metrics";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ServiceHealth {
Ok,
Degraded,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsoleMetricsReport {
pub service_id: String,
pub display_name: String,
pub version: String,
pub status: ServiceHealth,
pub metrics: Value,
pub metrics_schema_version: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub collected_at_unix: Option<u64>,
}
pub fn make_report(
service_id: impl Into<String>,
display_name: impl Into<String>,
version: impl Into<String>,
status: ServiceHealth,
metrics: Value,
metrics_schema_version: u32,
) -> ConsoleMetricsReport {
let collected_at_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs());
ConsoleMetricsReport {
service_id: service_id.into(),
display_name: display_name.into(),
version: version.into(),
status,
metrics,
metrics_schema_version,
collected_at_unix,
}
}
pub fn serialise_report(report: &ConsoleMetricsReport) -> Result<Value> {
let text = serde_json::to_string(report).context("serialising ConsoleMetricsReport")?;
Ok(serde_json::json!({
"content": [
{ "type": "text", "text": text }
]
}))
}
pub fn parse_report(raw: &Value) -> Result<ConsoleMetricsReport> {
let text = raw
.get("content")
.and_then(|c| c.as_array())
.and_then(|a| a.first())
.and_then(|item| item.get("text"))
.and_then(|t| t.as_str())
.context("MCP tool result missing content[0].text")?;
serde_json::from_str::<ConsoleMetricsReport>(text)
.context("deserialising ConsoleMetricsReport from tool result text")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn method_constant_value() {
assert_eq!(CONSOLE_METRICS_METHOD, "console_metrics");
}
#[test]
fn service_health_serde_round_trip() {
let cases = [
(ServiceHealth::Ok, "\"ok\""),
(ServiceHealth::Degraded, "\"degraded\""),
(ServiceHealth::Error, "\"error\""),
];
for (variant, expected_json) in &cases {
let serialised = serde_json::to_string(variant).unwrap();
assert_eq!(&serialised, expected_json, "wrong JSON for {variant:?}");
let roundtripped: ServiceHealth = serde_json::from_str(&serialised).unwrap();
assert_eq!(&roundtripped, variant, "round-trip failed for {variant:?}");
}
}
#[test]
fn report_round_trips_all_fields() {
let metrics = json!({ "index_count": 42, "warm_boot_degraded": false });
let report = ConsoleMetricsReport {
service_id: "trusty-search".to_string(),
display_name: "Search".to_string(),
version: "0.24.1".to_string(),
status: ServiceHealth::Ok,
metrics: metrics.clone(),
metrics_schema_version: 3,
collected_at_unix: Some(1_700_000_000),
};
let json_str = serde_json::to_string(&report).unwrap();
let decoded: ConsoleMetricsReport = serde_json::from_str(&json_str).unwrap();
assert_eq!(decoded.service_id, "trusty-search");
assert_eq!(decoded.display_name, "Search");
assert_eq!(decoded.version, "0.24.1");
assert_eq!(decoded.status, ServiceHealth::Ok);
assert_eq!(decoded.metrics, metrics);
assert_eq!(decoded.metrics_schema_version, 3);
assert_eq!(decoded.collected_at_unix, Some(1_700_000_000));
}
#[test]
fn report_minimal_round_trip() {
let report = ConsoleMetricsReport {
service_id: "trusty-memory".to_string(),
display_name: "Memory".to_string(),
version: "0.15.0".to_string(),
status: ServiceHealth::Degraded,
metrics: json!(null),
metrics_schema_version: 1,
collected_at_unix: None,
};
let json_str = serde_json::to_string(&report).unwrap();
let raw: Value = serde_json::from_str(&json_str).unwrap();
assert!(
raw.get("collected_at_unix").is_none(),
"collected_at_unix should be absent when None"
);
let decoded: ConsoleMetricsReport = serde_json::from_str(&json_str).unwrap();
assert_eq!(decoded.collected_at_unix, None);
assert_eq!(decoded.status, ServiceHealth::Degraded);
}
#[test]
fn schema_version_boundary_values() {
for version in [0u32, u32::MAX] {
let report = ConsoleMetricsReport {
service_id: "svc".to_string(),
display_name: "Svc".to_string(),
version: "1.0.0".to_string(),
status: ServiceHealth::Ok,
metrics: json!({}),
metrics_schema_version: version,
collected_at_unix: None,
};
let json_str = serde_json::to_string(&report).unwrap();
let decoded: ConsoleMetricsReport = serde_json::from_str(&json_str).unwrap();
assert_eq!(
decoded.metrics_schema_version, version,
"schema_version {version} did not survive round-trip"
);
}
}
#[test]
fn make_report_sets_collection_time() {
let before = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let report = make_report(
"trusty-analyze",
"Analyze",
"0.5.0",
ServiceHealth::Ok,
json!({ "files_analyzed": 100 }),
2,
);
let after = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
assert_eq!(report.service_id, "trusty-analyze");
assert_eq!(report.display_name, "Analyze");
assert_eq!(report.version, "0.5.0");
assert_eq!(report.status, ServiceHealth::Ok);
assert_eq!(report.metrics_schema_version, 2);
let ts = report
.collected_at_unix
.expect("make_report should set collected_at_unix");
assert!(
ts >= before && ts <= after + 1,
"collected_at_unix {ts} is outside [{before}, {after}]"
);
}
#[test]
fn parse_report_decodes_valid_json() {
let original = ConsoleMetricsReport {
service_id: "trusty-search".to_string(),
display_name: "Search".to_string(),
version: "0.24.1".to_string(),
status: ServiceHealth::Ok,
metrics: json!({ "index_count": 7 }),
metrics_schema_version: 1,
collected_at_unix: Some(1_700_000_000),
};
let envelope = serialise_report(&original).unwrap();
let decoded = parse_report(&envelope).unwrap();
assert_eq!(decoded.service_id, original.service_id);
assert_eq!(decoded.display_name, original.display_name);
assert_eq!(decoded.version, original.version);
assert_eq!(decoded.status, original.status);
assert_eq!(decoded.metrics, original.metrics);
assert_eq!(
decoded.metrics_schema_version,
original.metrics_schema_version
);
assert_eq!(decoded.collected_at_unix, original.collected_at_unix);
}
#[test]
fn serialise_report_produces_mcp_envelope() {
let report = make_report("svc", "Svc", "1.0.0", ServiceHealth::Ok, json!({}), 1);
let envelope = serialise_report(&report).unwrap();
let content = envelope
.get("content")
.and_then(|c| c.as_array())
.expect("envelope should have `content` array");
assert_eq!(content.len(), 1, "content should have exactly one item");
assert_eq!(
content[0].get("type").and_then(|t| t.as_str()),
Some("text"),
"content[0].type should be \"text\""
);
assert!(
content[0].get("text").and_then(|t| t.as_str()).is_some(),
"content[0].text should be a string"
);
}
#[test]
fn parse_report_errors_on_bad_envelope() {
let bad = json!({ "result": "some random value" });
assert!(
parse_report(&bad).is_err(),
"parse_report should error on missing content envelope"
);
}
}