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 assert!(ItemId::parse("bn-abcd").is_err());
352 }
353
354 #[test]
355 fn parse_accepts_longer_hash_with_digit() {
356 let id = ItemId::parse("bn-a7x3q9").unwrap();
357 assert_eq!(id.as_str(), "bn-a7x3q9");
358 }
359
360 #[test]
363 fn display_fromstr_roundtrip() {
364 let id: ItemId = "bn-a7x".parse().unwrap();
365 let rendered = id.to_string();
366 let reparsed: ItemId = rendered.parse().unwrap();
367 assert_eq!(id, reparsed);
368 }
369
370 #[test]
371 fn display_fromstr_roundtrip_child() {
372 let id: ItemId = "bn-a7x.1.3".parse().unwrap();
373 let rendered = id.to_string();
374 let reparsed: ItemId = rendered.parse().unwrap();
375 assert_eq!(id, reparsed);
376 }
377
378 #[test]
381 fn serde_json_roundtrip() {
382 let id = ItemId::parse("bn-a7x.1").unwrap();
383 let json = serde_json::to_string(&id).unwrap();
384 assert_eq!(json, "\"bn-a7x.1\"");
385 let deser: ItemId = serde_json::from_str(&json).unwrap();
386 assert_eq!(id, deser);
387 }
388
389 #[test]
390 fn serde_rejects_invalid() {
391 let result = serde_json::from_str::<ItemId>("\"notvalid\"");
392 assert!(result.is_err());
393 }
394
395 #[test]
398 fn child_creates_valid_id() {
399 let parent = ItemId::parse("bn-a7x").unwrap();
400 let child = parent.child(1);
401 assert_eq!(child.as_str(), "bn-a7x.1");
402 assert!(child.is_child());
403 }
404
405 #[test]
406 fn grandchild_creation() {
407 let root = ItemId::parse("bn-a7x").unwrap();
408 let child = root.child(1);
409 let grandchild = child.child(3);
410 assert_eq!(grandchild.as_str(), "bn-a7x.1.3");
411 assert_eq!(grandchild.depth(), 2);
412 }
413
414 #[test]
415 fn parent_of_root_is_none() {
416 let root = ItemId::parse("bn-a7x").unwrap();
417 assert!(root.parent().is_none());
418 }
419
420 #[test]
421 fn parent_of_child() {
422 let child = ItemId::parse("bn-a7x.1").unwrap();
423 let parent = child.parent().unwrap();
424 assert_eq!(parent.as_str(), "bn-a7x");
425 }
426
427 #[test]
428 fn parent_of_grandchild() {
429 let gc = ItemId::parse("bn-a7x.1.3").unwrap();
430 let parent = gc.parent().unwrap();
431 assert_eq!(parent.as_str(), "bn-a7x.1");
432 }
433
434 #[test]
435 fn is_child_of_works() {
436 let root = ItemId::parse("bn-a7x").unwrap();
437 let child = ItemId::parse("bn-a7x.1").unwrap();
438 let gc = ItemId::parse("bn-a7x.1.3").unwrap();
439
440 assert!(child.is_child_of(&root));
441 assert!(gc.is_child_of(&root));
442 assert!(gc.is_child_of(&child));
443 assert!(!root.is_child_of(&child));
444 assert!(!child.is_child_of(&gc));
445 }
446
447 #[test]
450 fn generate_produces_valid_id() {
451 let id = generate_item_id("my test item", 0, |_| false);
452 assert!(id.as_str().starts_with("bn-"));
453 assert!(id.is_root());
454 let reparsed = ItemId::parse(id.as_str()).unwrap();
456 assert_eq!(id, reparsed);
457 }
458
459 #[test]
460 fn generate_deterministic_with_same_seed() {
461 let id1 = generate_item_id("seed-abc", 0, |_| false);
462 let id2 = generate_item_id("seed-abc", 0, |_| false);
463 assert_eq!(id1, id2);
464 }
465
466 #[test]
467 fn generate_different_seeds_different_ids() {
468 let id1 = generate_item_id("seed-one", 0, |_| false);
469 let id2 = generate_item_id("seed-two", 0, |_| false);
470 assert_ne!(id1, id2);
471 }
472
473 #[test]
474 fn generate_avoids_collisions() {
475 let mut taken: HashSet<String> = HashSet::new();
476
477 for i in 0..20 {
478 let id = generate_item_id(&format!("item-{i}"), taken.len(), |candidate| {
479 taken.contains(candidate)
480 });
481 assert!(
482 taken.insert(id.as_str().to_string()),
483 "collision on iteration {i}: {}",
484 id
485 );
486 }
487
488 assert_eq!(taken.len(), 20);
489 }
490
491 #[test]
492 fn generate_adaptive_length_grows() {
493 let generator = bones_id_generator();
495 let short = generator.optimal_length(0);
496 assert_eq!(short, 3);
497
498 let long = generator.optimal_length(100_000);
500 assert!(long > short, "expected adaptive growth: {long} > {short}");
501 }
502
503 #[test]
506 fn resolve_exact() {
507 let known = vec!["bn-a7x".to_string(), "bn-b8y".to_string()];
508 let id = resolve_item_id(
509 "bn-a7x",
510 |candidate| known.contains(&candidate.to_string()),
511 |_sub| vec![],
512 )
513 .unwrap();
514 assert_eq!(id.as_str(), "bn-a7x");
515 }
516
517 #[test]
518 fn resolve_bare_hash() {
519 let known = vec!["bn-a7x".to_string()];
520 let id = resolve_item_id(
521 "a7x",
522 |candidate| known.contains(&candidate.to_string()),
523 |_sub| vec![],
524 )
525 .unwrap();
526 assert_eq!(id.as_str(), "bn-a7x");
527 }
528
529 #[test]
530 fn resolve_substring() {
531 let known = vec!["bn-a7x".to_string(), "bn-b8y".to_string()];
532 let id = resolve_item_id(
533 "a7",
534 |candidate| known.contains(&candidate.to_string()),
535 |sub| {
536 known
537 .iter()
538 .filter(|id| id.split('-').last().is_some_and(|hash| hash.contains(sub)))
539 .cloned()
540 .collect()
541 },
542 )
543 .unwrap();
544 assert_eq!(id.as_str(), "bn-a7x");
545 }
546
547 #[test]
548 fn resolve_ambiguous() {
549 let known = vec!["bn-a7x".to_string(), "bn-a7y".to_string()];
550 let err = resolve_item_id(
551 "a7",
552 |candidate| known.contains(&candidate.to_string()),
553 |sub| {
554 known
555 .iter()
556 .filter(|id| id.split('-').last().is_some_and(|hash| hash.contains(sub)))
557 .cloned()
558 .collect()
559 },
560 )
561 .unwrap_err();
562 assert!(matches!(err, ItemIdError::Ambiguous { .. }));
563 }
564
565 #[test]
566 fn resolve_not_found() {
567 let err = resolve_item_id("zzz", |_| false, |_| vec![]).unwrap_err();
568 assert!(matches!(err, ItemIdError::NotFound(_)));
569 }
570
571 #[test]
574 fn ordering_is_lexicographic() {
575 let a = ItemId::parse("bn-a7x").unwrap();
576 let b = ItemId::parse("bn-b8y").unwrap();
577 assert!(a < b);
578 }
579
580 #[test]
581 fn hash_set_deduplication() {
582 let id1 = ItemId::parse("bn-a7x").unwrap();
583 let id2 = ItemId::parse("bn-a7x").unwrap();
584 let mut set = HashSet::new();
585 set.insert(id1);
586 set.insert(id2);
587 assert_eq!(set.len(), 1);
588 }
589
590 #[test]
593 fn as_ref_str() {
594 let id = ItemId::parse("bn-a7x").unwrap();
595 let s: &str = id.as_ref();
596 assert_eq!(s, "bn-a7x");
597 }
598
599 #[test]
600 fn into_string() {
601 let id = ItemId::parse("bn-a7x").unwrap();
602 let s: String = id.into();
603 assert_eq!(s, "bn-a7x");
604 }
605
606 #[test]
609 fn new_unchecked_trusts_caller() {
610 let id = ItemId::new_unchecked("bn-a7x");
611 assert_eq!(id.as_str(), "bn-a7x");
612 }
613
614 #[test]
617 fn error_display() {
618 let e = ItemIdError::InvalidFormat("bad".into());
619 assert!(e.to_string().contains("bad"));
620
621 let e = ItemIdError::WrongPrefix {
622 expected: "bn".into(),
623 found: "tk".into(),
624 };
625 assert!(e.to_string().contains("bn"));
626 assert!(e.to_string().contains("tk"));
627
628 let e = ItemIdError::Ambiguous {
629 partial: "a7".into(),
630 matches: vec!["bn-a7x".into(), "bn-a7y".into()],
631 };
632 assert!(e.to_string().contains("a7"));
633
634 let e = ItemIdError::NotFound("zzz".into());
635 assert!(e.to_string().contains("zzz"));
636 }
637}