Skip to main content

cqlite_core/discovery/
coverage.rs

1//! Schema coverage calculation and badge generation
2//!
3//! This module provides functionality for computing schema coverage by comparing
4//! discovered SSTables with loaded schemas, and generating coverage badges.
5
6use std::sync::Arc;
7use tokio::sync::RwLock;
8
9use crate::error::Result;
10use crate::schema::registry::SchemaRegistry;
11
12/// Coverage information for discovered tables
13#[derive(Debug, Clone)]
14pub struct CoverageInfo {
15    /// Total number of tables discovered in data directory
16    pub total_tables: usize,
17    /// Number of tables with schema definitions loaded
18    pub tables_with_schema: usize,
19    /// Tables discovered but missing schema definitions
20    pub tables_missing_schema: Vec<String>,
21    /// Schema definitions loaded but no corresponding data found
22    pub schemas_without_data: Vec<String>,
23}
24
25impl CoverageInfo {
26    /// Calculate coverage percentage
27    pub fn coverage_percentage(&self) -> f64 {
28        if self.total_tables == 0 {
29            return 0.0;
30        }
31        (self.tables_with_schema as f64 / self.total_tables as f64) * 100.0
32    }
33
34    /// Check if there are critical issues
35    pub fn has_critical_issues(&self) -> bool {
36        // Critical issues: more than 50% of discovered tables missing schema
37        if self.total_tables > 0 {
38            let missing_pct =
39                (self.tables_missing_schema.len() as f64 / self.total_tables as f64) * 100.0;
40            missing_pct > 50.0
41        } else {
42            false
43        }
44    }
45}
46
47/// Coverage badge indicating overall schema coverage status
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum CoverageBadge {
50    /// Excellent coverage (≥95%)
51    Green,
52    /// Acceptable coverage (50-95%)
53    Yellow,
54    /// Poor coverage (<50% or critical issues)
55    Red,
56    /// No schema registry provided or no data to analyze
57    Unknown,
58}
59
60impl CoverageBadge {
61    /// Get a human-readable description of the badge
62    pub fn description(&self) -> &'static str {
63        match self {
64            CoverageBadge::Green => "Excellent coverage (≥95%)",
65            CoverageBadge::Yellow => "Acceptable coverage (50-95%)",
66            CoverageBadge::Red => "Poor coverage (<50% or critical issues)",
67            CoverageBadge::Unknown => "Unknown coverage (no schema data)",
68        }
69    }
70
71    /// Get the badge color name
72    pub fn color(&self) -> &'static str {
73        match self {
74            CoverageBadge::Green => "green",
75            CoverageBadge::Yellow => "yellow",
76            CoverageBadge::Red => "red",
77            CoverageBadge::Unknown => "gray",
78        }
79    }
80}
81
82/// Calculator for schema coverage analysis
83pub struct CoverageCalculator {
84    schema_registry: Arc<RwLock<SchemaRegistry>>,
85}
86
87impl CoverageCalculator {
88    /// Create a new coverage calculator with the given schema registry
89    pub fn new(schema_registry: Arc<RwLock<SchemaRegistry>>) -> Self {
90        Self { schema_registry }
91    }
92
93    /// Calculate coverage by comparing discovered tables with loaded schemas
94    ///
95    /// # Arguments
96    ///
97    /// * `discovered_tables` - Fully qualified table names discovered in data directory
98    ///
99    /// # Returns
100    ///
101    /// Coverage information including tables with/without schema and schemas without data
102    pub async fn calculate(&self, discovered_tables: &[String]) -> Result<CoverageInfo> {
103        let registry = self.schema_registry.read().await;
104
105        // Get all registered schemas
106        let registered_schemas = registry.list_schemas(None).await?;
107
108        // Build a set of registered table names for fast lookup
109        let registered_names: Vec<String> = registered_schemas
110            .iter()
111            .map(|s| format!("{}.{}", s.keyspace, s.table))
112            .collect();
113
114        // Compare discovered tables with registered schemas
115        let mut tables_with_schema = Vec::new();
116        let mut tables_missing_schema = Vec::new();
117
118        for table in discovered_tables {
119            // Normalize table name (keyspace.table)
120            let normalized = normalize_table_name(table);
121
122            if registered_names.iter().any(|s| {
123                let schema_normalized = normalize_table_name(s);
124                schema_normalized == normalized
125            }) {
126                tables_with_schema.push(table.clone());
127            } else {
128                tables_missing_schema.push(table.clone());
129            }
130        }
131
132        // Find schemas without corresponding data
133        let mut schemas_without_data = Vec::new();
134        for schema_name in &registered_names {
135            let schema_normalized = normalize_table_name(schema_name);
136            if !discovered_tables.iter().any(|t| {
137                let table_normalized = normalize_table_name(t);
138                table_normalized == schema_normalized
139            }) {
140                schemas_without_data.push(schema_name.clone());
141            }
142        }
143
144        Ok(CoverageInfo {
145            total_tables: discovered_tables.len(),
146            tables_with_schema: tables_with_schema.len(),
147            tables_missing_schema,
148            schemas_without_data,
149        })
150    }
151
152    /// Compute coverage badge based on coverage information
153    ///
154    /// Badge thresholds:
155    /// - Green: ≥95% coverage
156    /// - Yellow: 50-95% coverage
157    /// - Red: <50% coverage or critical issues
158    pub fn compute_badge(&self, coverage: &CoverageInfo) -> CoverageBadge {
159        if coverage.total_tables == 0 {
160            return CoverageBadge::Unknown;
161        }
162
163        // Check for critical issues first
164        if coverage.has_critical_issues() {
165            return CoverageBadge::Red;
166        }
167
168        let coverage_pct = coverage.coverage_percentage();
169
170        if coverage_pct >= 95.0 {
171            CoverageBadge::Green
172        } else if coverage_pct >= 50.0 {
173            CoverageBadge::Yellow
174        } else {
175            CoverageBadge::Red
176        }
177    }
178}
179
180/// Normalize table name to lowercase keyspace.table format
181fn normalize_table_name(name: &str) -> String {
182    name.to_lowercase()
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::schema::registry::{SchemaRegistry, SchemaRegistryConfig};
189    use crate::schema::TableSchema;
190    use crate::{Config, Platform};
191    use std::sync::Arc;
192    use tokio::sync::RwLock;
193
194    async fn create_test_registry() -> Arc<RwLock<SchemaRegistry>> {
195        let config = Config::default();
196        let platform = Arc::new(Platform::new(&config).await.unwrap());
197        let registry_config = SchemaRegistryConfig::default();
198
199        Arc::new(RwLock::new(
200            SchemaRegistry::new(registry_config, platform, config)
201                .await
202                .unwrap(),
203        ))
204    }
205
206    async fn register_test_schema(
207        registry: Arc<RwLock<SchemaRegistry>>,
208        keyspace: &str,
209        table: &str,
210    ) {
211        use crate::schema::registry::SchemaSource;
212
213        let schema = TableSchema::new_for_testing(keyspace, table);
214
215        let reg = registry.write().await;
216        reg.register_schema(schema, SchemaSource::Manual)
217            .await
218            .unwrap();
219    }
220
221    #[tokio::test]
222    async fn test_coverage_info_percentage() {
223        let info = CoverageInfo {
224            total_tables: 10,
225            tables_with_schema: 8,
226            tables_missing_schema: vec!["ks.t1".to_string(), "ks.t2".to_string()],
227            schemas_without_data: vec![],
228        };
229
230        assert_eq!(info.coverage_percentage(), 80.0);
231    }
232
233    #[tokio::test]
234    async fn test_coverage_info_zero_tables() {
235        let info = CoverageInfo {
236            total_tables: 0,
237            tables_with_schema: 0,
238            tables_missing_schema: vec![],
239            schemas_without_data: vec![],
240        };
241
242        assert_eq!(info.coverage_percentage(), 0.0);
243    }
244
245    #[tokio::test]
246    async fn test_coverage_info_critical_issues() {
247        // Critical: >50% missing schema
248        let info = CoverageInfo {
249            total_tables: 10,
250            tables_with_schema: 4,
251            tables_missing_schema: vec![
252                "ks.t1".to_string(),
253                "ks.t2".to_string(),
254                "ks.t3".to_string(),
255                "ks.t4".to_string(),
256                "ks.t5".to_string(),
257                "ks.t6".to_string(),
258            ],
259            schemas_without_data: vec![],
260        };
261
262        assert!(info.has_critical_issues());
263    }
264
265    #[tokio::test]
266    async fn test_coverage_badge_green() {
267        let registry = create_test_registry().await;
268        let calculator = CoverageCalculator::new(registry);
269
270        let coverage = CoverageInfo {
271            total_tables: 100,
272            tables_with_schema: 96,
273            tables_missing_schema: vec![],
274            schemas_without_data: vec![],
275        };
276
277        let badge = calculator.compute_badge(&coverage);
278        assert_eq!(badge, CoverageBadge::Green);
279        assert_eq!(badge.description(), "Excellent coverage (≥95%)");
280        assert_eq!(badge.color(), "green");
281    }
282
283    #[tokio::test]
284    async fn test_coverage_badge_yellow() {
285        let registry = create_test_registry().await;
286        let calculator = CoverageCalculator::new(registry);
287
288        let coverage = CoverageInfo {
289            total_tables: 100,
290            tables_with_schema: 75,
291            tables_missing_schema: vec![],
292            schemas_without_data: vec![],
293        };
294
295        let badge = calculator.compute_badge(&coverage);
296        assert_eq!(badge, CoverageBadge::Yellow);
297        assert_eq!(badge.description(), "Acceptable coverage (50-95%)");
298        assert_eq!(badge.color(), "yellow");
299    }
300
301    #[tokio::test]
302    async fn test_coverage_badge_red() {
303        let registry = create_test_registry().await;
304        let calculator = CoverageCalculator::new(registry);
305
306        let coverage = CoverageInfo {
307            total_tables: 100,
308            tables_with_schema: 30,
309            tables_missing_schema: vec![],
310            schemas_without_data: vec![],
311        };
312
313        let badge = calculator.compute_badge(&coverage);
314        assert_eq!(badge, CoverageBadge::Red);
315        assert_eq!(
316            badge.description(),
317            "Poor coverage (<50% or critical issues)"
318        );
319        assert_eq!(badge.color(), "red");
320    }
321
322    #[tokio::test]
323    async fn test_coverage_badge_unknown() {
324        let registry = create_test_registry().await;
325        let calculator = CoverageCalculator::new(registry);
326
327        let coverage = CoverageInfo {
328            total_tables: 0,
329            tables_with_schema: 0,
330            tables_missing_schema: vec![],
331            schemas_without_data: vec![],
332        };
333
334        let badge = calculator.compute_badge(&coverage);
335        assert_eq!(badge, CoverageBadge::Unknown);
336        assert_eq!(badge.description(), "Unknown coverage (no schema data)");
337        assert_eq!(badge.color(), "gray");
338    }
339
340    #[tokio::test]
341    async fn test_coverage_calculator() {
342        let registry = create_test_registry().await;
343
344        // Register some test schemas
345        register_test_schema(registry.clone(), "ks1", "table1").await;
346        register_test_schema(registry.clone(), "ks1", "table2").await;
347        register_test_schema(registry.clone(), "ks2", "table3").await;
348
349        let calculator = CoverageCalculator::new(registry);
350
351        // Discovered tables (some match schemas, some don't)
352        let discovered = vec![
353            "ks1.table1".to_string(),
354            "ks1.table2".to_string(),
355            "ks2.table4".to_string(), // No schema for this
356        ];
357
358        let coverage = calculator.calculate(&discovered).await.unwrap();
359
360        assert_eq!(coverage.total_tables, 3);
361        assert_eq!(coverage.tables_with_schema, 2);
362        assert_eq!(coverage.tables_missing_schema.len(), 1);
363        assert!(coverage
364            .tables_missing_schema
365            .contains(&"ks2.table4".to_string()));
366        assert_eq!(coverage.schemas_without_data.len(), 1);
367        assert!(coverage
368            .schemas_without_data
369            .contains(&"ks2.table3".to_string()));
370    }
371
372    #[tokio::test]
373    async fn test_coverage_calculator_empty_discovered() {
374        let registry = create_test_registry().await;
375        register_test_schema(registry.clone(), "ks1", "table1").await;
376
377        let calculator = CoverageCalculator::new(registry);
378        let discovered: Vec<String> = vec![];
379
380        let coverage = calculator.calculate(&discovered).await.unwrap();
381
382        assert_eq!(coverage.total_tables, 0);
383        assert_eq!(coverage.tables_with_schema, 0);
384        assert_eq!(coverage.tables_missing_schema.len(), 0);
385        assert_eq!(coverage.schemas_without_data.len(), 1);
386    }
387
388    #[tokio::test]
389    async fn test_coverage_calculator_case_insensitive() {
390        let registry = create_test_registry().await;
391        register_test_schema(registry.clone(), "MyKS", "MyTable").await;
392
393        let calculator = CoverageCalculator::new(registry);
394        let discovered = vec!["myks.mytable".to_string()]; // lowercase
395
396        let coverage = calculator.calculate(&discovered).await.unwrap();
397
398        assert_eq!(coverage.total_tables, 1);
399        assert_eq!(coverage.tables_with_schema, 1);
400        assert_eq!(coverage.tables_missing_schema.len(), 0);
401    }
402
403    #[tokio::test]
404    async fn test_normalize_table_name() {
405        assert_eq!(normalize_table_name("MyKS.MyTable"), "myks.mytable");
406        assert_eq!(normalize_table_name("ks.table"), "ks.table");
407        assert_eq!(normalize_table_name("KS.TABLE"), "ks.table");
408    }
409}