Skip to main content

vfa_tui/models/
coverage.rs

1//! Coverage matrix data models — cells, rows, and the full matrix.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7use super::provider::Provider;
8
9/// Asset type discriminator shared by the coverage and scanner modules.
10#[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/// Classification of a single (asset, workspace) coverage cell.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum CellStatus {
23    /// Content hash matches the canonical hash — asset is current.
24    Installed,
25    /// Installed version is behind the canonical version.
26    Outdated,
27    /// Content hash does not match regardless of version — local modification detected.
28    Drifted,
29    /// Asset is not present in this workspace.
30    NotInstalled,
31}
32
33/// Data for a single cell in the coverage matrix (one asset × one workspace).
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CoverageCell {
36    /// Classification of the installed asset relative to the canonical asset.
37    pub status: CellStatus,
38    /// Version string extracted from the installed copy, if detectable.
39    pub installed_version: Option<String>,
40    /// Version string from the canonical catalog entry.
41    pub canonical_version: String,
42    /// SHA-256 hash of the installed file, if present.
43    pub installed_hash: Option<String>,
44    /// SHA-256 hash recorded in the canonical asset-integrity manifest.
45    pub canonical_hash: Option<String>,
46}
47
48/// One row of the coverage matrix — represents a single catalog asset across all workspaces.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct CoverageRow {
51    /// Unique catalog asset identifier.
52    pub asset_id: String,
53    /// Discriminator for the asset kind.
54    pub asset_type: AssetType,
55    /// Human-readable asset name.
56    pub asset_name: String,
57    /// Provider that owns this asset.
58    pub provider: Provider,
59}
60
61/// Full coverage matrix: rows are catalog assets, columns are workspace names.
62///
63/// `cells` is keyed by `(asset_id, workspace_name)`.  In JSON the tuple key is
64/// serialized as `"<asset_id>\x1f<workspace_name>"` (ASCII Unit Separator 0x1F)
65/// so it remains a valid JSON string key.
66/// `workspace_scores` contains computed coverage percentages per workspace.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct CoverageMatrix {
69    /// One row per catalog asset.
70    pub rows: Vec<CoverageRow>,
71    /// Ordered workspace names (column headers).
72    pub columns: Vec<String>,
73    /// Cell values keyed by `(asset_id, workspace_name)`.
74    #[serde(with = "cell_map_serde")]
75    pub cells: HashMap<(String, String), CoverageCell>,
76    /// Computed coverage percentage per workspace (0.0–100.0).
77    pub workspace_scores: HashMap<String, f64>,
78}
79
80/// Custom serde module for `HashMap<(String, String), CoverageCell>`.
81///
82/// JSON object keys must be strings.  We encode the `(asset_id, workspace_name)`
83/// tuple as `"<asset_id>\x1f<workspace_name>"` (ASCII Unit Separator 0x1F),
84/// which guarantees reversibility as long as neither component contains 0x1F.
85mod 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}