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        // 4-char hash without digit → invalid format per terseid rules
351        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    // === FromStr / Display round-trip =======================================
361
362    #[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    // === Serde round-trip ===================================================
379
380    #[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    // === Child / parent =====================================================
396
397    #[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    // === Generation =========================================================
448
449    #[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        // Should parse back successfully
455        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        // With 0 items, should use short hash (min_hash_length = 3)
494        let generator = bones_id_generator();
495        let short = generator.optimal_length(0);
496        assert_eq!(short, 3);
497
498        // With many items, should use longer hash
499        let long = generator.optimal_length(100_000);
500        assert!(long > short, "expected adaptive growth: {long} > {short}");
501    }
502
503    // === Resolution =========================================================
504
505    #[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    // === Ordering & Hash ====================================================
572
573    #[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    // === AsRef / Into =======================================================
591
592    #[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    // === new_unchecked ======================================================
607
608    #[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    // === Error display ======================================================
615
616    #[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}