1use crate::{
7 error::{Error, Result},
8 parser::enhanced_statistics_parser::parse_statistics_with_fallback,
9 parser::statistics::{SSTableStatistics, StatisticsAnalyzer, StatisticsSummary},
10 platform::Platform,
11};
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use tokio::fs::File;
15use tokio::io::AsyncReadExt;
16
17fn crc32_checksum(data: &[u8]) -> u32 {
19 let mut crc = 0xffff_ffff_u32;
20
21 for &byte in data {
22 crc ^= byte as u32;
23 for _ in 0..8 {
24 if crc & 1 != 0 {
25 crc = (crc >> 1) ^ 0xedb8_8320_u32;
26 } else {
27 crc >>= 1;
28 }
29 }
30 }
31
32 !crc
33}
34
35pub struct StatisticsReader {
37 file_path: PathBuf,
39 statistics: SSTableStatistics,
41 #[allow(dead_code)]
43 platform: Arc<Platform>,
44}
45
46impl StatisticsReader {
47 pub async fn open(path: &Path, platform: Arc<Platform>) -> Result<Self> {
49 let gates = match crate::storage::sstable::version_gate::VersionGates::from_path(path) {
68 Ok(gates) => Some(gates),
69 Err(e @ Error::UnsupportedVersion { .. }) => return Err(e),
70 Err(e) => {
71 tracing::debug!(
72 "StatisticsReader::open: could not derive VersionGates from {:?} ({}); \
73 defaulting to nb-compatible behaviour",
74 path,
75 e
76 );
77 None
78 }
79 };
80
81 if !platform.fs().exists(path).await? {
82 return Err(Error::not_found(format!(
83 "Statistics.db file not found: {}",
84 path.display()
85 )));
86 }
87
88 let mut file = File::open(path).await?;
92 let mut buffer = Vec::new();
93 file.read_to_end(&mut buffer).await?;
94
95 let statistics = match parse_statistics_with_fallback(&buffer, gates.as_ref()) {
96 Ok((_, stats)) => stats,
97 Err(e) => {
98 return Err(Error::corruption(format!(
99 "Failed to parse Statistics.db with enhanced parser: {:?}",
100 e
101 )));
102 }
103 };
104
105 if statistics.header.checksum != 0 {
119 }
121
122 Ok(Self {
123 file_path: path.to_path_buf(),
124 statistics,
125 platform,
126 })
127 }
128
129 pub fn statistics(&self) -> &SSTableStatistics {
131 &self.statistics
132 }
133
134 pub fn analyze(&self) -> StatisticsSummary {
136 StatisticsAnalyzer::analyze(&self.statistics)
137 }
138
139 pub fn file_path(&self) -> &Path {
141 &self.file_path
142 }
143
144 pub async fn validate_checksum(&self) -> Result<bool> {
146 let mut file = File::open(&self.file_path).await?;
148 let mut buffer = Vec::new();
149 file.read_to_end(&mut buffer).await?;
150
151 if buffer.len() < 4 {
152 return Err(Error::corruption(
153 "Statistics file too small for checksum validation".to_string(),
154 ));
155 }
156
157 let stored_checksum = self.statistics.header.checksum;
159
160 let data_section = if buffer.len() >= 32 {
162 &buffer[28..buffer.len() - 4] } else {
164 return Err(Error::corruption(
165 "Invalid Statistics file format for checksum validation".to_string(),
166 ));
167 };
168
169 let calculated_checksum = crc32_checksum(data_section);
170
171 Ok(calculated_checksum == stored_checksum)
172 }
173
174 pub fn matches_table(&self, table_id: &[u8; 16]) -> bool {
176 if let Some(ref stats_table_id) = self.statistics.header.table_id {
177 stats_table_id == table_id
178 } else {
179 false }
181 }
182
183 pub fn row_count(&self) -> u64 {
185 self.statistics.row_stats.total_rows
186 }
187
188 pub fn live_row_count(&self) -> u64 {
190 self.statistics.row_stats.live_rows
191 }
192
193 pub fn timestamp_range(&self) -> (i64, i64) {
202 (
203 self.statistics.timestamp_stats.min_timestamp,
204 self.statistics
205 .timestamp_stats
206 .max_timestamp
207 .unwrap_or(i64::MIN),
208 )
209 }
210
211 pub fn max_timestamp(&self) -> Option<i64> {
217 self.statistics
222 .timestamp_stats
223 .max_timestamp
224 .filter(|&ts| ts != crate::parser::repair_metadata::NO_MAX_TIMESTAMP_SENTINEL)
225 }
226
227 pub fn compression_info(&self) -> Option<(&str, f64)> {
232 self.statistics
233 .compression_stats
234 .as_ref()
235 .map(|c| (c.algorithm.as_str(), c.ratio))
236 }
237
238 pub fn partition_info(&self) -> Option<(u64, f64, u64)> {
242 self.statistics.partition_stats.as_ref().map(|p| {
243 (
244 p.min_partition_size,
245 p.avg_partition_size,
246 p.max_partition_size,
247 )
248 })
249 }
250
251 pub fn column_stats(
253 &self,
254 column_name: &str,
255 ) -> Option<&crate::parser::statistics::ColumnStatistics> {
256 self.statistics
257 .column_stats
258 .iter()
259 .find(|col| col.name == column_name)
260 }
261
262 pub fn column_names(&self) -> Vec<&str> {
264 self.statistics
265 .column_stats
266 .iter()
267 .map(|col| col.name.as_str())
268 .collect()
269 }
270
271 pub fn has_ttl_data(&self) -> bool {
278 matches!(self.statistics.timestamp_stats.rows_with_ttl, Some(n) if n > 0)
279 }
280
281 pub fn disk_usage(&self) -> Option<(u64, u64, f64)> {
285 self.statistics
286 .table_stats
287 .as_ref()
288 .map(|t| (t.compressed_size, t.uncompressed_size, t.compression_ratio))
289 }
290
291 pub fn generate_report(&self, include_column_details: bool) -> String {
293 let mut report = String::new();
294 let summary = self.analyze();
295
296 report.push_str("# SSTable Statistics Report\n\n");
297
298 report.push_str("## Overview\n");
300 report.push_str(&match summary.total_rows {
305 0 => "- **Total Rows**: unavailable\n".to_string(),
306 n => format!("- **Total Rows**: {}\n", n),
307 });
308 report.push_str(&match summary.live_data_percentage {
312 Some(pct) => format!("- **Live Data**: {:.2}%\n", pct),
313 None => "- **Live Data**: unavailable\n".to_string(),
314 });
315 report.push_str(&match summary.compression_efficiency {
318 Some(eff) => format!("- **Compression Efficiency**: {:.2}%\n", eff),
319 None => "- **Compression Efficiency**: unavailable\n".to_string(),
320 });
321 report.push_str(&format!(
322 "- **Time Range**: {:.1} days\n",
323 summary.timestamp_range_days
324 ));
325 report.push_str(&match summary.largest_partition_mb {
327 Some(mb) => format!("- **Largest Partition**: {:.2} MB\n", mb),
328 None => "- **Largest Partition**: unavailable\n".to_string(),
329 });
330 report.push_str(&format!(
331 "- **Health Score**: {:.1}/100\n\n",
332 summary.health_score
333 ));
334
335 report.push_str("## Row Statistics\n");
337 report.push_str(&match self.statistics.row_stats.total_rows {
339 0 => "- Total rows: unavailable\n".to_string(),
340 n => format!("- Total rows: {}\n", n),
341 });
342 report.push_str(&match self.statistics.row_stats.live_rows {
348 0 => "- Live rows: unavailable\n".to_string(),
349 live => format!("- Live rows: {}\n", live),
350 });
351 report.push_str(&format!(
352 "- Tombstones: {}\n",
353 self.statistics.row_stats.tombstone_count
354 ));
355 report.push_str(&format!(
356 "- Partitions: {}\n",
357 self.statistics.row_stats.partition_count
358 ));
359 report.push_str(
367 &if crate::parser::statistics::avg_rows_available(&self.statistics) {
368 format!(
369 "- Average rows per partition: {:.1}\n\n",
370 self.statistics.row_stats.avg_rows_per_partition
371 )
372 } else {
373 "- Average rows per partition: unavailable\n\n".to_string()
374 },
375 );
376
377 if self.statistics.timestamp_stats.min_timestamp != 0 {
379 let min_time = chrono::DateTime::from_timestamp_micros(
380 self.statistics.timestamp_stats.min_timestamp,
381 );
382 let max_ts = self.max_timestamp();
385 let max_time = max_ts.and_then(chrono::DateTime::from_timestamp_micros);
386
387 report.push_str("## Timestamp Range\n");
388 if let (Some(min), Some(max)) = (min_time, max_time) {
391 report.push_str(&format!(
392 "- From: {}\n",
393 min.format("%Y-%m-%d %H:%M:%S UTC")
394 ));
395 report.push_str(&format!("- To: {}\n", max.format("%Y-%m-%d %H:%M:%S UTC")));
396 } else {
397 report.push_str(&format!(
398 "- Min timestamp: {}\n",
399 self.statistics.timestamp_stats.min_timestamp
400 ));
401 match max_ts {
402 Some(max) => {
403 report.push_str(&format!("- Max timestamp: {}\n", max));
404 }
405 None => {
406 report.push_str("- Max timestamp: unavailable\n");
407 }
408 }
409 }
410
411 if let Some(rows_with_ttl) = self.statistics.timestamp_stats.rows_with_ttl {
415 if rows_with_ttl > 0 {
416 report.push_str(&format!("- Rows with TTL: {}\n", rows_with_ttl));
417 }
418 }
419 report.push('\n');
420 }
421
422 report.push_str("## Compression\n");
426 match self.statistics.compression_stats.as_ref() {
427 Some(c) => {
428 report.push_str(&format!("- Algorithm: {}\n", c.algorithm));
429 report.push_str(&format!(
430 "- Original size: {:.2} MB\n",
431 c.original_size as f64 / 1_048_576.0
432 ));
433 report.push_str(&format!(
434 "- Compressed size: {:.2} MB\n",
435 c.compressed_size as f64 / 1_048_576.0
436 ));
437 report.push_str(&format!("- Ratio: {:.2}%\n", c.ratio * 100.0));
438 report.push_str(&format!(
439 "- Speed: {:.1} MB/s (compress), {:.1} MB/s (decompress)\n\n",
440 c.compression_speed, c.decompression_speed
441 ));
442 }
443 None => report.push_str("- unavailable\n\n"),
444 }
445
446 report.push_str("## Partition Distribution\n");
448 match self.statistics.partition_stats.as_ref() {
449 Some(p) => {
450 report.push_str(&format!(
451 "- Average size: {:.2} KB\n",
452 p.avg_partition_size / 1024.0
453 ));
454 report.push_str(&format!(
455 "- Range: {:.2} KB - {:.2} MB\n",
456 p.min_partition_size as f64 / 1024.0,
457 p.max_partition_size as f64 / 1_048_576.0
458 ));
459 report.push_str(&format!(
460 "- Large partitions (>1MB): {:.1}%\n\n",
461 p.large_partition_percentage
462 ));
463 }
464 None => report.push_str("- unavailable\n\n"),
465 }
466
467 if include_column_details && !self.statistics.column_stats.is_empty() {
469 report.push_str("## Column Statistics\n");
470 for column in &self.statistics.column_stats {
471 report.push_str(&format!("### {}\n", column.name));
472 report.push_str(&format!("- Type: {}\n", column.column_type));
473 report.push_str(&format!("- Values: {}\n", column.value_count));
474 report.push_str(&format!("- Nulls: {}\n", column.null_count));
475 report.push_str(&format!("- Average size: {:.1} bytes\n", column.avg_size));
476 report.push_str(&format!("- Cardinality: {}\n", column.cardinality));
477 if column.has_index {
478 report.push_str("- **Indexed**: Yes\n");
479 }
480 report.push('\n');
481 }
482 }
483
484 if !summary.query_performance_hints.is_empty() {
486 report.push_str("## Query Performance Hints\n");
487 for hint in &summary.query_performance_hints {
488 report.push_str(&format!("- {}\n", hint));
489 }
490 report.push('\n');
491 }
492
493 if !summary.storage_recommendations.is_empty() {
495 report.push_str("## Storage Recommendations\n");
496 for rec in &summary.storage_recommendations {
497 report.push_str(&format!("- {}\n", rec));
498 }
499 report.push('\n');
500 }
501
502 report
503 }
504
505 pub fn compact_summary(&self) -> String {
507 let summary = self.analyze();
508 let live = match summary.live_data_percentage {
511 Some(pct) => format!("{:.1}% live", pct),
512 None => "?% live".to_string(),
513 };
514 let rows = match summary.total_rows {
518 0 => "?".to_string(),
519 n => n.to_string(),
520 };
521 let compression = match summary.compression_efficiency {
524 Some(eff) => format!("{:.1}%", eff),
525 None => "?".to_string(),
526 };
527 let size = match self.statistics.table_stats.as_ref() {
528 Some(t) => format!("{:.2} MB", t.disk_size as f64 / 1_048_576.0),
529 None => "? MB".to_string(),
530 };
531 format!(
532 "Rows: {} ({}) | Compression: {} | Health: {:.0}/100 | Size: {}",
533 rows, live, compression, summary.health_score, size
534 )
535 }
536
537 #[cfg(test)]
543 async fn from_statistics_for_test(statistics: SSTableStatistics) -> Self {
544 let config = crate::Config::default();
545 let platform =
546 std::sync::Arc::new(crate::Platform::new(&config).await.expect("test platform"));
547 Self {
548 file_path: PathBuf::from("in-memory-test-Statistics.db"),
549 statistics,
550 platform,
551 }
552 }
553}
554
555pub async fn find_statistics_file(data_db_path: &Path) -> Option<PathBuf> {
557 if let Some(parent) = data_db_path.parent() {
558 if let Some(stem) = data_db_path.file_stem() {
559 if let Some(stem_str) = stem.to_str() {
560 let stats_name = stem_str.replace("-Data", "-Statistics") + ".db";
562 let stats_path = parent.join(stats_name);
563
564 if tokio::fs::metadata(&stats_path).await.is_ok() {
565 return Some(stats_path);
566 }
567 }
568 }
569 }
570 None
571}
572
573pub async fn check_statistics_availability(sstable_dir: &Path) -> Result<Vec<PathBuf>> {
575 let mut stats_files = Vec::new();
576
577 let mut dir_entries = tokio::fs::read_dir(sstable_dir).await?;
578 while let Some(entry) = dir_entries.next_entry().await? {
579 let path = entry.path();
580 if let Some(file_name) = path.file_name() {
581 if let Some(name_str) = file_name.to_str() {
582 if name_str.contains("-Statistics.db") {
583 stats_files.push(path);
584 }
585 }
586 }
587 }
588
589 Ok(stats_files)
590}
591
592#[cfg(test)]
593mod tests {
594
595 #[tokio::test]
596 async fn test_statistics_reader_creation() {
597 assert!(true);
600 }
601
602 #[tokio::test]
603 async fn test_find_statistics_file() {
604 use std::path::PathBuf;
605
606 let data_path = PathBuf::from("/path/to/sstables/users-123abc-Data.db");
607 if let Some(_parent) = data_path.parent() {
611 if let Some(stem) = data_path.file_stem() {
612 if let Some(stem_str) = stem.to_str() {
613 let stats_name = stem_str.replace("-Data", "-Statistics") + ".db";
614 assert_eq!(stats_name, "users-123abc-Statistics.db");
615 }
616 }
617 }
618 }
619
620 #[tokio::test]
627 async fn test_open_below_floor_statistics_rejected() {
628 use crate::error::Error;
629 use std::sync::Arc;
630
631 let dir = tempfile::tempdir().expect("create tempdir");
632 let path = dir.path().join("ma-1-big-Statistics.db");
634 std::fs::write(&path, b"not really a valid statistics file").expect("write fixture");
638
639 let config = crate::Config::default();
640 let platform = Arc::new(
641 crate::Platform::new(&config)
642 .await
643 .expect("create platform"),
644 );
645
646 let result = super::StatisticsReader::open(&path, platform).await;
647
648 match result {
649 Err(Error::UnsupportedVersion { version, floor }) => {
650 assert_eq!(version, "ma", "error must name the offending version");
651 assert_eq!(floor, "na", "error must name the na BIG floor");
652 }
653 Err(other) => panic!(
654 "expected UnsupportedVersion for below-floor Statistics.db, got {:?}",
655 other
656 ),
657 Ok(_) => panic!("below-floor Statistics.db must NOT open on nb-compatible defaults"),
658 }
659 }
660
661 #[tokio::test]
667 async fn test_open_above_floor_unknown_statistics_rejected() {
668 use crate::error::Error;
669 use std::sync::Arc;
670
671 let dir = tempfile::tempdir().expect("create tempdir");
672 let path = dir.path().join("nc-1-big-Statistics.db");
674 std::fs::write(&path, b"not really a valid statistics file").expect("write fixture");
675
676 let config = crate::Config::default();
677 let platform = Arc::new(
678 crate::Platform::new(&config)
679 .await
680 .expect("create platform"),
681 );
682
683 match super::StatisticsReader::open(&path, platform).await {
684 Err(Error::UnsupportedVersion { version, .. }) => {
685 assert_eq!(version, "nc", "error must name the offending version");
686 }
687 Err(other) => panic!(
688 "expected UnsupportedVersion for above-allowlist Statistics.db, got {:?}",
689 other
690 ),
691 Ok(_) => {
692 panic!("above-allowlist Statistics.db must NOT open on nb-compatible defaults")
693 }
694 }
695 }
696
697 #[tokio::test]
703 async fn test_open_below_floor_statistics_rejected_before_body_read() {
704 use crate::error::Error;
705 use std::sync::Arc;
706
707 let dir = tempfile::tempdir().expect("create tempdir");
708 let path = dir.path().join("ma-1-big-Statistics.db");
709 std::fs::write(&path, b"").expect("write empty fixture");
712
713 let config = crate::Config::default();
714 let platform = Arc::new(
715 crate::Platform::new(&config)
716 .await
717 .expect("create platform"),
718 );
719
720 match super::StatisticsReader::open(&path, platform).await {
721 Err(Error::UnsupportedVersion { version, floor }) => {
722 assert_eq!(version, "ma");
723 assert_eq!(floor, "na");
724 }
725 Err(other) => panic!(
726 "expected UnsupportedVersion before body read for empty below-floor \
727 Statistics.db, got {:?}",
728 other
729 ),
730 Ok(_) => {
731 panic!("below-floor Statistics.db with empty body must be rejected before parse")
732 }
733 }
734 }
735
736 #[tokio::test]
743 async fn test_open_below_floor_statistics_rejected_before_fs_access() {
744 use crate::error::Error;
745 use std::sync::Arc;
746
747 let dir = tempfile::tempdir().expect("create tempdir");
748 let path = dir.path().join("ma-1-big-Statistics.db");
751 assert!(
752 !path.exists(),
753 "fixture path must not exist on disk for this test"
754 );
755
756 let config = crate::Config::default();
757 let platform = Arc::new(
758 crate::Platform::new(&config)
759 .await
760 .expect("create platform"),
761 );
762
763 match super::StatisticsReader::open(&path, platform).await {
764 Err(Error::UnsupportedVersion { version, floor }) => {
765 assert_eq!(version, "ma", "error must name the offending version");
766 assert_eq!(floor, "na", "error must name the na BIG floor");
767 }
768 Err(other) => panic!(
769 "expected UnsupportedVersion before any FS access for a nonexistent \
770 below-floor Statistics.db, got {:?}",
771 other
772 ),
773 Ok(_) => panic!("below-floor Statistics.db must NOT open even when absent"),
774 }
775 }
776
777 #[tokio::test]
782 async fn test_open_real_nb_decodes_authoritative_max_timestamp() {
783 use std::path::PathBuf;
784 use std::sync::Arc;
785
786 let Ok(root) = std::env::var("CQLITE_DATASETS_ROOT") else {
787 eprintln!("CQLITE_DATASETS_ROOT not set, skipping");
788 return;
789 };
790 let path = PathBuf::from(root).join(
791 "sstables/test_timeseries/sensor_data-6c698230a25111f0a3fef1a551383fb9/\
792 nb-1-big-Statistics.db",
793 );
794 if !path.exists() {
795 eprintln!("dataset Statistics.db absent, skipping: {path:?}");
796 return;
797 }
798
799 let config = crate::Config::default();
800 let platform = Arc::new(
801 crate::Platform::new(&config)
802 .await
803 .expect("create platform"),
804 );
805 let reader = super::StatisticsReader::open(&path, platform)
806 .await
807 .expect("open real nb Statistics.db");
808
809 let (min, max) = reader.timestamp_range();
810 let decoded_max = reader.max_timestamp();
815 assert!(
816 decoded_max.is_some(),
817 "a real time-series nb SSTable must expose an authoritative maxTimestamp"
818 );
819 let decoded_max = decoded_max.unwrap_or(i64::MIN);
820 assert_ne!(
821 decoded_max,
822 i64::MIN,
823 "max_timestamp must not be the unavailable sentinel for real data"
824 );
825 assert!(
826 decoded_max >= min,
827 "authoritative max ({decoded_max}) must be >= min ({min})"
828 );
829 assert_eq!(
830 max, decoded_max,
831 "timestamp_range max must match the authoritative decode"
832 );
833 }
834
835 #[cfg(test)]
839 fn stats_with(
840 total_rows: u64,
841 live_rows: u64,
842 avg_rows_per_partition: f64,
843 ) -> crate::parser::statistics::SSTableStatistics {
844 use crate::parser::statistics::*;
845 SSTableStatistics {
846 header: StatisticsHeader {
847 version: 4,
848 statistics_kind: 0,
849 data_length: 0,
850 metadata1: 0,
851 metadata2: 0,
852 metadata3: 0,
853 checksum: 0,
854 table_id: None,
855 },
856 row_stats: RowStatistics {
857 total_rows,
858 live_rows,
859 tombstone_count: 0,
860 partition_count: 10,
861 avg_rows_per_partition,
862 row_size_histogram: vec![],
863 },
864 timestamp_stats: TimestampStatistics {
865 min_timestamp: 0,
866 max_timestamp: Some(0),
867 min_deletion_time: 0,
868 max_deletion_time: 0,
869 min_ttl: None,
870 max_ttl: None,
871 rows_with_ttl: Some(0),
872 },
873 column_stats: vec![],
874 table_stats: Some(TableStatistics {
875 disk_size: 1024,
876 uncompressed_size: 1024,
877 compressed_size: 1024,
878 compression_ratio: 1.0,
879 block_count: 1,
880 avg_block_size: 1024.0,
881 index_size: 0,
882 bloom_filter_size: 0,
883 level_count: 0,
884 }),
885 partition_stats: Some(PartitionStatistics {
886 avg_partition_size: 0.0,
887 min_partition_size: 0,
888 max_partition_size: 0,
889 size_histogram: vec![],
890 large_partition_percentage: 0.0,
891 }),
892 compression_stats: Some(CompressionStatistics {
893 algorithm: "none".to_string(),
894 original_size: 1024,
895 compressed_size: 1024,
896 ratio: 1.0,
897 compression_speed: 0.0,
898 decompression_speed: 0.0,
899 compressed_blocks: 0,
900 }),
901 metadata: std::collections::HashMap::new(),
902 serialization_header_columns: vec![],
903 serialization_header_partition_keys: vec![],
904 serialization_header_clustering_keys: vec![],
905 tombstone_drop_times: vec![],
906 }
907 }
908
909 #[tokio::test]
913 async fn test_report_renders_unavailable_when_counts_sentinel() {
914 let reader = super::StatisticsReader::from_statistics_for_test(stats_with(0, 0, 0.0)).await;
915
916 let report = reader.generate_report(false);
917 assert!(
918 report.contains("**Total Rows**: unavailable"),
919 "overview Total Rows must render unavailable for the total_rows==0 sentinel:\n{report}"
920 );
921 assert!(
922 report.contains("Total rows: unavailable"),
923 "row-stats Total rows must render unavailable for the sentinel:\n{report}"
924 );
925 assert!(
926 report.contains("Average rows per partition: unavailable"),
927 "avg rows/partition must render unavailable for the sentinel:\n{report}"
928 );
929 assert!(
930 !report.contains("Total rows: 0"),
931 "must NOT render the misleading concrete 0:\n{report}"
932 );
933
934 let compact = reader.compact_summary();
935 assert!(
936 compact.contains("Rows: ?"),
937 "compact summary must render '?' for the total_rows==0 sentinel: {compact}"
938 );
939 }
940
941 #[tokio::test]
944 async fn test_report_renders_concrete_when_counts_real() {
945 let reader =
946 super::StatisticsReader::from_statistics_for_test(stats_with(1000, 900, 20.0)).await;
947
948 let report = reader.generate_report(false);
949 assert!(
950 report.contains("**Total Rows**: 1000"),
951 "overview must render the real total_rows:\n{report}"
952 );
953 assert!(
954 report.contains("Average rows per partition: 20.0"),
955 "avg rows/partition must render the real value:\n{report}"
956 );
957 assert!(
958 !report.contains("Total Rows**: unavailable"),
959 "must NOT render unavailable when the count is real:\n{report}"
960 );
961
962 let compact = reader.compact_summary();
963 assert!(
964 compact.contains("Rows: 1000"),
965 "compact summary must render the real total_rows: {compact}"
966 );
967 }
968
969 #[cfg(test)]
971 fn stats_with_timestamps(
972 min_timestamp: i64,
973 max_timestamp: i64,
974 ) -> crate::parser::statistics::SSTableStatistics {
975 let mut s = stats_with(1, 1, 1.0);
976 s.timestamp_stats.min_timestamp = min_timestamp;
977 s.timestamp_stats.max_timestamp = Some(max_timestamp);
978 s
979 }
980
981 #[tokio::test]
984 async fn test_max_timestamp_authoritative_is_some_and_true_max() {
985 let min = 1_000_000i64;
986 let max = 5_000_000i64;
987 let reader =
988 super::StatisticsReader::from_statistics_for_test(stats_with_timestamps(min, max))
989 .await;
990 assert_eq!(reader.max_timestamp(), Some(max));
991 assert_ne!(
992 reader.max_timestamp(),
993 Some(min),
994 "max_timestamp must be the true max, never the min placeholder"
995 );
996 assert_eq!(reader.timestamp_range(), (min, max));
998 }
999
1000 #[tokio::test]
1004 async fn test_max_timestamp_unavailable_is_none_and_report_unavailable() {
1005 let min = 1_000_000i64;
1006 let mut stats = stats_with_timestamps(min, 0);
1009 stats.timestamp_stats.max_timestamp = None;
1010 let reader = super::StatisticsReader::from_statistics_for_test(stats).await;
1011 assert_eq!(
1012 reader.max_timestamp(),
1013 None,
1014 "an unavailable max_timestamp must surface as None (fail-closed)"
1015 );
1016
1017 let report = reader.generate_report(false);
1018 assert!(
1019 report.contains("Max timestamp: unavailable"),
1020 "report must render an unavailable max honestly, not a bogus epoch:\n{report}"
1021 );
1022 assert!(
1023 !report.contains("-9223372036854775808"),
1024 "report must NOT leak a raw i64::MIN value:\n{report}"
1025 );
1026 }
1027
1028 #[tokio::test]
1034 async fn test_report_renders_avg_unavailable_when_partition_count_zero() {
1035 let mut stats = stats_with(1000, 900, 0.0);
1036 stats.row_stats.partition_count = 0; let reader = super::StatisticsReader::from_statistics_for_test(stats).await;
1038
1039 let report = reader.generate_report(false);
1040 assert!(
1041 report.contains("Average rows per partition: unavailable"),
1042 "avg rows/partition must render unavailable when partition_count == 0:\n{report}"
1043 );
1044 assert!(
1045 !report.contains("Average rows per partition: 0.0"),
1046 "must NOT render the misleading concrete 0.0 avg:\n{report}"
1047 );
1048 assert!(
1050 report.contains("Total rows: 1000"),
1051 "row-stats Total rows must still render the real count:\n{report}"
1052 );
1053 }
1054}