use crate::error::{Error, Result};
use crate::link;
use super::model::*;
use super::paths::*;
pub(super) fn canonical_bytes(
created: &str,
trigger: &str,
label: Option<&str>,
parent: Option<&str>,
files: &[FileEntry],
) -> Vec<u8> {
let mut out = String::new();
out.push_str(&format!("created\t{created}\n"));
out.push_str(&format!("trigger\t{trigger}\n"));
if let Some(label) = label {
out.push_str(&format!("label\t{label}\n"));
}
if let Some(parent) = parent {
out.push_str(&format!("parent\t{parent}\n"));
}
for file in files {
let id = file.id.as_ref().map(|i| i.0.as_str()).unwrap_or("");
out.push_str(&format!(
"file\t{}\t{id}\t{}\n",
slash_path(&file.path),
file.hash
));
}
out.into_bytes()
}
pub(super) fn mint_id(
created: &str,
trigger: &str,
label: Option<&str>,
parent: Option<&str>,
files: &[FileEntry],
) -> Result<String> {
let stamp = id_stamp(created)?;
let digest = crate::fixity::digest(&canonical_bytes(created, trigger, label, parent, files));
let short = &digest["sha256:".len().."sha256:".len() + 8];
Ok(match label.map(link::slug) {
Some(slug) => format!("{stamp}-{slug}-{short}"),
None => format!("{stamp}-{short}"),
})
}
pub(super) const FRACTION_DIGITS: usize = 6;
pub(super) fn comparable(created: &str) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
let Some(rest) = created.strip_suffix('Z') else {
return Cow::Borrowed(created);
};
let (whole, fraction) = match rest.split_once('.') {
Some((whole, fraction)) => (whole, fraction),
None => (rest, ""),
};
if fraction.len() == FRACTION_DIGITS {
return Cow::Borrowed(created);
}
let mut padded = fraction.to_string();
padded.truncate(FRACTION_DIGITS);
while padded.len() < FRACTION_DIGITS {
padded.push('0');
}
Cow::Owned(format!("{whole}.{padded}Z"))
}
pub(super) fn check_cutoff(cutoff: &str) -> Result<()> {
let ok = cutoff.len() >= 10
&& cutoff.as_bytes()[4] == b'-'
&& cutoff.as_bytes()[7] == b'-'
&& cutoff
.bytes()
.take(10)
.enumerate()
.all(|(i, b)| matches!(i, 4 | 7) || b.is_ascii_digit());
match ok {
true => Ok(()),
false => Err(Error::Structure(format!(
"`{cutoff}` is not a date — expected YYYY-MM-DD, or a full RFC 3339 timestamp"
))),
}
}
pub(super) fn id_stamp(created: &str) -> Result<String> {
let bad = || Error::Structure(format!("`{created}` is not an RFC 3339 UTC timestamp"));
let bytes = created.as_bytes();
if bytes.len() < 16 || bytes[4] != b'-' || bytes[7] != b'-' || bytes[13] != b':' {
return Err(bad());
}
let digits = |range: std::ops::Range<usize>| {
created
.get(range)
.filter(|s| s.bytes().all(|b| b.is_ascii_digit()))
.ok_or_else(bad)
};
Ok(format!(
"{}-{}-{}-{}{}",
digits(0..4)?,
digits(5..7)?,
digits(8..10)?,
digits(11..13)?,
digits(14..16)?
))
}
pub(super) fn display_stamp(id: &str) -> String {
let parts: Vec<&str> = id.splitn(5, '-').collect();
match parts.as_slice() {
[y, m, d, hm, ..] if hm.len() == 4 => format!("{y}-{m}-{d} {}:{}", &hm[..2], &hm[2..]),
_ => id.to_string(),
}
}
pub(super) fn label_slug(id: &str) -> Option<String> {
let parts: Vec<&str> = id.split('-').collect();
let [_, _, _, _, rest @ ..] = parts.as_slice() else {
return None;
};
match rest.len() {
0 | 1 => None,
n => Some(rest[..n - 1].join("-")),
}
}
pub(super) fn display_entry(id: &str) -> String {
match label_slug(id) {
Some(slug) => format!("{} ({slug})", display_stamp(id)),
None => display_stamp(id),
}
}
#[cfg(test)]
mod tests {
use super::super::TRIGGER_MANUAL;
use super::super::layout::shard_of;
use super::super::support::entry;
use super::*;
#[test]
fn the_canonical_form_matches_the_spec_spelled_out_by_hand() {
let files = vec![
FileEntry {
path: "notes/foo.md".into(),
id: Some(crate::identity::Id("b7k2m".into())),
hash: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
.into(),
},
FileEntry {
path: "notes/photo.jpg".into(),
id: None,
hash: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
.into(),
},
];
let by_hand = concat!(
"created\t2026-07-31T09:15:22.481903Z\n",
"trigger\tmanual\n",
"label\tpre-sync\n",
"parent\t2026-07-30-1804-nightly-8c1d55aa\n",
"file\tnotes/foo.md\tb7k2m\tsha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08\n",
"file\tnotes/photo.jpg\t\tsha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae\n",
);
assert_eq!(
String::from_utf8(canonical_bytes(
"2026-07-31T09:15:22.481903Z",
TRIGGER_MANUAL,
Some("pre-sync"),
Some("2026-07-30-1804-nightly-8c1d55aa"),
&files,
))
.unwrap(),
by_hand,
"the canonical form drifted from docs/history-format.md §4.1"
);
let digest = crate::fixity::digest(by_hand.as_bytes());
assert_eq!(
&digest["sha256:".len().."sha256:".len() + 8],
"21ae2ca1",
"the id digest changed — every event on disk keeps its old id"
);
assert_eq!(
mint_id(
"2026-07-31T09:15:22.481903Z",
TRIGGER_MANUAL,
Some("pre-sync"),
Some("2026-07-30-1804-nightly-8c1d55aa"),
&files,
)
.unwrap(),
"2026-07-31-0915-pre-sync-21ae2ca1",
"the id's shape is `<date>-<HHMM>[-<slug>]-<8 hex>` (§4)"
);
}
#[test]
fn the_id_stamp_reads_the_timestamp_and_survives_a_round_trip() {
assert_eq!(id_stamp("2026-07-31T09:15:22Z").unwrap(), "2026-07-31-0915");
assert!(id_stamp("yesterday").is_err());
assert_eq!(
display_stamp("2026-07-31-0915-pre-sync-4f2a9c1e"),
"2026-07-31 09:15"
);
assert_eq!(
display_entry("2026-07-31-0915-pre-sync-4f2a9c1e"),
"2026-07-31 09:15 (pre-sync)"
);
assert_eq!(
label_slug("2026-07-31-0915-pre-sync-4f2a9c1e"),
Some("pre-sync".into())
);
assert_eq!(label_slug("2026-07-31-0915-4f2a9c1e"), None);
assert_eq!(
display_entry("2026-07-31-0915-4f2a9c1e"),
"2026-07-31 09:15"
);
}
#[test]
fn the_canonical_form_ignores_the_serialization_format() {
let files = vec![entry("a.md", b"a"), entry("b.md", b"b")];
let one = mint_id("2026-07-31T09:15:22Z", TRIGGER_MANUAL, None, None, &files).unwrap();
let two = mint_id("2026-07-31T09:15:22Z", TRIGGER_MANUAL, None, None, &files).unwrap();
assert_eq!(one, two);
let changed = vec![entry("a.md", b"a"), entry("b.md", b"CHANGED")];
let three = mint_id("2026-07-31T09:15:22Z", TRIGGER_MANUAL, None, None, &changed).unwrap();
assert_ne!(one, three);
let forked = mint_id(
"2026-07-31T09:15:22Z",
TRIGGER_MANUAL,
None,
Some("2026-07-30-1804-nightly-8c1d55aa"),
&files,
)
.unwrap();
assert_ne!(one, forked);
}
#[test]
fn a_label_is_slugged_into_the_id_and_omitted_when_absent() {
let files = vec![entry("a.md", b"a")];
let labeled = mint_id(
"2026-07-31T09:15:22Z",
TRIGGER_MANUAL,
Some("Pre Sync!"),
None,
&files,
)
.unwrap();
assert!(
labeled.starts_with("2026-07-31-0915-pre-sync-"),
"{labeled}"
);
let bare = mint_id("2026-07-31T09:15:22Z", TRIGGER_MANUAL, None, None, &files).unwrap();
assert!(bare.starts_with("2026-07-31-0915-"), "{bare}");
assert_eq!(shard_of(&labeled).unwrap(), shard_of(&bare).unwrap());
}
#[test]
fn timestamps_of_two_precisions_still_order_against_each_other() {
let coarse = "2026-07-31T09:15:10Z";
let fine = "2026-07-31T09:15:10.500000Z";
assert!(
coarse > fine,
"the raw strings really are backwards — `Z` sorts after `.`"
);
assert!(
comparable(coarse) < comparable(fine),
"normalized, 09:15:10.000000 precedes 09:15:10.500000"
);
assert_eq!(
comparable("2026-07-31T09:15:10Z"),
"2026-07-31T09:15:10.000000Z"
);
assert_eq!(
comparable("2026-07-31T09:15:10.5Z"),
"2026-07-31T09:15:10.500000Z"
);
assert_eq!(
comparable("2026-07-31T09:15:10.123456789Z"),
"2026-07-31T09:15:10.123456Z"
);
assert!(matches!(
comparable("2026-07-31T09:15:10.123456Z"),
std::borrow::Cow::Borrowed(_)
));
assert_eq!(
comparable("2026-07-31T09:15:10+01:00"),
"2026-07-31T09:15:10+01:00"
);
assert_eq!(id_stamp(coarse).unwrap(), id_stamp(fine).unwrap());
}
}