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 log::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) {
200 (
201 self.statistics.timestamp_stats.min_timestamp,
202 self.statistics.timestamp_stats.max_timestamp,
203 )
204 }
205
206 pub fn max_timestamp(&self) -> Option<i64> {
212 match self.statistics.timestamp_stats.max_timestamp {
213 i64::MIN => None,
214 v => Some(v),
215 }
216 }
217
218 pub fn compression_info(&self) -> (&str, f64) {
220 (
221 &self.statistics.compression_stats.algorithm,
222 self.statistics.compression_stats.ratio,
223 )
224 }
225
226 pub fn partition_info(&self) -> (u64, f64, u64) {
228 (
229 self.statistics.partition_stats.min_partition_size,
230 self.statistics.partition_stats.avg_partition_size,
231 self.statistics.partition_stats.max_partition_size,
232 )
233 }
234
235 pub fn column_stats(
237 &self,
238 column_name: &str,
239 ) -> Option<&crate::parser::statistics::ColumnStatistics> {
240 self.statistics
241 .column_stats
242 .iter()
243 .find(|col| col.name == column_name)
244 }
245
246 pub fn column_names(&self) -> Vec<&str> {
248 self.statistics
249 .column_stats
250 .iter()
251 .map(|col| col.name.as_str())
252 .collect()
253 }
254
255 pub fn has_ttl_data(&self) -> bool {
257 self.statistics.timestamp_stats.rows_with_ttl > 0
258 }
259
260 pub fn disk_usage(&self) -> (u64, u64, f64) {
262 (
263 self.statistics.table_stats.compressed_size,
264 self.statistics.table_stats.uncompressed_size,
265 self.statistics.table_stats.compression_ratio,
266 )
267 }
268
269 pub fn generate_report(&self, include_column_details: bool) -> String {
271 let mut report = String::new();
272 let summary = self.analyze();
273
274 report.push_str("# SSTable Statistics Report\n\n");
275
276 report.push_str("## Overview\n");
278 report.push_str(&match summary.total_rows {
283 0 => "- **Total Rows**: unavailable\n".to_string(),
284 n => format!("- **Total Rows**: {}\n", n),
285 });
286 report.push_str(&match summary.live_data_percentage {
290 Some(pct) => format!("- **Live Data**: {:.2}%\n", pct),
291 None => "- **Live Data**: unavailable\n".to_string(),
292 });
293 report.push_str(&format!(
294 "- **Compression Efficiency**: {:.2}%\n",
295 summary.compression_efficiency
296 ));
297 report.push_str(&format!(
298 "- **Time Range**: {:.1} days\n",
299 summary.timestamp_range_days
300 ));
301 report.push_str(&format!(
302 "- **Largest Partition**: {:.2} MB\n",
303 summary.largest_partition_mb
304 ));
305 report.push_str(&format!(
306 "- **Health Score**: {:.1}/100\n\n",
307 summary.health_score
308 ));
309
310 report.push_str("## Row Statistics\n");
312 report.push_str(&match self.statistics.row_stats.total_rows {
314 0 => "- Total rows: unavailable\n".to_string(),
315 n => format!("- Total rows: {}\n", n),
316 });
317 report.push_str(&match self.statistics.row_stats.live_rows {
323 0 => "- Live rows: unavailable\n".to_string(),
324 live => format!("- Live rows: {}\n", live),
325 });
326 report.push_str(&format!(
327 "- Tombstones: {}\n",
328 self.statistics.row_stats.tombstone_count
329 ));
330 report.push_str(&format!(
331 "- Partitions: {}\n",
332 self.statistics.row_stats.partition_count
333 ));
334 report.push_str(
342 &if crate::parser::statistics::avg_rows_available(&self.statistics) {
343 format!(
344 "- Average rows per partition: {:.1}\n\n",
345 self.statistics.row_stats.avg_rows_per_partition
346 )
347 } else {
348 "- Average rows per partition: unavailable\n\n".to_string()
349 },
350 );
351
352 if self.statistics.timestamp_stats.min_timestamp != 0 {
354 let min_time = chrono::DateTime::from_timestamp_micros(
355 self.statistics.timestamp_stats.min_timestamp,
356 );
357 let max_time = chrono::DateTime::from_timestamp_micros(
358 self.statistics.timestamp_stats.max_timestamp,
359 );
360
361 report.push_str("## Timestamp Range\n");
362 let max_available = self.max_timestamp().is_some();
366 if let (Some(min), Some(max)) = (min_time, max_time) {
367 report.push_str(&format!(
368 "- From: {}\n",
369 min.format("%Y-%m-%d %H:%M:%S UTC")
370 ));
371 report.push_str(&format!("- To: {}\n", max.format("%Y-%m-%d %H:%M:%S UTC")));
372 } else {
373 report.push_str(&format!(
374 "- Min timestamp: {}\n",
375 self.statistics.timestamp_stats.min_timestamp
376 ));
377 if max_available {
378 report.push_str(&format!(
379 "- Max timestamp: {}\n",
380 self.statistics.timestamp_stats.max_timestamp
381 ));
382 } else {
383 report.push_str("- Max timestamp: unavailable\n");
384 }
385 }
386
387 if self.has_ttl_data() {
388 report.push_str(&format!(
389 "- Rows with TTL: {}\n",
390 self.statistics.timestamp_stats.rows_with_ttl
391 ));
392 }
393 report.push('\n');
394 }
395
396 report.push_str("## Compression\n");
398 report.push_str(&format!(
399 "- Algorithm: {}\n",
400 self.statistics.compression_stats.algorithm
401 ));
402 report.push_str(&format!(
403 "- Original size: {:.2} MB\n",
404 self.statistics.compression_stats.original_size as f64 / 1_048_576.0
405 ));
406 report.push_str(&format!(
407 "- Compressed size: {:.2} MB\n",
408 self.statistics.compression_stats.compressed_size as f64 / 1_048_576.0
409 ));
410 report.push_str(&format!(
411 "- Ratio: {:.2}%\n",
412 self.statistics.compression_stats.ratio * 100.0
413 ));
414 report.push_str(&format!(
415 "- Speed: {:.1} MB/s (compress), {:.1} MB/s (decompress)\n\n",
416 self.statistics.compression_stats.compression_speed,
417 self.statistics.compression_stats.decompression_speed
418 ));
419
420 report.push_str("## Partition Distribution\n");
422 report.push_str(&format!(
423 "- Average size: {:.2} KB\n",
424 self.statistics.partition_stats.avg_partition_size / 1024.0
425 ));
426 report.push_str(&format!(
427 "- Range: {:.2} KB - {:.2} MB\n",
428 self.statistics.partition_stats.min_partition_size as f64 / 1024.0,
429 self.statistics.partition_stats.max_partition_size as f64 / 1_048_576.0
430 ));
431 report.push_str(&format!(
432 "- Large partitions (>1MB): {:.1}%\n\n",
433 self.statistics.partition_stats.large_partition_percentage
434 ));
435
436 if include_column_details && !self.statistics.column_stats.is_empty() {
438 report.push_str("## Column Statistics\n");
439 for column in &self.statistics.column_stats {
440 report.push_str(&format!("### {}\n", column.name));
441 report.push_str(&format!("- Type: {}\n", column.column_type));
442 report.push_str(&format!("- Values: {}\n", column.value_count));
443 report.push_str(&format!("- Nulls: {}\n", column.null_count));
444 report.push_str(&format!("- Average size: {:.1} bytes\n", column.avg_size));
445 report.push_str(&format!("- Cardinality: {}\n", column.cardinality));
446 if column.has_index {
447 report.push_str("- **Indexed**: Yes\n");
448 }
449 report.push('\n');
450 }
451 }
452
453 if !summary.query_performance_hints.is_empty() {
455 report.push_str("## Query Performance Hints\n");
456 for hint in &summary.query_performance_hints {
457 report.push_str(&format!("- {}\n", hint));
458 }
459 report.push('\n');
460 }
461
462 if !summary.storage_recommendations.is_empty() {
464 report.push_str("## Storage Recommendations\n");
465 for rec in &summary.storage_recommendations {
466 report.push_str(&format!("- {}\n", rec));
467 }
468 report.push('\n');
469 }
470
471 report
472 }
473
474 pub fn compact_summary(&self) -> String {
476 let summary = self.analyze();
477 let live = match summary.live_data_percentage {
480 Some(pct) => format!("{:.1}% live", pct),
481 None => "?% live".to_string(),
482 };
483 let rows = match summary.total_rows {
487 0 => "?".to_string(),
488 n => n.to_string(),
489 };
490 format!(
491 "Rows: {} ({}) | Compression: {:.1}% | Health: {:.0}/100 | Size: {:.2} MB",
492 rows,
493 live,
494 summary.compression_efficiency,
495 summary.health_score,
496 self.statistics.table_stats.disk_size as f64 / 1_048_576.0
497 )
498 }
499
500 #[cfg(test)]
506 async fn from_statistics_for_test(statistics: SSTableStatistics) -> Self {
507 let config = crate::Config::default();
508 let platform =
509 std::sync::Arc::new(crate::Platform::new(&config).await.expect("test platform"));
510 Self {
511 file_path: PathBuf::from("in-memory-test-Statistics.db"),
512 statistics,
513 platform,
514 }
515 }
516}
517
518pub async fn find_statistics_file(data_db_path: &Path) -> Option<PathBuf> {
520 if let Some(parent) = data_db_path.parent() {
521 if let Some(stem) = data_db_path.file_stem() {
522 if let Some(stem_str) = stem.to_str() {
523 let stats_name = stem_str.replace("-Data", "-Statistics") + ".db";
525 let stats_path = parent.join(stats_name);
526
527 if tokio::fs::metadata(&stats_path).await.is_ok() {
528 return Some(stats_path);
529 }
530 }
531 }
532 }
533 None
534}
535
536pub async fn check_statistics_availability(sstable_dir: &Path) -> Result<Vec<PathBuf>> {
538 let mut stats_files = Vec::new();
539
540 let mut dir_entries = tokio::fs::read_dir(sstable_dir).await?;
541 while let Some(entry) = dir_entries.next_entry().await? {
542 let path = entry.path();
543 if let Some(file_name) = path.file_name() {
544 if let Some(name_str) = file_name.to_str() {
545 if name_str.contains("-Statistics.db") {
546 stats_files.push(path);
547 }
548 }
549 }
550 }
551
552 Ok(stats_files)
553}
554
555#[cfg(test)]
556mod tests {
557
558 #[tokio::test]
559 async fn test_statistics_reader_creation() {
560 assert!(true);
563 }
564
565 #[tokio::test]
566 async fn test_find_statistics_file() {
567 use std::path::PathBuf;
568
569 let data_path = PathBuf::from("/path/to/sstables/users-123abc-Data.db");
570 if let Some(_parent) = data_path.parent() {
574 if let Some(stem) = data_path.file_stem() {
575 if let Some(stem_str) = stem.to_str() {
576 let stats_name = stem_str.replace("-Data", "-Statistics") + ".db";
577 assert_eq!(stats_name, "users-123abc-Statistics.db");
578 }
579 }
580 }
581 }
582
583 #[tokio::test]
590 async fn test_open_below_floor_statistics_rejected() {
591 use crate::error::Error;
592 use std::sync::Arc;
593
594 let dir = tempfile::tempdir().expect("create tempdir");
595 let path = dir.path().join("ma-1-big-Statistics.db");
597 std::fs::write(&path, b"not really a valid statistics file").expect("write fixture");
601
602 let config = crate::Config::default();
603 let platform = Arc::new(
604 crate::Platform::new(&config)
605 .await
606 .expect("create platform"),
607 );
608
609 let result = super::StatisticsReader::open(&path, platform).await;
610
611 match result {
612 Err(Error::UnsupportedVersion { version, floor }) => {
613 assert_eq!(version, "ma", "error must name the offending version");
614 assert_eq!(floor, "na", "error must name the na BIG floor");
615 }
616 Err(other) => panic!(
617 "expected UnsupportedVersion for below-floor Statistics.db, got {:?}",
618 other
619 ),
620 Ok(_) => panic!("below-floor Statistics.db must NOT open on nb-compatible defaults"),
621 }
622 }
623
624 #[tokio::test]
630 async fn test_open_above_floor_unknown_statistics_rejected() {
631 use crate::error::Error;
632 use std::sync::Arc;
633
634 let dir = tempfile::tempdir().expect("create tempdir");
635 let path = dir.path().join("nc-1-big-Statistics.db");
637 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 match super::StatisticsReader::open(&path, platform).await {
647 Err(Error::UnsupportedVersion { version, .. }) => {
648 assert_eq!(version, "nc", "error must name the offending version");
649 }
650 Err(other) => panic!(
651 "expected UnsupportedVersion for above-allowlist Statistics.db, got {:?}",
652 other
653 ),
654 Ok(_) => {
655 panic!("above-allowlist Statistics.db must NOT open on nb-compatible defaults")
656 }
657 }
658 }
659
660 #[tokio::test]
666 async fn test_open_below_floor_statistics_rejected_before_body_read() {
667 use crate::error::Error;
668 use std::sync::Arc;
669
670 let dir = tempfile::tempdir().expect("create tempdir");
671 let path = dir.path().join("ma-1-big-Statistics.db");
672 std::fs::write(&path, b"").expect("write empty 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, floor }) => {
685 assert_eq!(version, "ma");
686 assert_eq!(floor, "na");
687 }
688 Err(other) => panic!(
689 "expected UnsupportedVersion before body read for empty below-floor \
690 Statistics.db, got {:?}",
691 other
692 ),
693 Ok(_) => {
694 panic!("below-floor Statistics.db with empty body must be rejected before parse")
695 }
696 }
697 }
698
699 #[tokio::test]
706 async fn test_open_below_floor_statistics_rejected_before_fs_access() {
707 use crate::error::Error;
708 use std::sync::Arc;
709
710 let dir = tempfile::tempdir().expect("create tempdir");
711 let path = dir.path().join("ma-1-big-Statistics.db");
714 assert!(
715 !path.exists(),
716 "fixture path must not exist on disk for this test"
717 );
718
719 let config = crate::Config::default();
720 let platform = Arc::new(
721 crate::Platform::new(&config)
722 .await
723 .expect("create platform"),
724 );
725
726 match super::StatisticsReader::open(&path, platform).await {
727 Err(Error::UnsupportedVersion { version, floor }) => {
728 assert_eq!(version, "ma", "error must name the offending version");
729 assert_eq!(floor, "na", "error must name the na BIG floor");
730 }
731 Err(other) => panic!(
732 "expected UnsupportedVersion before any FS access for a nonexistent \
733 below-floor Statistics.db, got {:?}",
734 other
735 ),
736 Ok(_) => panic!("below-floor Statistics.db must NOT open even when absent"),
737 }
738 }
739
740 #[tokio::test]
745 async fn test_open_real_nb_decodes_authoritative_max_timestamp() {
746 use std::path::PathBuf;
747 use std::sync::Arc;
748
749 let Ok(root) = std::env::var("CQLITE_DATASETS_ROOT") else {
750 eprintln!("CQLITE_DATASETS_ROOT not set, skipping");
751 return;
752 };
753 let path = PathBuf::from(root).join(
754 "sstables/test_timeseries/sensor_data-6c698230a25111f0a3fef1a551383fb9/\
755 nb-1-big-Statistics.db",
756 );
757 if !path.exists() {
758 eprintln!("dataset Statistics.db absent, skipping: {path:?}");
759 return;
760 }
761
762 let config = crate::Config::default();
763 let platform = Arc::new(
764 crate::Platform::new(&config)
765 .await
766 .expect("create platform"),
767 );
768 let reader = super::StatisticsReader::open(&path, platform)
769 .await
770 .expect("open real nb Statistics.db");
771
772 let (min, max) = reader.timestamp_range();
773 let decoded_max = reader.max_timestamp();
778 assert!(
779 decoded_max.is_some(),
780 "a real time-series nb SSTable must expose an authoritative maxTimestamp"
781 );
782 let decoded_max = decoded_max.unwrap_or(i64::MIN);
783 assert_ne!(
784 decoded_max,
785 i64::MIN,
786 "max_timestamp must not be the unavailable sentinel for real data"
787 );
788 assert!(
789 decoded_max >= min,
790 "authoritative max ({decoded_max}) must be >= min ({min})"
791 );
792 assert_eq!(
793 max, decoded_max,
794 "timestamp_range max must match the authoritative decode"
795 );
796 }
797
798 #[cfg(test)]
802 fn stats_with(
803 total_rows: u64,
804 live_rows: u64,
805 avg_rows_per_partition: f64,
806 ) -> crate::parser::statistics::SSTableStatistics {
807 use crate::parser::statistics::*;
808 SSTableStatistics {
809 header: StatisticsHeader {
810 version: 4,
811 statistics_kind: 0,
812 data_length: 0,
813 metadata1: 0,
814 metadata2: 0,
815 metadata3: 0,
816 checksum: 0,
817 table_id: None,
818 },
819 row_stats: RowStatistics {
820 total_rows,
821 live_rows,
822 tombstone_count: 0,
823 partition_count: 10,
824 avg_rows_per_partition,
825 row_size_histogram: vec![],
826 },
827 timestamp_stats: TimestampStatistics {
828 min_timestamp: 0,
829 max_timestamp: 0,
830 min_deletion_time: 0,
831 max_deletion_time: 0,
832 min_ttl: None,
833 max_ttl: None,
834 rows_with_ttl: 0,
835 },
836 column_stats: vec![],
837 table_stats: TableStatistics {
838 disk_size: 1024,
839 uncompressed_size: 1024,
840 compressed_size: 1024,
841 compression_ratio: 1.0,
842 block_count: 1,
843 avg_block_size: 1024.0,
844 index_size: 0,
845 bloom_filter_size: 0,
846 level_count: 0,
847 },
848 partition_stats: PartitionStatistics {
849 avg_partition_size: 0.0,
850 min_partition_size: 0,
851 max_partition_size: 0,
852 size_histogram: vec![],
853 large_partition_percentage: 0.0,
854 },
855 compression_stats: CompressionStatistics {
856 algorithm: "none".to_string(),
857 original_size: 1024,
858 compressed_size: 1024,
859 ratio: 1.0,
860 compression_speed: 0.0,
861 decompression_speed: 0.0,
862 compressed_blocks: 0,
863 },
864 metadata: std::collections::HashMap::new(),
865 serialization_header_columns: vec![],
866 serialization_header_partition_keys: vec![],
867 serialization_header_clustering_keys: vec![],
868 tombstone_drop_times: vec![],
869 }
870 }
871
872 #[tokio::test]
876 async fn test_report_renders_unavailable_when_counts_sentinel() {
877 let reader = super::StatisticsReader::from_statistics_for_test(stats_with(0, 0, 0.0)).await;
878
879 let report = reader.generate_report(false);
880 assert!(
881 report.contains("**Total Rows**: unavailable"),
882 "overview Total Rows must render unavailable for the total_rows==0 sentinel:\n{report}"
883 );
884 assert!(
885 report.contains("Total rows: unavailable"),
886 "row-stats Total rows must render unavailable for the sentinel:\n{report}"
887 );
888 assert!(
889 report.contains("Average rows per partition: unavailable"),
890 "avg rows/partition must render unavailable for the sentinel:\n{report}"
891 );
892 assert!(
893 !report.contains("Total rows: 0"),
894 "must NOT render the misleading concrete 0:\n{report}"
895 );
896
897 let compact = reader.compact_summary();
898 assert!(
899 compact.contains("Rows: ?"),
900 "compact summary must render '?' for the total_rows==0 sentinel: {compact}"
901 );
902 }
903
904 #[tokio::test]
907 async fn test_report_renders_concrete_when_counts_real() {
908 let reader =
909 super::StatisticsReader::from_statistics_for_test(stats_with(1000, 900, 20.0)).await;
910
911 let report = reader.generate_report(false);
912 assert!(
913 report.contains("**Total Rows**: 1000"),
914 "overview must render the real total_rows:\n{report}"
915 );
916 assert!(
917 report.contains("Average rows per partition: 20.0"),
918 "avg rows/partition must render the real value:\n{report}"
919 );
920 assert!(
921 !report.contains("Total Rows**: unavailable"),
922 "must NOT render unavailable when the count is real:\n{report}"
923 );
924
925 let compact = reader.compact_summary();
926 assert!(
927 compact.contains("Rows: 1000"),
928 "compact summary must render the real total_rows: {compact}"
929 );
930 }
931
932 #[cfg(test)]
934 fn stats_with_timestamps(
935 min_timestamp: i64,
936 max_timestamp: i64,
937 ) -> crate::parser::statistics::SSTableStatistics {
938 let mut s = stats_with(1, 1, 1.0);
939 s.timestamp_stats.min_timestamp = min_timestamp;
940 s.timestamp_stats.max_timestamp = max_timestamp;
941 s
942 }
943
944 #[tokio::test]
947 async fn test_max_timestamp_authoritative_is_some_and_true_max() {
948 let min = 1_000_000i64;
949 let max = 5_000_000i64;
950 let reader =
951 super::StatisticsReader::from_statistics_for_test(stats_with_timestamps(min, max))
952 .await;
953 assert_eq!(reader.max_timestamp(), Some(max));
954 assert_ne!(
955 reader.max_timestamp(),
956 Some(min),
957 "max_timestamp must be the true max, never the min placeholder"
958 );
959 assert_eq!(reader.timestamp_range(), (min, max));
961 }
962
963 #[tokio::test]
967 async fn test_max_timestamp_sentinel_is_none_and_report_unavailable() {
968 let min = 1_000_000i64;
969 let reader =
970 super::StatisticsReader::from_statistics_for_test(stats_with_timestamps(min, i64::MIN))
971 .await;
972 assert_eq!(
973 reader.max_timestamp(),
974 None,
975 "the i64::MIN unavailable sentinel must surface as None (fail-closed)"
976 );
977
978 let report = reader.generate_report(false);
979 assert!(
980 report.contains("Max timestamp: unavailable"),
981 "report must render an unavailable max honestly, not a bogus epoch:\n{report}"
982 );
983 assert!(
984 !report.contains("-9223372036854775808"),
985 "report must NOT leak the raw i64::MIN sentinel:\n{report}"
986 );
987 }
988
989 #[tokio::test]
995 async fn test_report_renders_avg_unavailable_when_partition_count_zero() {
996 let mut stats = stats_with(1000, 900, 0.0);
997 stats.row_stats.partition_count = 0; let reader = super::StatisticsReader::from_statistics_for_test(stats).await;
999
1000 let report = reader.generate_report(false);
1001 assert!(
1002 report.contains("Average rows per partition: unavailable"),
1003 "avg rows/partition must render unavailable when partition_count == 0:\n{report}"
1004 );
1005 assert!(
1006 !report.contains("Average rows per partition: 0.0"),
1007 "must NOT render the misleading concrete 0.0 avg:\n{report}"
1008 );
1009 assert!(
1011 report.contains("Total rows: 1000"),
1012 "row-stats Total rows must still render the real count:\n{report}"
1013 );
1014 }
1015}