use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::provider::Provider;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AssetType {
Agent,
Skill,
Rule,
McpRef,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CellStatus {
Installed,
Outdated,
Drifted,
NotInstalled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageCell {
pub status: CellStatus,
pub installed_version: Option<String>,
pub canonical_version: String,
pub installed_hash: Option<String>,
pub canonical_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageRow {
pub asset_id: String,
pub asset_type: AssetType,
pub asset_name: String,
pub provider: Provider,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageMatrix {
pub rows: Vec<CoverageRow>,
pub columns: Vec<String>,
#[serde(with = "cell_map_serde")]
pub cells: HashMap<(String, String), CoverageCell>,
pub workspace_scores: HashMap<String, f64>,
}
mod cell_map_serde {
use std::collections::HashMap;
use serde::de::{Deserializer, MapAccess, Visitor};
use serde::ser::{SerializeMap, Serializer};
use super::CoverageCell;
const SEP: char = '\x1f';
pub fn serialize<S>(
map: &HashMap<(String, String), CoverageCell>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut s = serializer.serialize_map(Some(map.len()))?;
for ((asset_id, workspace), cell) in map {
let key = format!("{}{}{}", asset_id, SEP, workspace);
s.serialize_entry(&key, cell)?;
}
s.end()
}
pub fn deserialize<'de, D>(
deserializer: D,
) -> Result<HashMap<(String, String), CoverageCell>, D::Error>
where
D: Deserializer<'de>,
{
struct MapVisitor;
impl<'de> Visitor<'de> for MapVisitor {
type Value = HashMap<(String, String), CoverageCell>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "a map with \"<asset_id>\\x1f<workspace>\" string keys")
}
fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
let mut map = HashMap::new();
while let Some((key, value)) = access.next_entry::<String, CoverageCell>()? {
let sep_pos = key.find('\x1f').ok_or_else(|| {
serde::de::Error::custom(format!(
"cell map key missing separator: {:?}",
key
))
})?;
let asset_id = key[..sep_pos].to_string();
let workspace = key[sep_pos + '\x1f'.len_utf8()..].to_string();
map.insert((asset_id, workspace), value);
}
Ok(map)
}
}
deserializer.deserialize_map(MapVisitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_cell(status: CellStatus) -> CoverageCell {
CoverageCell {
status,
installed_version: Some("1.0.0".to_string()),
canonical_version: "1.0.0".to_string(),
installed_hash: Some("abc123".to_string()),
canonical_hash: Some("abc123".to_string()),
}
}
#[test]
fn cell_status_round_trip() {
for status in [
CellStatus::Installed,
CellStatus::Outdated,
CellStatus::Drifted,
CellStatus::NotInstalled,
] {
let json = serde_json::to_string(&status).unwrap();
let decoded: CellStatus = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, status);
}
}
#[test]
fn cell_status_serialized_strings() {
assert_eq!(
serde_json::to_string(&CellStatus::Installed).unwrap(),
"\"installed\""
);
assert_eq!(
serde_json::to_string(&CellStatus::NotInstalled).unwrap(),
"\"not_installed\""
);
assert_eq!(
serde_json::to_string(&CellStatus::Drifted).unwrap(),
"\"drifted\""
);
assert_eq!(
serde_json::to_string(&CellStatus::Outdated).unwrap(),
"\"outdated\""
);
}
#[test]
fn coverage_cell_round_trip() {
let cell = make_cell(CellStatus::Outdated);
let json = serde_json::to_string(&cell).unwrap();
let decoded: CoverageCell = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.status, CellStatus::Outdated);
assert_eq!(decoded.canonical_version, "1.0.0");
}
#[test]
fn coverage_row_round_trip() {
let row = CoverageRow {
asset_id: "aws-iam-scanner".to_string(),
asset_type: AssetType::Agent,
asset_name: "AWS IAM Scanner".to_string(),
provider: Provider::Aws,
};
let json = serde_json::to_string(&row).unwrap();
let decoded: CoverageRow = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.asset_id, row.asset_id);
assert_eq!(decoded.asset_type, AssetType::Agent);
}
#[test]
fn asset_type_serialized_strings() {
assert_eq!(
serde_json::to_string(&AssetType::Agent).unwrap(),
"\"agent\""
);
assert_eq!(
serde_json::to_string(&AssetType::McpRef).unwrap(),
"\"mcp_ref\""
);
}
#[test]
fn coverage_matrix_round_trip() {
let mut cells = HashMap::new();
cells.insert(
("aws-iam-scanner".to_string(), "prod".to_string()),
make_cell(CellStatus::Installed),
);
let mut scores = HashMap::new();
scores.insert("prod".to_string(), 100.0_f64);
let matrix = CoverageMatrix {
rows: vec![CoverageRow {
asset_id: "aws-iam-scanner".to_string(),
asset_type: AssetType::Agent,
asset_name: "AWS IAM Scanner".to_string(),
provider: Provider::Aws,
}],
columns: vec!["prod".to_string()],
cells,
workspace_scores: scores,
};
let json = serde_json::to_string(&matrix).unwrap();
let decoded: CoverageMatrix = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.columns, vec!["prod"]);
assert!(decoded
.cells
.contains_key(&("aws-iam-scanner".to_string(), "prod".to_string())));
}
}