use std::path::PathBuf;
use snafu::Snafu;
use crate::metadata::table_metadata::TimeBucket;
pub const COVERAGE_ROOT_DIR: &str = "_coverage";
pub const SEGMENT_COVERAGE_DIR: &str = "_coverage/segments";
pub const TABLE_SNAPSHOT_DIR: &str = "_coverage/table";
pub const COVERAGE_EXT: &str = "roar";
#[derive(Debug, Snafu)]
pub enum CoverageLayoutError {
#[snafu(display("Invalid coverage id: {coverage_id}"))]
InvalidCoverageId {
coverage_id: String,
},
}
pub fn validate_coverage_id(coverage_id: &str) -> Result<(), CoverageLayoutError> {
if coverage_id.is_empty() || coverage_id.len() > 128 {
return Err(CoverageLayoutError::InvalidCoverageId {
coverage_id: coverage_id.to_string(),
});
}
if !coverage_id.chars().any(|c| c.is_ascii_alphanumeric()) {
return Err(CoverageLayoutError::InvalidCoverageId {
coverage_id: coverage_id.to_string(),
});
}
if coverage_id.starts_with('.') {
return Err(CoverageLayoutError::InvalidCoverageId {
coverage_id: coverage_id.to_string(),
});
}
if coverage_id.contains('/') || coverage_id.contains('\\') || coverage_id.contains("..") {
return Err(CoverageLayoutError::InvalidCoverageId {
coverage_id: coverage_id.to_string(),
});
}
let ok = coverage_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
if !ok {
return Err(CoverageLayoutError::InvalidCoverageId {
coverage_id: coverage_id.to_string(),
});
}
Ok(())
}
pub fn segment_coverage_path(coverage_id: &str) -> Result<PathBuf, CoverageLayoutError> {
validate_coverage_id(coverage_id)?;
let mut p = PathBuf::from(COVERAGE_ROOT_DIR);
p.push("segments");
p.push(format!("{coverage_id}.{COVERAGE_EXT}"));
Ok(p)
}
pub fn table_snapshot_path(
version: u64,
snapshot_id: &str,
) -> Result<PathBuf, CoverageLayoutError> {
validate_coverage_id(snapshot_id)?;
let mut p = PathBuf::from(COVERAGE_ROOT_DIR);
p.push("table");
p.push(format!("{version}-{snapshot_id}.{COVERAGE_EXT}"));
Ok(p)
}
fn coverage_id_v1(
domain_prefix: &[u8],
output_prefix: &str,
bucket_spec: &TimeBucket,
time_column: &str,
coverage_bytes: &[u8],
) -> String {
let mut h = blake3::Hasher::new();
h.update(domain_prefix);
h.update(b"\0");
match bucket_spec {
TimeBucket::Seconds(n) => {
h.update(b"S");
h.update(&n.to_le_bytes());
}
TimeBucket::Minutes(n) => {
h.update(b"M");
h.update(&n.to_le_bytes());
}
TimeBucket::Hours(n) => {
h.update(b"H");
h.update(&n.to_le_bytes());
}
TimeBucket::Days(n) => {
h.update(b"D");
h.update(&n.to_le_bytes());
}
}
h.update(b"\0");
h.update(time_column.as_bytes());
h.update(b"\0");
h.update(coverage_bytes);
let hex = h.finalize().to_hex();
format!("{output_prefix}-{}", &hex[..32])
}
pub fn segment_coverage_id_v1(
bucket_spec: &TimeBucket,
time_column: &str,
coverage_bytes: &[u8],
) -> String {
coverage_id_v1(
b"segcov-v1",
"segcov",
bucket_spec,
time_column,
coverage_bytes,
)
}
pub fn table_coverage_id_v1(
bucket_spec: &TimeBucket,
time_column: &str,
coverage_bytes: &[u8],
) -> String {
coverage_id_v1(
b"tblcov-v1",
"tblcov",
bucket_spec,
time_column,
coverage_bytes,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_coverage_id_accepts_valid_ids() {
let long = "a".repeat(128);
let valid_ids = ["abc", "A_B-1.2", long.as_str()];
for id in valid_ids {
validate_coverage_id(id).expect("valid id should pass");
}
}
#[test]
fn validate_coverage_id_rejects_empty_or_too_long() {
let too_long = "x".repeat(129);
assert!(validate_coverage_id("").is_err());
assert!(validate_coverage_id(&too_long).is_err());
}
#[test]
fn validate_coverage_id_rejects_path_components() {
for id in ["a/b", "a\\b", "a..b", "..", "../etc"] {
assert!(validate_coverage_id(id).is_err(), "id `{id}` should fail");
}
}
#[test]
fn validate_coverage_id_rejects_disallowed_chars() {
for id in ["space id", "id*", "id@", "id$", "id:"] {
assert!(validate_coverage_id(id).is_err(), "id `{id}` should fail");
}
}
#[test]
fn segment_coverage_path_formats_and_validates() {
let id = "seg-001";
let path = segment_coverage_path(id).expect("valid id");
assert_eq!(path, PathBuf::from("_coverage/segments/seg-001.roar"));
assert!(segment_coverage_path("bad/id").is_err());
}
#[test]
fn table_snapshot_path_formats() {
let path = table_snapshot_path(42, "snap-001").expect("valid snapshot id");
assert_eq!(path, PathBuf::from("_coverage/table/42-snap-001.roar"));
}
#[test]
fn segment_coverage_id_is_deterministic_and_valid() {
let bucket = TimeBucket::Minutes(1);
let time_col = "ts";
let bytes = b"bitmap-bytes";
let id1 = segment_coverage_id_v1(&bucket, time_col, bytes);
let id2 = segment_coverage_id_v1(&bucket, time_col, bytes);
assert_eq!(id1, id2, "same inputs must produce stable id");
assert!(id1.starts_with("segcov-"));
assert_eq!(id1.len(), "segcov-".len() + 32, "prefix + 32 hex chars");
validate_coverage_id(&id1).expect("derived id should be valid");
}
#[test]
fn segment_coverage_id_changes_with_inputs() {
let bytes = b"bytes";
let base = segment_coverage_id_v1(&TimeBucket::Seconds(5), "ts", bytes);
let different_bucket = segment_coverage_id_v1(&TimeBucket::Hours(5), "ts", bytes);
let different_column = segment_coverage_id_v1(&TimeBucket::Seconds(5), "event_time", bytes);
let different_bytes = segment_coverage_id_v1(&TimeBucket::Seconds(5), "ts", b"other");
assert_ne!(base, different_bucket, "bucket spec should affect id");
assert_ne!(base, different_column, "time column should affect id");
assert_ne!(base, different_bytes, "coverage bytes should affect id");
}
#[test]
fn table_coverage_id_is_deterministic_and_valid() {
let bucket = TimeBucket::Hours(1);
let time_col = "ts";
let bytes = b"table-bitmap";
let id1 = table_coverage_id_v1(&bucket, time_col, bytes);
let id2 = table_coverage_id_v1(&bucket, time_col, bytes);
assert_eq!(id1, id2, "same inputs must produce stable id");
assert!(id1.starts_with("tblcov-"));
assert_eq!(id1.len(), "tblcov-".len() + 32, "prefix + 32 hex chars");
validate_coverage_id(&id1).expect("derived id should be valid");
}
#[test]
fn table_coverage_id_changes_with_inputs() {
let bytes = b"bytes";
let base = table_coverage_id_v1(&TimeBucket::Minutes(15), "ts", bytes);
let different_bucket = table_coverage_id_v1(&TimeBucket::Days(1), "ts", bytes);
let different_column = table_coverage_id_v1(&TimeBucket::Minutes(15), "event_time", bytes);
let different_bytes = table_coverage_id_v1(&TimeBucket::Minutes(15), "ts", b"other");
assert_ne!(base, different_bucket, "bucket spec should affect id");
assert_ne!(base, different_column, "time column should affect id");
assert_ne!(base, different_bytes, "coverage bytes should affect id");
}
}