1use serde::{Deserialize, Serialize};
9use std::{fmt, str::FromStr};
10use terseid::{
11 IdConfig, IdGenerator, IdResolver, ParsedId, ResolverConfig, child_id as terseid_child_id,
12 id_depth as terseid_id_depth, is_child_id as terseid_is_child_id, parse_id,
13};
14
15pub const BONES_PREFIX: &str = "bn";
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
28#[serde(try_from = "String", into = "String")]
29pub struct ItemId(String);
30
31#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ItemIdError {
38 InvalidFormat(String),
40 WrongPrefix { expected: String, found: String },
42 Ambiguous {
44 partial: String,
45 matches: Vec<String>,
46 },
47 NotFound(String),
49}
50
51impl fmt::Display for ItemIdError {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 match self {
54 Self::InvalidFormat(raw) => write!(f, "invalid item ID format: '{raw}'"),
55 Self::WrongPrefix { expected, found } => {
56 write!(f, "expected prefix '{expected}', found '{found}'")
57 }
58 Self::Ambiguous { partial, matches } => {
59 write!(f, "ambiguous ID '{partial}': matches {matches:?}")
60 }
61 Self::NotFound(id) => write!(f, "item ID not found: '{id}'"),
62 }
63 }
64}
65
66impl std::error::Error for ItemIdError {}
67
68impl ItemId {
73 #[must_use]
80 pub fn new_unchecked(raw: impl Into<String>) -> Self {
81 Self(raw.into())
82 }
83
84 pub fn parse(raw: &str) -> Result<Self, ItemIdError> {
93 let normalized = raw.trim().to_lowercase();
94
95 let parsed =
97 parse_id(&normalized).map_err(|_| ItemIdError::InvalidFormat(raw.to_string()))?;
98
99 if parsed.prefix != BONES_PREFIX {
100 return Err(ItemIdError::WrongPrefix {
101 expected: BONES_PREFIX.to_string(),
102 found: parsed.prefix,
103 });
104 }
105
106 Ok(Self(parsed.to_id_string()))
107 }
108
109 pub fn parse_any_prefix(raw: &str) -> Result<Self, ItemIdError> {
119 let normalized = raw.trim().to_lowercase();
120 let parsed =
121 parse_id(&normalized).map_err(|_| ItemIdError::InvalidFormat(raw.to_string()))?;
122 Ok(Self(parsed.to_id_string()))
123 }
124
125 #[must_use]
127 pub fn as_str(&self) -> &str {
128 &self.0
129 }
130
131 #[must_use]
140 pub fn parsed(&self) -> ParsedId {
141 parse_id(&self.0).expect("ItemId invariant broken")
142 }
143
144 #[must_use]
146 pub fn is_root(&self) -> bool {
147 !terseid_is_child_id(&self.0)
148 }
149
150 #[must_use]
152 pub fn is_child(&self) -> bool {
153 terseid_is_child_id(&self.0)
154 }
155
156 #[must_use]
158 pub fn depth(&self) -> usize {
159 terseid_id_depth(&self.0)
160 }
161
162 #[must_use]
171 pub fn child(&self, number: u32) -> Self {
172 Self(terseid_child_id(&self.0, number))
173 }
174
175 #[must_use]
177 pub fn parent(&self) -> Option<Self> {
178 self.parsed().parent().map(Self)
179 }
180
181 #[must_use]
183 pub fn is_child_of(&self, ancestor: &Self) -> bool {
184 self.parsed().is_child_of(ancestor.as_str())
185 }
186}
187
188impl FromStr for ItemId {
193 type Err = ItemIdError;
194
195 fn from_str(s: &str) -> Result<Self, Self::Err> {
196 Self::parse(s)
197 }
198}
199
200impl fmt::Display for ItemId {
201 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202 f.write_str(&self.0)
203 }
204}
205
206impl AsRef<str> for ItemId {
207 fn as_ref(&self) -> &str {
208 &self.0
209 }
210}
211
212impl From<ItemId> for String {
213 fn from(id: ItemId) -> Self {
214 id.0
215 }
216}
217
218impl TryFrom<String> for ItemId {
219 type Error = ItemIdError;
220
221 fn try_from(value: String) -> Result<Self, Self::Error> {
222 Self::parse(&value)
223 }
224}
225
226#[must_use]
233pub fn bones_id_generator() -> IdGenerator {
234 IdGenerator::new(IdConfig::new(BONES_PREFIX))
235}
236
237pub fn generate_item_id(seed: &str, item_count: usize, exists: impl Fn(&str) -> bool) -> ItemId {
243 let generator = bones_id_generator();
244 let id = generator.generate(
245 |nonce| format!("{seed}\0{nonce}").into_bytes(),
246 item_count,
247 &exists,
248 );
249 ItemId(id)
250}
251
252pub fn resolve_item_id(
271 input: &str,
272 exists: impl Fn(&str) -> bool,
273 substring_match: impl Fn(&str) -> Vec<String>,
274) -> Result<ItemId, ItemIdError> {
275 let cfg = ResolverConfig::new(BONES_PREFIX);
276 let id_resolver = IdResolver::new(cfg);
277
278 let result = id_resolver
279 .resolve(input, &exists, &substring_match)
280 .map_err(|e| match e {
281 terseid::TerseIdError::AmbiguousId { partial, matches } => {
282 ItemIdError::Ambiguous { partial, matches }
283 }
284 terseid::TerseIdError::NotFound { id } => ItemIdError::NotFound(id),
285 other => ItemIdError::InvalidFormat(other.to_string()),
286 })?;
287
288 Ok(ItemId(result.id))
290}
291
292#[cfg(test)]
297mod tests {
298 use super::*;
299 use std::collections::HashSet;
300
301 #[test]
304 fn parse_valid_root_id() {
305 let id = ItemId::parse("bn-a7x").unwrap();
306 assert_eq!(id.as_str(), "bn-a7x");
307 assert!(id.is_root());
308 assert!(!id.is_child());
309 assert_eq!(id.depth(), 0);
310 }
311
312 #[test]
313 fn parse_valid_child_id() {
314 let id = ItemId::parse("bn-a7x.1").unwrap();
315 assert_eq!(id.as_str(), "bn-a7x.1");
316 assert!(!id.is_root());
317 assert!(id.is_child());
318 assert_eq!(id.depth(), 1);
319 }
320
321 #[test]
322 fn parse_valid_grandchild_id() {
323 let id = ItemId::parse("bn-a7x.1.3").unwrap();
324 assert_eq!(id.depth(), 2);
325 }
326
327 #[test]
328 fn parse_normalises_case() {
329 let id = ItemId::parse("BN-A7X").unwrap();
330 assert_eq!(id.as_str(), "bn-a7x");
331 }
332
333 #[test]
334 fn parse_trims_whitespace() {
335 let id = ItemId::parse(" bn-a7x ").unwrap();
336 assert_eq!(id.as_str(), "bn-a7x");
337 }
338
339 #[test]
340 fn parse_rejects_wrong_prefix() {
341 let err = ItemId::parse("tk-a7x").unwrap_err();
342 assert!(matches!(err, ItemIdError::WrongPrefix { .. }));
343 }
344
345 #[test]
346 fn parse_rejects_invalid_format() {
347 assert!(ItemId::parse("notanid").is_err());
348 assert!(ItemId::parse("bn-").is_err());
349 assert!(ItemId::parse("").is_err());
350 }
351
352 #[test]
353 fn parse_accepts_all_letter_hash() {
354 assert!(ItemId::parse("bn-abcd").is_ok());
356 assert!(ItemId::parse("bn-unwi").is_ok());
357 }
358
359 #[test]
360 fn parse_accepts_longer_hash_with_digit() {
361 let id = ItemId::parse("bn-a7x3q9").unwrap();
362 assert_eq!(id.as_str(), "bn-a7x3q9");
363 }
364
365 #[test]
368 fn display_fromstr_roundtrip() {
369 let id: ItemId = "bn-a7x".parse().unwrap();
370 let rendered = id.to_string();
371 let reparsed: ItemId = rendered.parse().unwrap();
372 assert_eq!(id, reparsed);
373 }
374
375 #[test]
376 fn display_fromstr_roundtrip_child() {
377 let id: ItemId = "bn-a7x.1.3".parse().unwrap();
378 let rendered = id.to_string();
379 let reparsed: ItemId = rendered.parse().unwrap();
380 assert_eq!(id, reparsed);
381 }
382
383 #[test]
386 fn serde_json_roundtrip() {
387 let id = ItemId::parse("bn-a7x.1").unwrap();
388 let json = serde_json::to_string(&id).unwrap();
389 assert_eq!(json, "\"bn-a7x.1\"");
390 let deser: ItemId = serde_json::from_str(&json).unwrap();
391 assert_eq!(id, deser);
392 }
393
394 #[test]
395 fn serde_rejects_invalid() {
396 let result = serde_json::from_str::<ItemId>("\"notvalid\"");
397 assert!(result.is_err());
398 }
399
400 #[test]
403 fn child_creates_valid_id() {
404 let parent = ItemId::parse("bn-a7x").unwrap();
405 let child = parent.child(1);
406 assert_eq!(child.as_str(), "bn-a7x.1");
407 assert!(child.is_child());
408 }
409
410 #[test]
411 fn grandchild_creation() {
412 let root = ItemId::parse("bn-a7x").unwrap();
413 let child = root.child(1);
414 let grandchild = child.child(3);
415 assert_eq!(grandchild.as_str(), "bn-a7x.1.3");
416 assert_eq!(grandchild.depth(), 2);
417 }
418
419 #[test]
420 fn parent_of_root_is_none() {
421 let root = ItemId::parse("bn-a7x").unwrap();
422 assert!(root.parent().is_none());
423 }
424
425 #[test]
426 fn parent_of_child() {
427 let child = ItemId::parse("bn-a7x.1").unwrap();
428 let parent = child.parent().unwrap();
429 assert_eq!(parent.as_str(), "bn-a7x");
430 }
431
432 #[test]
433 fn parent_of_grandchild() {
434 let gc = ItemId::parse("bn-a7x.1.3").unwrap();
435 let parent = gc.parent().unwrap();
436 assert_eq!(parent.as_str(), "bn-a7x.1");
437 }
438
439 #[test]
440 fn is_child_of_works() {
441 let root = ItemId::parse("bn-a7x").unwrap();
442 let child = ItemId::parse("bn-a7x.1").unwrap();
443 let gc = ItemId::parse("bn-a7x.1.3").unwrap();
444
445 assert!(child.is_child_of(&root));
446 assert!(gc.is_child_of(&root));
447 assert!(gc.is_child_of(&child));
448 assert!(!root.is_child_of(&child));
449 assert!(!child.is_child_of(&gc));
450 }
451
452 #[test]
455 fn generate_produces_valid_id() {
456 let id = generate_item_id("my test item", 0, |_| false);
457 assert!(id.as_str().starts_with("bn-"));
458 assert!(id.is_root());
459 let reparsed = ItemId::parse(id.as_str()).unwrap();
461 assert_eq!(id, reparsed);
462 }
463
464 #[test]
465 fn generate_deterministic_with_same_seed() {
466 let id1 = generate_item_id("seed-abc", 0, |_| false);
467 let id2 = generate_item_id("seed-abc", 0, |_| false);
468 assert_eq!(id1, id2);
469 }
470
471 #[test]
472 fn generate_different_seeds_different_ids() {
473 let id1 = generate_item_id("seed-one", 0, |_| false);
474 let id2 = generate_item_id("seed-two", 0, |_| false);
475 assert_ne!(id1, id2);
476 }
477
478 #[test]
479 fn generate_avoids_collisions() {
480 let mut taken: HashSet<String> = HashSet::new();
481
482 for i in 0..20 {
483 let id = generate_item_id(&format!("item-{i}"), taken.len(), |candidate| {
484 taken.contains(candidate)
485 });
486 assert!(
487 taken.insert(id.as_str().to_string()),
488 "collision on iteration {i}: {}",
489 id
490 );
491 }
492
493 assert_eq!(taken.len(), 20);
494 }
495
496 #[test]
497 fn generate_adaptive_length_grows() {
498 let generator = bones_id_generator();
500 let short = generator.optimal_length(0);
501 assert_eq!(short, 3);
502
503 let long = generator.optimal_length(100_000);
505 assert!(long > short, "expected adaptive growth: {long} > {short}");
506 }
507
508 #[test]
511 fn resolve_exact() {
512 let known = vec!["bn-a7x".to_string(), "bn-b8y".to_string()];
513 let id = resolve_item_id(
514 "bn-a7x",
515 |candidate| known.contains(&candidate.to_string()),
516 |_sub| vec![],
517 )
518 .unwrap();
519 assert_eq!(id.as_str(), "bn-a7x");
520 }
521
522 #[test]
523 fn resolve_bare_hash() {
524 let known = vec!["bn-a7x".to_string()];
525 let id = resolve_item_id(
526 "a7x",
527 |candidate| known.contains(&candidate.to_string()),
528 |_sub| vec![],
529 )
530 .unwrap();
531 assert_eq!(id.as_str(), "bn-a7x");
532 }
533
534 #[test]
535 fn resolve_substring() {
536 let known = vec!["bn-a7x".to_string(), "bn-b8y".to_string()];
537 let id = resolve_item_id(
538 "a7",
539 |candidate| known.contains(&candidate.to_string()),
540 |sub| {
541 known
542 .iter()
543 .filter(|id| id.split('-').last().is_some_and(|hash| hash.contains(sub)))
544 .cloned()
545 .collect()
546 },
547 )
548 .unwrap();
549 assert_eq!(id.as_str(), "bn-a7x");
550 }
551
552 #[test]
553 fn resolve_ambiguous() {
554 let known = vec!["bn-a7x".to_string(), "bn-a7y".to_string()];
555 let err = resolve_item_id(
556 "a7",
557 |candidate| known.contains(&candidate.to_string()),
558 |sub| {
559 known
560 .iter()
561 .filter(|id| id.split('-').last().is_some_and(|hash| hash.contains(sub)))
562 .cloned()
563 .collect()
564 },
565 )
566 .unwrap_err();
567 assert!(matches!(err, ItemIdError::Ambiguous { .. }));
568 }
569
570 #[test]
571 fn resolve_not_found() {
572 let err = resolve_item_id("zzz", |_| false, |_| vec![]).unwrap_err();
573 assert!(matches!(err, ItemIdError::NotFound(_)));
574 }
575
576 #[test]
579 fn ordering_is_lexicographic() {
580 let a = ItemId::parse("bn-a7x").unwrap();
581 let b = ItemId::parse("bn-b8y").unwrap();
582 assert!(a < b);
583 }
584
585 #[test]
586 fn hash_set_deduplication() {
587 let id1 = ItemId::parse("bn-a7x").unwrap();
588 let id2 = ItemId::parse("bn-a7x").unwrap();
589 let mut set = HashSet::new();
590 set.insert(id1);
591 set.insert(id2);
592 assert_eq!(set.len(), 1);
593 }
594
595 #[test]
598 fn as_ref_str() {
599 let id = ItemId::parse("bn-a7x").unwrap();
600 let s: &str = id.as_ref();
601 assert_eq!(s, "bn-a7x");
602 }
603
604 #[test]
605 fn into_string() {
606 let id = ItemId::parse("bn-a7x").unwrap();
607 let s: String = id.into();
608 assert_eq!(s, "bn-a7x");
609 }
610
611 #[test]
614 fn new_unchecked_trusts_caller() {
615 let id = ItemId::new_unchecked("bn-a7x");
616 assert_eq!(id.as_str(), "bn-a7x");
617 }
618
619 #[test]
622 fn error_display() {
623 let e = ItemIdError::InvalidFormat("bad".into());
624 assert!(e.to_string().contains("bad"));
625
626 let e = ItemIdError::WrongPrefix {
627 expected: "bn".into(),
628 found: "tk".into(),
629 };
630 assert!(e.to_string().contains("bn"));
631 assert!(e.to_string().contains("tk"));
632
633 let e = ItemIdError::Ambiguous {
634 partial: "a7".into(),
635 matches: vec!["bn-a7x".into(), "bn-a7y".into()],
636 };
637 assert!(e.to_string().contains("a7"));
638
639 let e = ItemIdError::NotFound("zzz".into());
640 assert!(e.to_string().contains("zzz"));
641 }
642}