use serde::{Deserialize, Serialize};
use std::{fmt, str::FromStr};
use terseid::{
IdConfig, IdGenerator, IdResolver, ParsedId, ResolverConfig, child_id as terseid_child_id,
id_depth as terseid_id_depth, is_child_id as terseid_is_child_id, parse_id,
};
pub const BONES_PREFIX: &str = "bn";
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ItemId(String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ItemIdError {
InvalidFormat(String),
WrongPrefix { expected: String, found: String },
Ambiguous {
partial: String,
matches: Vec<String>,
},
NotFound(String),
}
impl fmt::Display for ItemIdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidFormat(raw) => write!(f, "invalid item ID format: '{raw}'"),
Self::WrongPrefix { expected, found } => {
write!(f, "expected prefix '{expected}', found '{found}'")
}
Self::Ambiguous { partial, matches } => {
write!(f, "ambiguous ID '{partial}': matches {matches:?}")
}
Self::NotFound(id) => write!(f, "item ID not found: '{id}'"),
}
}
}
impl std::error::Error for ItemIdError {}
impl ItemId {
#[must_use]
pub fn new_unchecked(raw: impl Into<String>) -> Self {
Self(raw.into())
}
pub fn parse(raw: &str) -> Result<Self, ItemIdError> {
let normalized = raw.trim().to_lowercase();
let parsed =
parse_id(&normalized).map_err(|_| ItemIdError::InvalidFormat(raw.to_string()))?;
if parsed.prefix != BONES_PREFIX {
return Err(ItemIdError::WrongPrefix {
expected: BONES_PREFIX.to_string(),
found: parsed.prefix,
});
}
Ok(Self(parsed.to_id_string()))
}
pub fn parse_any_prefix(raw: &str) -> Result<Self, ItemIdError> {
let normalized = raw.trim().to_lowercase();
let parsed =
parse_id(&normalized).map_err(|_| ItemIdError::InvalidFormat(raw.to_string()))?;
Ok(Self(parsed.to_id_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn parsed(&self) -> ParsedId {
parse_id(&self.0).expect("ItemId invariant broken")
}
#[must_use]
pub fn is_root(&self) -> bool {
!terseid_is_child_id(&self.0)
}
#[must_use]
pub fn is_child(&self) -> bool {
terseid_is_child_id(&self.0)
}
#[must_use]
pub fn depth(&self) -> usize {
terseid_id_depth(&self.0)
}
#[must_use]
pub fn child(&self, number: u32) -> Self {
Self(terseid_child_id(&self.0, number))
}
#[must_use]
pub fn parent(&self) -> Option<Self> {
self.parsed().parent().map(Self)
}
#[must_use]
pub fn is_child_of(&self, ancestor: &Self) -> bool {
self.parsed().is_child_of(ancestor.as_str())
}
}
impl FromStr for ItemId {
type Err = ItemIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl fmt::Display for ItemId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for ItemId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<ItemId> for String {
fn from(id: ItemId) -> Self {
id.0
}
}
impl TryFrom<String> for ItemId {
type Error = ItemIdError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(&value)
}
}
#[must_use]
pub fn bones_id_generator() -> IdGenerator {
IdGenerator::new(IdConfig::new(BONES_PREFIX))
}
pub fn generate_item_id(seed: &str, item_count: usize, exists: impl Fn(&str) -> bool) -> ItemId {
let generator = bones_id_generator();
let id = generator.generate(
|nonce| format!("{seed}\0{nonce}").into_bytes(),
item_count,
&exists,
);
ItemId(id)
}
pub fn resolve_item_id(
input: &str,
exists: impl Fn(&str) -> bool,
substring_match: impl Fn(&str) -> Vec<String>,
) -> Result<ItemId, ItemIdError> {
let cfg = ResolverConfig::new(BONES_PREFIX);
let id_resolver = IdResolver::new(cfg);
let result = id_resolver
.resolve(input, &exists, &substring_match)
.map_err(|e| match e {
terseid::TerseIdError::AmbiguousId { partial, matches } => {
ItemIdError::Ambiguous { partial, matches }
}
terseid::TerseIdError::NotFound { id } => ItemIdError::NotFound(id),
other => ItemIdError::InvalidFormat(other.to_string()),
})?;
Ok(ItemId(result.id))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn parse_valid_root_id() {
let id = ItemId::parse("bn-a7x").unwrap();
assert_eq!(id.as_str(), "bn-a7x");
assert!(id.is_root());
assert!(!id.is_child());
assert_eq!(id.depth(), 0);
}
#[test]
fn parse_valid_child_id() {
let id = ItemId::parse("bn-a7x.1").unwrap();
assert_eq!(id.as_str(), "bn-a7x.1");
assert!(!id.is_root());
assert!(id.is_child());
assert_eq!(id.depth(), 1);
}
#[test]
fn parse_valid_grandchild_id() {
let id = ItemId::parse("bn-a7x.1.3").unwrap();
assert_eq!(id.depth(), 2);
}
#[test]
fn parse_normalises_case() {
let id = ItemId::parse("BN-A7X").unwrap();
assert_eq!(id.as_str(), "bn-a7x");
}
#[test]
fn parse_trims_whitespace() {
let id = ItemId::parse(" bn-a7x ").unwrap();
assert_eq!(id.as_str(), "bn-a7x");
}
#[test]
fn parse_rejects_wrong_prefix() {
let err = ItemId::parse("tk-a7x").unwrap_err();
assert!(matches!(err, ItemIdError::WrongPrefix { .. }));
}
#[test]
fn parse_rejects_invalid_format() {
assert!(ItemId::parse("notanid").is_err());
assert!(ItemId::parse("bn-").is_err());
assert!(ItemId::parse("").is_err());
}
#[test]
fn parse_accepts_all_letter_hash() {
assert!(ItemId::parse("bn-abcd").is_ok());
assert!(ItemId::parse("bn-unwi").is_ok());
}
#[test]
fn parse_accepts_longer_hash_with_digit() {
let id = ItemId::parse("bn-a7x3q9").unwrap();
assert_eq!(id.as_str(), "bn-a7x3q9");
}
#[test]
fn display_fromstr_roundtrip() {
let id: ItemId = "bn-a7x".parse().unwrap();
let rendered = id.to_string();
let reparsed: ItemId = rendered.parse().unwrap();
assert_eq!(id, reparsed);
}
#[test]
fn display_fromstr_roundtrip_child() {
let id: ItemId = "bn-a7x.1.3".parse().unwrap();
let rendered = id.to_string();
let reparsed: ItemId = rendered.parse().unwrap();
assert_eq!(id, reparsed);
}
#[test]
fn serde_json_roundtrip() {
let id = ItemId::parse("bn-a7x.1").unwrap();
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, "\"bn-a7x.1\"");
let deser: ItemId = serde_json::from_str(&json).unwrap();
assert_eq!(id, deser);
}
#[test]
fn serde_rejects_invalid() {
let result = serde_json::from_str::<ItemId>("\"notvalid\"");
assert!(result.is_err());
}
#[test]
fn child_creates_valid_id() {
let parent = ItemId::parse("bn-a7x").unwrap();
let child = parent.child(1);
assert_eq!(child.as_str(), "bn-a7x.1");
assert!(child.is_child());
}
#[test]
fn grandchild_creation() {
let root = ItemId::parse("bn-a7x").unwrap();
let child = root.child(1);
let grandchild = child.child(3);
assert_eq!(grandchild.as_str(), "bn-a7x.1.3");
assert_eq!(grandchild.depth(), 2);
}
#[test]
fn parent_of_root_is_none() {
let root = ItemId::parse("bn-a7x").unwrap();
assert!(root.parent().is_none());
}
#[test]
fn parent_of_child() {
let child = ItemId::parse("bn-a7x.1").unwrap();
let parent = child.parent().unwrap();
assert_eq!(parent.as_str(), "bn-a7x");
}
#[test]
fn parent_of_grandchild() {
let gc = ItemId::parse("bn-a7x.1.3").unwrap();
let parent = gc.parent().unwrap();
assert_eq!(parent.as_str(), "bn-a7x.1");
}
#[test]
fn is_child_of_works() {
let root = ItemId::parse("bn-a7x").unwrap();
let child = ItemId::parse("bn-a7x.1").unwrap();
let gc = ItemId::parse("bn-a7x.1.3").unwrap();
assert!(child.is_child_of(&root));
assert!(gc.is_child_of(&root));
assert!(gc.is_child_of(&child));
assert!(!root.is_child_of(&child));
assert!(!child.is_child_of(&gc));
}
#[test]
fn generate_produces_valid_id() {
let id = generate_item_id("my test item", 0, |_| false);
assert!(id.as_str().starts_with("bn-"));
assert!(id.is_root());
let reparsed = ItemId::parse(id.as_str()).unwrap();
assert_eq!(id, reparsed);
}
#[test]
fn generate_deterministic_with_same_seed() {
let id1 = generate_item_id("seed-abc", 0, |_| false);
let id2 = generate_item_id("seed-abc", 0, |_| false);
assert_eq!(id1, id2);
}
#[test]
fn generate_different_seeds_different_ids() {
let id1 = generate_item_id("seed-one", 0, |_| false);
let id2 = generate_item_id("seed-two", 0, |_| false);
assert_ne!(id1, id2);
}
#[test]
fn generate_avoids_collisions() {
let mut taken: HashSet<String> = HashSet::new();
for i in 0..20 {
let id = generate_item_id(&format!("item-{i}"), taken.len(), |candidate| {
taken.contains(candidate)
});
assert!(
taken.insert(id.as_str().to_string()),
"collision on iteration {i}: {}",
id
);
}
assert_eq!(taken.len(), 20);
}
#[test]
fn generate_adaptive_length_grows() {
let generator = bones_id_generator();
let short = generator.optimal_length(0);
assert_eq!(short, 3);
let long = generator.optimal_length(100_000);
assert!(long > short, "expected adaptive growth: {long} > {short}");
}
#[test]
fn resolve_exact() {
let known = vec!["bn-a7x".to_string(), "bn-b8y".to_string()];
let id = resolve_item_id(
"bn-a7x",
|candidate| known.contains(&candidate.to_string()),
|_sub| vec![],
)
.unwrap();
assert_eq!(id.as_str(), "bn-a7x");
}
#[test]
fn resolve_bare_hash() {
let known = vec!["bn-a7x".to_string()];
let id = resolve_item_id(
"a7x",
|candidate| known.contains(&candidate.to_string()),
|_sub| vec![],
)
.unwrap();
assert_eq!(id.as_str(), "bn-a7x");
}
#[test]
fn resolve_substring() {
let known = vec!["bn-a7x".to_string(), "bn-b8y".to_string()];
let id = resolve_item_id(
"a7",
|candidate| known.contains(&candidate.to_string()),
|sub| {
known
.iter()
.filter(|id| id.split('-').last().is_some_and(|hash| hash.contains(sub)))
.cloned()
.collect()
},
)
.unwrap();
assert_eq!(id.as_str(), "bn-a7x");
}
#[test]
fn resolve_ambiguous() {
let known = vec!["bn-a7x".to_string(), "bn-a7y".to_string()];
let err = resolve_item_id(
"a7",
|candidate| known.contains(&candidate.to_string()),
|sub| {
known
.iter()
.filter(|id| id.split('-').last().is_some_and(|hash| hash.contains(sub)))
.cloned()
.collect()
},
)
.unwrap_err();
assert!(matches!(err, ItemIdError::Ambiguous { .. }));
}
#[test]
fn resolve_not_found() {
let err = resolve_item_id("zzz", |_| false, |_| vec![]).unwrap_err();
assert!(matches!(err, ItemIdError::NotFound(_)));
}
#[test]
fn ordering_is_lexicographic() {
let a = ItemId::parse("bn-a7x").unwrap();
let b = ItemId::parse("bn-b8y").unwrap();
assert!(a < b);
}
#[test]
fn hash_set_deduplication() {
let id1 = ItemId::parse("bn-a7x").unwrap();
let id2 = ItemId::parse("bn-a7x").unwrap();
let mut set = HashSet::new();
set.insert(id1);
set.insert(id2);
assert_eq!(set.len(), 1);
}
#[test]
fn as_ref_str() {
let id = ItemId::parse("bn-a7x").unwrap();
let s: &str = id.as_ref();
assert_eq!(s, "bn-a7x");
}
#[test]
fn into_string() {
let id = ItemId::parse("bn-a7x").unwrap();
let s: String = id.into();
assert_eq!(s, "bn-a7x");
}
#[test]
fn new_unchecked_trusts_caller() {
let id = ItemId::new_unchecked("bn-a7x");
assert_eq!(id.as_str(), "bn-a7x");
}
#[test]
fn error_display() {
let e = ItemIdError::InvalidFormat("bad".into());
assert!(e.to_string().contains("bad"));
let e = ItemIdError::WrongPrefix {
expected: "bn".into(),
found: "tk".into(),
};
assert!(e.to_string().contains("bn"));
assert!(e.to_string().contains("tk"));
let e = ItemIdError::Ambiguous {
partial: "a7".into(),
matches: vec!["bn-a7x".into(), "bn-a7y".into()],
};
assert!(e.to_string().contains("a7"));
let e = ItemIdError::NotFound("zzz".into());
assert!(e.to_string().contains("zzz"));
}
}