use std::path::Path;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub(crate) struct SegmentId(u64);
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub(crate) enum SegmentNameError {
MissingStem,
NotUtf8,
EmptyStem,
NotAnInteger {
stem: String,
},
}
impl std::fmt::Display for SegmentNameError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingStem => write!(f, "segment filename has no stem"),
Self::NotUtf8 => write!(f, "segment filename stem is not valid UTF-8"),
Self::EmptyStem => write!(f, "segment filename stem is empty"),
Self::NotAnInteger { stem } => {
write!(f, "segment filename stem {stem:?} is not a base-10 u64")
}
}
}
}
impl std::error::Error for SegmentNameError {}
impl SegmentId {
#[must_use]
pub(crate) const fn as_u64(self) -> u64 {
self.0
}
pub(crate) fn from_filename(path: &Path) -> Result<Self, SegmentNameError> {
let stem_os = path.file_stem().ok_or(SegmentNameError::MissingStem)?;
let stem = stem_os.to_str().ok_or(SegmentNameError::NotUtf8)?;
Self::from_stem(stem)
}
pub(crate) fn from_stem(stem: &str) -> Result<Self, SegmentNameError> {
if stem.is_empty() {
return Err(SegmentNameError::EmptyStem);
}
stem.parse::<u64>()
.map(Self)
.map_err(|_| SegmentNameError::NotAnInteger {
stem: stem.to_string(),
})
}
}
impl std::fmt::Display for SegmentId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn from_filename_accepts_six_digit_stem() {
let id = SegmentId::from_filename(&PathBuf::from("data/000123.fbat"))
.expect("six-digit stem parses");
assert_eq!(
id.as_u64(),
123,
"PROPERTY: zero-padded six-digit stem maps to the underlying u64"
);
}
#[test]
fn from_filename_accepts_unpadded_stem() {
let id = SegmentId::from_filename(&PathBuf::from("123.fbat"))
.expect("unpadded stem still parses");
assert_eq!(id.as_u64(), 123);
}
#[test]
fn from_filename_rejects_non_integer_stem_with_diagnostic() {
let err = SegmentId::from_filename(&PathBuf::from("data/abc.fbat"))
.expect_err("non-integer stem must surface");
assert_eq!(
err,
SegmentNameError::NotAnInteger {
stem: "abc".to_string()
},
"PROPERTY: malformed stems carry the offending text in the error so the warn log shows it"
);
}
#[test]
fn from_stem_rejects_empty_string_with_empty_stem_variant() {
let err = SegmentId::from_stem("").expect_err("empty stem must surface");
assert_eq!(err, SegmentNameError::EmptyStem);
}
#[test]
fn from_filename_treats_dot_fbat_as_non_integer_stem() {
let err = SegmentId::from_filename(&PathBuf::from(".fbat"))
.expect_err("dotfile stem is not a u64");
assert_eq!(
err,
SegmentNameError::NotAnInteger {
stem: ".fbat".to_string()
}
);
}
#[test]
fn from_filename_rejects_missing_stem() {
let err = SegmentId::from_filename(&PathBuf::from("")).expect_err("empty path has no stem");
assert_eq!(err, SegmentNameError::MissingStem);
}
#[test]
fn from_filename_accepts_boundary_ids() {
for raw in [0u64, 1, u64::MAX] {
let name = format!("{raw}.fbat");
let parsed = SegmentId::from_filename(&PathBuf::from(&name));
assert!(
parsed.is_ok(),
"PROPERTY: boundary id {raw} must round-trip; got {parsed:?}"
);
assert_eq!(
parsed.expect("checked above").as_u64(),
raw,
"PROPERTY: u64 boundary values round-trip through SegmentId::from_filename"
);
}
}
#[test]
fn from_stem_round_trips_with_display() {
let id = SegmentId::from_stem("42").expect("base-10 stem parses");
let rendered = format!("{id}");
assert_eq!(rendered, "42");
let parsed = SegmentId::from_stem(&rendered).expect("Display output parses back");
assert_eq!(parsed, id);
}
#[test]
fn segment_name_error_implements_std_error() {
fn assert_error<E: std::error::Error>(_: &E) {}
let err = SegmentNameError::NotAnInteger {
stem: "x".to_string(),
};
assert_error(&err);
let rendered = format!("{err}");
assert!(
rendered.contains("\"x\""),
"PROPERTY: Display of NotAnInteger shows the offending stem; got {rendered:?}"
);
}
}