use std::{convert::Infallible, fmt};
use serde::{Deserialize, Serialize};
fn normalized_supersession_reason_token(input: &str) -> String {
let trimmed = input.trim();
let mut normalized = String::with_capacity(trimmed.len());
let mut previous_was_lowercase = false;
let mut previous_was_separator = false;
for character in trimmed.chars() {
match character {
'-' | '_' => {
if !normalized.is_empty() && !previous_was_separator {
normalized.push('_');
}
previous_was_lowercase = false;
previous_was_separator = true;
}
character if character.is_ascii_uppercase() => {
if previous_was_lowercase && !previous_was_separator {
normalized.push('_');
}
normalized.push(character.to_ascii_lowercase());
previous_was_lowercase = false;
previous_was_separator = false;
}
character => {
normalized.push(character.to_ascii_lowercase());
previous_was_lowercase = character.is_ascii_lowercase();
previous_was_separator = false;
}
}
}
normalized
}
pub const REVISION_GROUP_PREFIX: &str = "rev_";
pub const REVISION_GROUP_ID_LEN: usize = 29;
pub const LEGAL_HOLD_PREFIX: &str = "hold_";
pub const LEGAL_HOLD_ID_LEN: usize = 30;
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub struct CorpusRevision(String);
impl CorpusRevision {
pub const UNKNOWN: &'static str = "unknown";
#[must_use]
pub fn new(raw: impl Into<String>) -> Self {
let raw = raw.into();
let trimmed = raw.trim();
if trimmed.is_empty() {
Self::unknown()
} else {
Self(trimmed.to_owned())
}
}
#[must_use]
pub fn unknown() -> Self {
Self(Self::UNKNOWN.to_owned())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn is_unknown(&self) -> bool {
self.0 == Self::UNKNOWN
}
#[must_use]
pub fn is_coherent_with(&self, current: &Self) -> bool {
!self.is_unknown() && !current.is_unknown() && self == current
}
}
impl Default for CorpusRevision {
fn default() -> Self {
Self::unknown()
}
}
impl From<&str> for CorpusRevision {
fn from(raw: &str) -> Self {
Self::new(raw)
}
}
impl From<String> for CorpusRevision {
fn from(raw: String) -> Self {
Self::new(raw)
}
}
impl From<CorpusRevision> for String {
fn from(revision: CorpusRevision) -> Self {
revision.0
}
}
impl fmt::Display for CorpusRevision {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl AsRef<str> for CorpusRevision {
fn as_ref(&self) -> &str {
self.as_str()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RevisionIdError {
WrongPrefix {
input: String,
expected: &'static str,
},
WrongLength {
input: String,
expected: usize,
actual: usize,
},
}
impl fmt::Display for RevisionIdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::WrongPrefix { input, expected } => {
write!(f, "ID `{input}` must start with `{expected}`")
}
Self::WrongLength {
input,
expected,
actual,
} => {
write!(f, "ID `{input}` has length {actual}, expected {expected}")
}
}
}
}
impl std::error::Error for RevisionIdError {}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct RevisionGroupId(String);
impl RevisionGroupId {
pub fn parse(input: impl Into<String>) -> Result<Self, RevisionIdError> {
let id = input.into();
if !id.starts_with(REVISION_GROUP_PREFIX) {
return Err(RevisionIdError::WrongPrefix {
input: id,
expected: REVISION_GROUP_PREFIX,
});
}
if id.len() != REVISION_GROUP_ID_LEN {
return Err(RevisionIdError::WrongLength {
input: id.clone(),
expected: REVISION_GROUP_ID_LEN,
actual: id.len(),
});
}
Ok(Self(id))
}
#[must_use]
pub fn from_trusted(id: impl Into<String>) -> Self {
let id = id.into();
debug_assert!(
id.starts_with(REVISION_GROUP_PREFIX) && id.len() == REVISION_GROUP_ID_LEN,
"invalid revision group ID: {id}"
);
Self(id)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for RevisionGroupId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for RevisionGroupId {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct LegalHoldId(String);
impl LegalHoldId {
pub fn parse(input: impl Into<String>) -> Result<Self, RevisionIdError> {
let id = input.into();
if !id.starts_with(LEGAL_HOLD_PREFIX) {
return Err(RevisionIdError::WrongPrefix {
input: id,
expected: LEGAL_HOLD_PREFIX,
});
}
if id.len() != LEGAL_HOLD_ID_LEN {
return Err(RevisionIdError::WrongLength {
input: id.clone(),
expected: LEGAL_HOLD_ID_LEN,
actual: id.len(),
});
}
Ok(Self(id))
}
#[must_use]
pub fn from_trusted(id: impl Into<String>) -> Self {
let id = id.into();
debug_assert!(
id.starts_with(LEGAL_HOLD_PREFIX) && id.len() == LEGAL_HOLD_ID_LEN,
"invalid legal hold ID: {id}"
);
Self(id)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for LegalHoldId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for LegalHoldId {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SupersessionLink {
pub superseded_id: String,
pub superseding_id: String,
pub reason: SupersessionReason,
pub created_at: String,
}
impl SupersessionLink {
#[must_use]
pub fn new(
superseded_id: impl Into<String>,
superseding_id: impl Into<String>,
reason: SupersessionReason,
created_at: impl Into<String>,
) -> Self {
Self {
superseded_id: superseded_id.into(),
superseding_id: superseding_id.into(),
reason,
created_at: created_at.into(),
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum SupersessionReason {
#[default]
UserUpdate,
Curation,
Consolidation,
Correction,
Import,
SystemGenerated,
}
impl SupersessionReason {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::UserUpdate => "user_update",
Self::Curation => "curation",
Self::Consolidation => "consolidation",
Self::Correction => "correction",
Self::Import => "import",
Self::SystemGenerated => "system_generated",
}
}
#[must_use]
pub fn parse_lossy(s: &str) -> Self {
match normalized_supersession_reason_token(s).as_str() {
"user_update" | "update" => Self::UserUpdate,
"curation" => Self::Curation,
"consolidation" => Self::Consolidation,
"correction" => Self::Correction,
"import" => Self::Import,
"system_generated" => Self::SystemGenerated,
_ => Self::UserUpdate,
}
}
}
impl std::str::FromStr for SupersessionReason {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parse_lossy(s))
}
}
impl fmt::Display for SupersessionReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct IdempotencyKey(String);
impl IdempotencyKey {
pub const MAX_LEN: usize = 128;
pub fn new(key: impl Into<String>) -> Result<Self, IdempotencyKeyError> {
let key = key.into();
if key.is_empty() {
return Err(IdempotencyKeyError::Empty);
}
if key.len() > Self::MAX_LEN {
return Err(IdempotencyKeyError::TooLong {
len: key.len(),
max: Self::MAX_LEN,
});
}
Ok(Self(key))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for IdempotencyKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for IdempotencyKey {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IdempotencyKeyError {
Empty,
TooLong { len: usize, max: usize },
}
impl fmt::Display for IdempotencyKeyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "idempotency key cannot be empty"),
Self::TooLong { len, max } => {
write!(f, "idempotency key too long: {len} bytes (max {max})")
}
}
}
}
impl std::error::Error for IdempotencyKeyError {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LegalHold {
pub hold_id: LegalHoldId,
pub memory_id: String,
pub reason: String,
pub placed_by: String,
pub placed_at: String,
pub released_at: Option<String>,
pub released_by: Option<String>,
}
impl LegalHold {
#[must_use]
pub fn new(
hold_id: LegalHoldId,
memory_id: impl Into<String>,
reason: impl Into<String>,
placed_by: impl Into<String>,
placed_at: impl Into<String>,
) -> Self {
Self {
hold_id,
memory_id: memory_id.into(),
reason: reason.into(),
placed_by: placed_by.into(),
placed_at: placed_at.into(),
released_at: None,
released_by: None,
}
}
#[must_use]
pub fn is_active(&self) -> bool {
self.released_at.is_none()
}
#[must_use]
pub fn release(
mut self,
released_by: impl Into<String>,
released_at: impl Into<String>,
) -> Self {
self.released_by = Some(released_by.into());
self.released_at = Some(released_at.into());
self
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RevisionMeta {
pub group_id: RevisionGroupId,
pub version: u32,
pub supersedes: Option<String>,
pub is_current: bool,
pub idempotency_key: Option<IdempotencyKey>,
}
impl RevisionMeta {
#[must_use]
pub fn first(group_id: RevisionGroupId) -> Self {
Self {
group_id,
version: 1,
supersedes: None,
is_current: true,
idempotency_key: None,
}
}
#[must_use]
pub fn subsequent(
group_id: RevisionGroupId,
version: u32,
supersedes: impl Into<String>,
) -> Self {
Self {
group_id,
version,
supersedes: Some(supersedes.into()),
is_current: true,
idempotency_key: None,
}
}
#[must_use]
pub fn with_idempotency_key(mut self, key: IdempotencyKey) -> Self {
self.idempotency_key = Some(key);
self
}
#[must_use]
pub fn as_superseded(mut self) -> Self {
self.is_current = false;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
type TestResult = Result<(), String>;
#[test]
fn corpus_revision_is_opaque_and_unknown_is_incoherent() {
let revision = CorpusRevision::new(" corpus:v1 ");
assert_eq!(revision.as_str(), "corpus:v1");
assert!(revision.is_coherent_with(&CorpusRevision::from("corpus:v1")));
assert!(!revision.is_coherent_with(&CorpusRevision::from("corpus:v2")));
assert!(!CorpusRevision::unknown().is_coherent_with(&revision));
assert!(!revision.is_coherent_with(&CorpusRevision::unknown()));
assert!(CorpusRevision::new("").is_unknown());
}
fn ensure_equal<T: std::fmt::Debug + PartialEq>(
actual: &T,
expected: &T,
context: &str,
) -> TestResult {
if actual == expected {
Ok(())
} else {
Err(format!("{context}: expected {expected:?}, got {actual:?}"))
}
}
#[test]
fn revision_group_id_validates_format() -> TestResult {
let valid = RevisionGroupId::parse("rev_test000000000000000000000");
assert!(valid.is_ok(), "valid ID should parse");
let wrong_prefix = RevisionGroupId::parse("mem_test000000000000000000000");
assert!(wrong_prefix.is_err(), "wrong prefix should fail");
let too_short = RevisionGroupId::parse("rev_abc");
assert!(too_short.is_err(), "too short should fail");
Ok(())
}
#[test]
fn legal_hold_id_validates_format() -> TestResult {
let valid = LegalHoldId::parse("hold_test000000000000000000000");
assert!(valid.is_ok(), "valid ID should parse");
let wrong_prefix = LegalHoldId::parse("rev_test0000000000000000000");
assert!(wrong_prefix.is_err(), "wrong prefix should fail");
Ok(())
}
#[test]
fn supersession_reason_strings_are_stable() -> TestResult {
ensure_equal(
&SupersessionReason::UserUpdate.as_str(),
&"user_update",
"user_update",
)?;
ensure_equal(
&SupersessionReason::Curation.as_str(),
&"curation",
"curation",
)?;
ensure_equal(
&SupersessionReason::Consolidation.as_str(),
&"consolidation",
"consolidation",
)?;
ensure_equal(
&SupersessionReason::Correction.as_str(),
&"correction",
"correction",
)?;
ensure_equal(&SupersessionReason::Import.as_str(), &"import", "import")?;
ensure_equal(
&SupersessionReason::SystemGenerated.as_str(),
&"system_generated",
"system_generated",
)
}
#[test]
fn supersession_reason_round_trips() {
for reason in [
SupersessionReason::UserUpdate,
SupersessionReason::Curation,
SupersessionReason::Consolidation,
SupersessionReason::Correction,
SupersessionReason::Import,
SupersessionReason::SystemGenerated,
] {
let parsed = SupersessionReason::parse_lossy(reason.as_str());
assert_eq!(reason, parsed, "round trip failed for {reason:?}");
}
}
#[test]
fn supersession_reason_parse_lossy_normalizes_external_values() {
assert_eq!(
SupersessionReason::parse_lossy("user-update"),
SupersessionReason::UserUpdate
);
assert_eq!(
SupersessionReason::parse_lossy(" update "),
SupersessionReason::UserUpdate
);
assert_eq!(
SupersessionReason::parse_lossy(" Correction "),
SupersessionReason::Correction
);
assert_eq!(
SupersessionReason::parse_lossy("system-generated"),
SupersessionReason::SystemGenerated
);
assert_eq!(
SupersessionReason::parse_lossy(" SYSTEM_GENERATED "),
SupersessionReason::SystemGenerated
);
assert_eq!(
SupersessionReason::parse_lossy("systemGenerated"),
SupersessionReason::SystemGenerated
);
assert_eq!(
SupersessionReason::parse_lossy("UserUpdate"),
SupersessionReason::UserUpdate
);
}
#[test]
fn idempotency_key_validates_length() {
let valid = IdempotencyKey::new("import-session-abc123");
assert!(valid.is_ok(), "valid key should work");
let empty = IdempotencyKey::new("");
assert!(
matches!(empty, Err(IdempotencyKeyError::Empty)),
"empty key should fail"
);
let too_long = IdempotencyKey::new("x".repeat(200));
assert!(
matches!(too_long, Err(IdempotencyKeyError::TooLong { .. })),
"too long key should fail"
);
}
#[test]
fn legal_hold_lifecycle() {
let hold_id = LegalHoldId::from_trusted("hold_test000000000000000000000");
let hold = LegalHold::new(
hold_id,
"mem_test000000000000000000000",
"Litigation hold",
"legal@example.com",
"2026-01-01T00:00:00Z",
);
assert!(hold.is_active(), "new hold should be active");
let released = hold.release("legal@example.com", "2026-02-01T00:00:00Z");
assert!(!released.is_active(), "released hold should not be active");
assert_eq!(
released.released_at,
Some("2026-02-01T00:00:00Z".to_string())
);
}
#[test]
fn revision_meta_version_tracking() {
let group_id = RevisionGroupId::from_trusted("rev_test000000000000000000000");
let first = RevisionMeta::first(group_id.clone());
assert_eq!(first.version, 1);
assert!(first.is_current);
assert!(first.supersedes.is_none());
let second = RevisionMeta::subsequent(group_id.clone(), 2, "mem_v1");
assert_eq!(second.version, 2);
assert!(second.is_current);
assert_eq!(second.supersedes, Some("mem_v1".to_string()));
let superseded = first.as_superseded();
assert!(!superseded.is_current);
}
#[test]
fn revision_meta_with_idempotency_key() -> TestResult {
let group_id = RevisionGroupId::from_trusted("rev_test000000000000000000000");
let key = IdempotencyKey::new("import-xyz")
.map_err(|error| format!("valid key should parse: {error}"))?;
let meta = RevisionMeta::first(group_id).with_idempotency_key(key);
assert!(meta.idempotency_key.is_some());
ensure_equal(
&meta.idempotency_key.as_ref().map(IdempotencyKey::as_str),
&Some("import-xyz"),
"idempotency key",
)
}
#[test]
fn supersession_link_creation() {
let link = SupersessionLink::new(
"mem_old",
"mem_new",
SupersessionReason::Curation,
"2026-01-01T00:00:00Z",
);
assert_eq!(link.superseded_id, "mem_old");
assert_eq!(link.superseding_id, "mem_new");
assert_eq!(link.reason, SupersessionReason::Curation);
}
}