use serde_json;
use super::error::CassError;
use super::process::CassOutcome;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CassHealth {
pub status: String,
pub healthy: bool,
pub errors: Vec<String>,
pub latency_ms: u64,
pub db: CassDbHealth,
pub index: CassIndexHealth,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CassDbHealth {
pub exists: bool,
pub opened: bool,
pub conversations: Option<u64>,
pub messages: Option<u64>,
pub counts_skipped: bool,
pub open_skipped: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CassIndexHealth {
pub exists: bool,
pub status: String,
pub fresh: bool,
pub stale: bool,
pub documents: Option<u64>,
}
impl CassHealth {
pub fn parse(outcome: &CassOutcome) -> Result<Self, CassError> {
let stdout = outcome.stdout_utf8_lossy();
Self::parse_json(&stdout)
}
pub fn parse_json(json: &str) -> Result<Self, CassError> {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| CassError::InvalidStdoutJson {
hint: format!("failed to parse cass health JSON: {e}"),
})?;
let status = get_string(&value, "status")?;
let healthy = get_bool(&value, "healthy")?;
let errors = get_string_array(&value, "errors")?;
let latency_ms = get_u64(&value, "latency_ms")?;
let db_value = value
.get("db")
.ok_or_else(|| CassError::InvalidStdoutJson {
hint: "missing 'db' field".to_string(),
})?;
let db = CassDbHealth {
exists: get_bool(db_value, "exists")?,
opened: get_bool(db_value, "opened")?,
conversations: get_optional_u64(db_value, "conversations")?,
messages: get_optional_u64(db_value, "messages")?,
counts_skipped: get_bool_or_false(db_value, "counts_skipped")?,
open_skipped: get_bool_or_false(db_value, "open_skipped")?,
};
let state_value = value
.get("state")
.ok_or_else(|| CassError::InvalidStdoutJson {
hint: "missing 'state' field".to_string(),
})?;
let index_value = state_value
.get("index")
.ok_or_else(|| CassError::InvalidStdoutJson {
hint: "missing 'state.index' field".to_string(),
})?;
let index = CassIndexHealth {
exists: get_bool(index_value, "exists")?,
status: get_string(index_value, "status")?,
fresh: get_bool(index_value, "fresh")?,
stale: get_bool(index_value, "stale")?,
documents: get_optional_u64(index_value, "documents")?,
};
Ok(Self {
status,
healthy,
errors,
latency_ms,
db,
index,
})
}
#[must_use]
pub fn is_ready(&self) -> bool {
self.healthy && self.db.opened && self.index.fresh
}
#[must_use]
pub fn is_stale(&self) -> bool {
self.index.exists && self.index.stale
}
}
fn get_string(value: &serde_json::Value, field: &str) -> Result<String, CassError> {
value
.get(field)
.and_then(serde_json::Value::as_str)
.map(String::from)
.ok_or_else(|| CassError::InvalidStdoutJson {
hint: format!("missing or invalid '{field}' string field"),
})
}
fn get_bool(value: &serde_json::Value, field: &str) -> Result<bool, CassError> {
value
.get(field)
.and_then(serde_json::Value::as_bool)
.ok_or_else(|| CassError::InvalidStdoutJson {
hint: format!("missing or invalid '{field}' bool field"),
})
}
fn get_u64(value: &serde_json::Value, field: &str) -> Result<u64, CassError> {
value
.get(field)
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| CassError::InvalidStdoutJson {
hint: format!("missing or invalid '{field}' u64 field"),
})
}
fn get_optional_u64(value: &serde_json::Value, field: &str) -> Result<Option<u64>, CassError> {
match value.get(field) {
None | Some(serde_json::Value::Null) => Ok(None),
Some(number) => number
.as_u64()
.map(Some)
.ok_or_else(|| CassError::InvalidStdoutJson {
hint: format!("missing or invalid '{field}' u64 field"),
}),
}
}
fn get_bool_or_false(value: &serde_json::Value, field: &str) -> Result<bool, CassError> {
match value.get(field) {
Some(flag) => flag.as_bool().ok_or_else(|| CassError::InvalidStdoutJson {
hint: format!("missing or invalid '{field}' bool field"),
}),
None => Ok(false),
}
}
fn get_string_array(value: &serde_json::Value, field: &str) -> Result<Vec<String>, CassError> {
let arr = value
.get(field)
.and_then(serde_json::Value::as_array)
.ok_or_else(|| CassError::InvalidStdoutJson {
hint: format!("missing or invalid '{field}' array field"),
})?;
arr.iter()
.map(|v| {
v.as_str()
.map(String::from)
.ok_or_else(|| CassError::InvalidStdoutJson {
hint: format!("non-string element in '{field}' array"),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::CassHealth;
const HEALTH_FIXTURE: &str = include_str!("../../tests/fixtures/cass/health.v1.json");
type TestResult = Result<(), String>;
#[test]
fn parse_health_fixture_succeeds() -> TestResult {
let health = CassHealth::parse_json(HEALTH_FIXTURE)
.map_err(|e| format!("failed to parse health fixture: {e}"))?;
assert_eq!(health.status, "healthy");
assert!(health.healthy);
assert!(health.errors.is_empty());
assert!(health.db.exists);
assert!(health.db.opened);
assert_eq!(health.db.conversations, Some(42));
assert_eq!(health.db.messages, Some(1234));
assert!(!health.db.counts_skipped);
assert!(!health.db.open_skipped);
assert!(health.index.exists);
assert!(health.index.fresh);
assert_eq!(health.index.documents, Some(1234));
Ok(())
}
#[test]
fn is_ready_requires_healthy_and_fresh() {
let healthy_fresh = CassHealth {
status: "healthy".to_string(),
healthy: true,
errors: vec![],
latency_ms: 10,
db: super::CassDbHealth {
exists: true,
opened: true,
conversations: Some(10),
messages: Some(100),
counts_skipped: false,
open_skipped: false,
},
index: super::CassIndexHealth {
exists: true,
status: "fresh".to_string(),
fresh: true,
stale: false,
documents: Some(100),
},
};
assert!(healthy_fresh.is_ready());
let unhealthy = CassHealth {
healthy: false,
..healthy_fresh.clone()
};
assert!(!unhealthy.is_ready());
let stale = CassHealth {
index: super::CassIndexHealth {
fresh: false,
stale: true,
status: "stale".to_string(),
..healthy_fresh.index.clone()
},
..healthy_fresh
};
assert!(!stale.is_ready());
}
#[test]
fn is_stale_detects_stale_index() {
let stale = CassHealth {
status: "unhealthy".to_string(),
healthy: false,
errors: vec!["index stale".to_string()],
latency_ms: 100,
db: super::CassDbHealth {
exists: true,
opened: true,
conversations: Some(10),
messages: Some(100),
counts_skipped: false,
open_skipped: false,
},
index: super::CassIndexHealth {
exists: true,
status: "stale".to_string(),
fresh: false,
stale: true,
documents: Some(50),
},
};
assert!(stale.is_stale());
}
#[test]
fn parse_accepts_health_fast_path_unknown_counts() -> TestResult {
let payload = r#"{
"status": "unhealthy",
"healthy": false,
"errors": ["index not found"],
"latency_ms": 7,
"db": {
"exists": true,
"opened": true,
"conversations": null,
"messages": null,
"counts_skipped": true,
"open_skipped": true
},
"state": {
"index": {
"exists": false,
"status": "missing",
"fresh": false,
"stale": false,
"documents": null
}
}
}"#;
let health = CassHealth::parse_json(payload)
.map_err(|error| format!("fast-path health payload should parse: {error}"))?;
assert!(!health.healthy);
assert!(!health.is_ready());
assert_eq!(health.db.conversations, None);
assert_eq!(health.db.messages, None);
assert!(health.db.counts_skipped);
assert!(health.db.open_skipped);
assert_eq!(health.index.documents, None);
Ok(())
}
#[test]
fn parse_accepts_health_fast_path_omitted_optional_counts() -> TestResult {
let payload = r#"{
"status": "unhealthy",
"healthy": false,
"errors": ["index not found"],
"latency_ms": 7,
"db": {
"exists": true,
"opened": true,
"counts_skipped": true,
"open_skipped": true
},
"state": {
"index": {
"exists": false,
"status": "missing",
"fresh": false,
"stale": false
}
}
}"#;
let health = CassHealth::parse_json(payload)
.map_err(|error| format!("fast-path health payload should parse: {error}"))?;
assert_eq!(health.db.conversations, None);
assert_eq!(health.db.messages, None);
assert_eq!(health.index.documents, None);
assert!(health.db.counts_skipped);
assert!(health.db.open_skipped);
Ok(())
}
#[test]
fn parse_rejects_missing_fields() -> TestResult {
let result = CassHealth::parse_json("{}");
match result {
Err(e) => {
assert_eq!(e.kind_str(), "invalid_stdout_json");
Ok(())
}
Ok(_) => Err("expected parse to fail on empty object".to_string()),
}
}
}