Skip to main content

bones_core/model/
item_id.rs

1//! Validated identifier type for work items.
2//!
3//! All work items in bones carry a `bn-XXXX` identifier generated by the
4//! [`terseid`] library.  `ItemId` is a validated newtype that wraps the
5//! raw string and delegates to `terseid` for generation, parsing, and
6//! resolution.
7
8use 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
15/// The prefix used for all bones work-item identifiers.
16pub const BONES_PREFIX: &str = "bn";
17
18// ---------------------------------------------------------------------------
19// Core type
20// ---------------------------------------------------------------------------
21
22/// A validated work-item identifier (e.g. `bn-a7x`, `bn-a7x.1`).
23///
24/// The inner string is guaranteed to be a valid terseid ID with the `bn` prefix.
25/// Construction goes through [`FromStr`], [`ItemId::new_unchecked`], or the
26/// generator helpers.
27#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
28#[serde(try_from = "String", into = "String")]
29pub struct ItemId(String);
30
31// ---------------------------------------------------------------------------
32// Error type
33// ---------------------------------------------------------------------------
34
35/// Errors that arise when constructing or resolving an [`ItemId`].
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ItemIdError {
38    /// The raw string does not match the `<prefix>-<hash>[.<child>…]` pattern.
39    InvalidFormat(String),
40    /// The ID has a valid format but the wrong prefix.
41    WrongPrefix { expected: String, found: String },
42    /// Partial input matched more than one existing ID.
43    Ambiguous {
44        partial: String,
45        matches: Vec<String>,
46    },
47    /// Partial input matched zero existing IDs.
48    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
68// ---------------------------------------------------------------------------
69// Construction & validation
70// ---------------------------------------------------------------------------
71
72impl ItemId {
73    /// Create an `ItemId` without validation.
74    ///
75    /// # Safety (logical)
76    ///
77    /// The caller **must** ensure `raw` is a valid terseid ID with the correct
78    /// prefix.  Prefer [`FromStr`] or the generator helpers in production code.
79    #[must_use]
80    pub fn new_unchecked(raw: impl Into<String>) -> Self {
81        Self(raw.into())
82    }
83
84    /// Parse and validate a raw string into an `ItemId`.
85    ///
86    /// The string is lower-cased and trimmed before validation.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`ItemIdError::InvalidFormat`] if the format is wrong, or
91    /// [`ItemIdError::WrongPrefix`] if the prefix is not `bn`.
92    pub fn parse(raw: &str) -> Result<Self, ItemIdError> {
93        let normalized = raw.trim().to_lowercase();
94
95        // Validate via terseid
96        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    /// Parse any valid terseid ID regardless of prefix.
110    ///
111    /// Used by the event parser and migration paths where IDs from external
112    /// systems (e.g. beads with custom prefixes) must be accepted.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`ItemIdError::InvalidFormat`] if the string is not a valid
117    /// terseid ID.
118    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    /// Return the raw ID string.
126    #[must_use]
127    pub fn as_str(&self) -> &str {
128        &self.0
129    }
130
131    /// Parse the inner string into a terseid [`ParsedId`].
132    ///
133    /// This never fails because `ItemId` guarantees validity at construction.
134    ///
135    /// # Panics
136    ///
137    /// Panics if the internal invariant is broken (the stored string is not a
138    /// valid terseid ID).  This should never happen through the public API.
139    #[must_use]
140    pub fn parsed(&self) -> ParsedId {
141        parse_id(&self.0).expect("ItemId invariant broken")
142    }
143
144    /// Return `true` if this is a root ID (no child path segments).
145    #[must_use]
146    pub fn is_root(&self) -> bool {
147        !terseid_is_child_id(&self.0)
148    }
149
150    /// Return `true` if this is a child ID (has child path segments).
151    #[must_use]
152    pub fn is_child(&self) -> bool {
153        terseid_is_child_id(&self.0)
154    }
155
156    /// Depth of the child path (0 for root IDs).
157    #[must_use]
158    pub fn depth(&self) -> usize {
159        terseid_id_depth(&self.0)
160    }
161
162    /// Create a child ID by appending a child number.
163    ///
164    /// ```
165    /// # use bones_core::model::item_id::ItemId;
166    /// let parent = ItemId::parse("bn-a7x").unwrap();
167    /// let child = parent.child(1);
168    /// assert_eq!(child.as_str(), "bn-a7x.1");
169    /// ```
170    #[must_use]
171    pub fn child(&self, number: u32) -> Self {
172        Self(terseid_child_id(&self.0, number))
173    }
174
175    /// Return the parent `ItemId`, or `None` if this is a root.
176    #[must_use]
177    pub fn parent(&self) -> Option<Self> {
178        self.parsed().parent().map(Self)
179    }
180
181    /// Return `true` if `self` is a descendant of `ancestor`.
182    #[must_use]
183    pub fn is_child_of(&self, ancestor: &Self) -> bool {
184        self.parsed().is_child_of(ancestor.as_str())
185    }
186}
187
188// ---------------------------------------------------------------------------
189// Trait implementations
190// ---------------------------------------------------------------------------
191
192impl 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// ---------------------------------------------------------------------------
227// Generator helper
228// ---------------------------------------------------------------------------
229
230/// Create a configured [`IdGenerator`] with the `bn` prefix and sensible
231/// defaults for a bones repository.
232#[must_use]
233pub fn bones_id_generator() -> IdGenerator {
234    IdGenerator::new(IdConfig::new(BONES_PREFIX))
235}
236
237/// Generate a new `ItemId` from a seed and current item count.
238///
239/// `seed` — typically `format!("{title}\0{nonce}")`.
240/// `item_count` — current number of existing items (drives adaptive length).
241/// `exists` — returns `true` if a candidate ID is already taken.
242pub 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
252// ---------------------------------------------------------------------------
253// Resolver helper
254// ---------------------------------------------------------------------------
255
256/// Resolve partial CLI input into a full `ItemId`.
257///
258/// Resolution order (delegated to [`terseid::IdResolver`]):
259/// 1. Exact match (case-insensitive)
260/// 2. Prefix normalisation — bare hash `a7x` becomes `bn-a7x`
261/// 3. Substring match on the hash portion
262///
263/// `exists` — returns `true` if a full ID exists.
264/// `substring_match` — returns all full IDs whose hash contains the input.
265///
266/// # Errors
267///
268/// * [`ItemIdError::Ambiguous`] — substring matched more than one ID.
269/// * [`ItemIdError::NotFound`] — no match at all.
270pub 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    // The resolved ID came from the known-good set, so it is valid.
289    Ok(ItemId(result.id))
290}
291
292// ---------------------------------------------------------------------------
293// Tests
294// ---------------------------------------------------------------------------
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use std::collections::HashSet;
300
301    // === Construction & validation ==========================================
302
303    #[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        // All-letter 4+ char hashes are valid (backward compat with old terseid)
355        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    // === FromStr / Display round-trip =======================================
366
367    #[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    // === Serde round-trip ===================================================
384
385    #[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    // === Child / parent =====================================================
401
402    #[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    // === Generation =========================================================
453
454    #[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        // Should parse back successfully
460        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        // With 0 items, should use short hash (min_hash_length = 3)
499        let generator = bones_id_generator();
500        let short = generator.optimal_length(0);
501        assert_eq!(short, 3);
502
503        // With many items, should use longer hash
504        let long = generator.optimal_length(100_000);
505        assert!(long > short, "expected adaptive growth: {long} > {short}");
506    }
507
508    // === Resolution =========================================================
509
510    #[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    // === Ordering & Hash ====================================================
577
578    #[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    // === AsRef / Into =======================================================
596
597    #[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    // === new_unchecked ======================================================
612
613    #[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    // === Error display ======================================================
620
621    #[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}