use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use super::{BLOBS_DIR, EVENTS_DIR, HISTORY_DIR};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoreLocation {
Declared,
Conventional,
Absent,
}
impl StoreLocation {
pub fn exists(self) -> bool {
!matches!(self, StoreLocation::Absent)
}
}
pub fn shard_of(id: &str) -> Result<PathBuf> {
let bad = || Error::Structure(format!("`{id}` is not a history event id"));
let (year, rest) = id.split_once('-').ok_or_else(bad)?;
let (month, _) = rest.split_once('-').ok_or_else(bad)?;
if year.len() != 4
|| month.len() != 2
|| !year.bytes().all(|b| b.is_ascii_digit())
|| !month.bytes().all(|b| b.is_ascii_digit())
{
return Err(bad());
}
Ok(PathBuf::from(year).join(month))
}
pub fn event_path(store_index: &Path, id: &str, ext: &str) -> Result<PathBuf> {
Ok(store_dir(store_index)
.join(EVENTS_DIR)
.join(shard_of(id)?)
.join(format!("{id}.{ext}")))
}
pub fn blob_path(store_index: &Path, hash: &str) -> Result<PathBuf> {
let hex = hash.strip_prefix("sha256:").ok_or_else(|| {
Error::Structure(format!("`{hash}` is not a sha256 digest prov can park"))
})?;
if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(Error::Structure(format!("`{hash}` is not a sha256 digest")));
}
let (prefix, rest) = hex.split_at(2);
Ok(store_dir(store_index)
.join(BLOBS_DIR)
.join(prefix)
.join(rest))
}
pub fn store_dir(store_index: &Path) -> PathBuf {
store_index
.parent()
.unwrap_or(Path::new(HISTORY_DIR))
.to_path_buf()
}
pub(super) fn shard_parts(shard: &Path) -> Result<(String, String)> {
let parts: Vec<String> = shard
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
match parts.as_slice() {
[year, month] => Ok((year.clone(), month.clone())),
_ => Err(Error::Structure(format!(
"{} is not a history shard directory",
shard.display()
))),
}
}
pub(super) fn is_event_id(stem: &str) -> bool {
let parts: Vec<&str> = stem.split('-').collect();
let [year, month, day, time, rest @ ..] = parts.as_slice() else {
return false;
};
let digits = |s: &str, n: usize| s.len() == n && s.bytes().all(|b| b.is_ascii_digit());
let Some(digest) = rest.last() else {
return false;
};
digits(year, 4)
&& digits(month, 2)
&& digits(day, 2)
&& digits(time, 4)
&& digest.len() == 8
&& digest.bytes().all(|b| b.is_ascii_hexdigit())
}
pub(super) fn id_stamp_of(stem: &str) -> Option<String> {
if !is_event_id(stem) {
return None;
}
let parts: Vec<&str> = stem.split('-').collect();
Some(parts[..4].join("-"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_stamp_is_the_minute_and_nothing_after_it() {
assert_eq!(
id_stamp_of("2026-07-31-0915-pre-sync-4f2a9c1e").as_deref(),
Some("2026-07-31-0915")
);
assert_eq!(
id_stamp_of("2026-07-31-0915-4f2a9c1e"),
id_stamp_of("2026-07-31-0915-pre-sync-9b3e0d77")
);
assert_eq!(id_stamp_of("not-an-event-id"), None);
assert_eq!(
id_stamp_of("2026-07-31-0915-4f2a9c1e.sync-conflict-091600"),
None
);
}
#[test]
fn an_event_id_is_reversible_to_its_shard_path() {
let id = "2026-07-31-0915-pre-sync-4f2a9c1e";
assert_eq!(shard_of(id).unwrap(), Path::new("2026").join("07"));
assert_eq!(
event_path(Path::new("history/index.md"), id, "md").unwrap(),
Path::new("history/events/2026/07/2026-07-31-0915-pre-sync-4f2a9c1e.md")
);
assert!(shard_of("not-an-event-id").is_err());
}
#[test]
fn a_blob_path_is_bare_hex_never_the_scheme_prefix() {
let hash = crate::fixity::digest(b"hello");
let path = blob_path(Path::new("history/index.md"), &hash).unwrap();
let spelled = path.to_string_lossy();
assert!(
!spelled.contains(':'),
"a colon in a blob filename is hostile to Windows and to sync clients: {spelled}"
);
let hex = hash.strip_prefix("sha256:").unwrap();
assert_eq!(
path,
Path::new("history/blobs").join(&hex[..2]).join(&hex[2..])
);
assert!(blob_path(Path::new("history/index.md"), "blake3:beef").is_err());
}
}