1use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7use super::provider::Provider;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum AssetType {
13 Agent,
14 Skill,
15 Rule,
16 McpRef,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum CellStatus {
23 Installed,
25 Outdated,
27 Drifted,
29 NotInstalled,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CoverageCell {
36 pub status: CellStatus,
38 pub installed_version: Option<String>,
40 pub canonical_version: String,
42 pub installed_hash: Option<String>,
44 pub canonical_hash: Option<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct CoverageRow {
51 pub asset_id: String,
53 pub asset_type: AssetType,
55 pub asset_name: String,
57 pub provider: Provider,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct CoverageMatrix {
69 pub rows: Vec<CoverageRow>,
71 pub columns: Vec<String>,
73 #[serde(with = "cell_map_serde")]
75 pub cells: HashMap<(String, String), CoverageCell>,
76 pub workspace_scores: HashMap<String, f64>,
78}
79
80mod cell_map_serde {
86 use std::collections::HashMap;
87
88 use serde::de::{Deserializer, MapAccess, Visitor};
89 use serde::ser::{SerializeMap, Serializer};
90
91 use super::CoverageCell;
92
93 const SEP: char = '\x1f';
94
95 pub fn serialize<S>(
96 map: &HashMap<(String, String), CoverageCell>,
97 serializer: S,
98 ) -> Result<S::Ok, S::Error>
99 where
100 S: Serializer,
101 {
102 let mut s = serializer.serialize_map(Some(map.len()))?;
103 for ((asset_id, workspace), cell) in map {
104 let key = format!("{}{}{}", asset_id, SEP, workspace);
105 s.serialize_entry(&key, cell)?;
106 }
107 s.end()
108 }
109
110 pub fn deserialize<'de, D>(
111 deserializer: D,
112 ) -> Result<HashMap<(String, String), CoverageCell>, D::Error>
113 where
114 D: Deserializer<'de>,
115 {
116 struct MapVisitor;
117
118 impl<'de> Visitor<'de> for MapVisitor {
119 type Value = HashMap<(String, String), CoverageCell>;
120
121 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
122 write!(f, "a map with \"<asset_id>\\x1f<workspace>\" string keys")
123 }
124
125 fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
126 let mut map = HashMap::new();
127 while let Some((key, value)) = access.next_entry::<String, CoverageCell>()? {
128 let sep_pos = key.find('\x1f').ok_or_else(|| {
129 serde::de::Error::custom(format!(
130 "cell map key missing separator: {:?}",
131 key
132 ))
133 })?;
134 let asset_id = key[..sep_pos].to_string();
135 let workspace = key[sep_pos + '\x1f'.len_utf8()..].to_string();
136 map.insert((asset_id, workspace), value);
137 }
138 Ok(map)
139 }
140 }
141
142 deserializer.deserialize_map(MapVisitor)
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 fn make_cell(status: CellStatus) -> CoverageCell {
151 CoverageCell {
152 status,
153 installed_version: Some("1.0.0".to_string()),
154 canonical_version: "1.0.0".to_string(),
155 installed_hash: Some("abc123".to_string()),
156 canonical_hash: Some("abc123".to_string()),
157 }
158 }
159
160 #[test]
161 fn cell_status_round_trip() {
162 for status in [
163 CellStatus::Installed,
164 CellStatus::Outdated,
165 CellStatus::Drifted,
166 CellStatus::NotInstalled,
167 ] {
168 let json = serde_json::to_string(&status).unwrap();
169 let decoded: CellStatus = serde_json::from_str(&json).unwrap();
170 assert_eq!(decoded, status);
171 }
172 }
173
174 #[test]
175 fn cell_status_serialized_strings() {
176 assert_eq!(
177 serde_json::to_string(&CellStatus::Installed).unwrap(),
178 "\"installed\""
179 );
180 assert_eq!(
181 serde_json::to_string(&CellStatus::NotInstalled).unwrap(),
182 "\"not_installed\""
183 );
184 assert_eq!(
185 serde_json::to_string(&CellStatus::Drifted).unwrap(),
186 "\"drifted\""
187 );
188 assert_eq!(
189 serde_json::to_string(&CellStatus::Outdated).unwrap(),
190 "\"outdated\""
191 );
192 }
193
194 #[test]
195 fn coverage_cell_round_trip() {
196 let cell = make_cell(CellStatus::Outdated);
197 let json = serde_json::to_string(&cell).unwrap();
198 let decoded: CoverageCell = serde_json::from_str(&json).unwrap();
199 assert_eq!(decoded.status, CellStatus::Outdated);
200 assert_eq!(decoded.canonical_version, "1.0.0");
201 }
202
203 #[test]
204 fn coverage_row_round_trip() {
205 let row = CoverageRow {
206 asset_id: "aws-iam-scanner".to_string(),
207 asset_type: AssetType::Agent,
208 asset_name: "AWS IAM Scanner".to_string(),
209 provider: Provider::Aws,
210 };
211 let json = serde_json::to_string(&row).unwrap();
212 let decoded: CoverageRow = serde_json::from_str(&json).unwrap();
213 assert_eq!(decoded.asset_id, row.asset_id);
214 assert_eq!(decoded.asset_type, AssetType::Agent);
215 }
216
217 #[test]
218 fn asset_type_serialized_strings() {
219 assert_eq!(
220 serde_json::to_string(&AssetType::Agent).unwrap(),
221 "\"agent\""
222 );
223 assert_eq!(
224 serde_json::to_string(&AssetType::McpRef).unwrap(),
225 "\"mcp_ref\""
226 );
227 }
228
229 #[test]
230 fn coverage_matrix_round_trip() {
231 let mut cells = HashMap::new();
232 cells.insert(
233 ("aws-iam-scanner".to_string(), "prod".to_string()),
234 make_cell(CellStatus::Installed),
235 );
236 let mut scores = HashMap::new();
237 scores.insert("prod".to_string(), 100.0_f64);
238
239 let matrix = CoverageMatrix {
240 rows: vec![CoverageRow {
241 asset_id: "aws-iam-scanner".to_string(),
242 asset_type: AssetType::Agent,
243 asset_name: "AWS IAM Scanner".to_string(),
244 provider: Provider::Aws,
245 }],
246 columns: vec!["prod".to_string()],
247 cells,
248 workspace_scores: scores,
249 };
250
251 let json = serde_json::to_string(&matrix).unwrap();
252 let decoded: CoverageMatrix = serde_json::from_str(&json).unwrap();
253 assert_eq!(decoded.columns, vec!["prod"]);
254 assert!(decoded
255 .cells
256 .contains_key(&("aws-iam-scanner".to_string(), "prod".to_string())));
257 }
258}