use chrono::{DateTime, Utc};
use sha2::{Digest, Sha256};
pub const MAX_ID_PREFIX_LEN: usize = 64;
pub const MAX_ID_HASH_LEN: usize = 40;
pub const MAX_ID_LENGTH: usize = MAX_ID_PREFIX_LEN + 1 + MAX_ID_HASH_LEN;
#[derive(Debug, Clone)]
pub struct IdConfig {
pub prefix: String,
pub min_hash_length: usize,
pub max_hash_length: usize,
pub max_collision_prob: f64,
}
impl Default for IdConfig {
fn default() -> Self {
Self {
prefix: "br".to_string(),
min_hash_length: 3,
max_hash_length: 8,
max_collision_prob: 0.25,
}
}
}
impl IdConfig {
#[must_use]
pub fn with_prefix(prefix: impl Into<String>) -> Self {
Self {
prefix: normalize_prefix(&prefix.into()),
..Default::default()
}
}
}
#[derive(Debug, Clone)]
pub struct IdGenerator {
config: IdConfig,
}
impl IdGenerator {
#[must_use]
pub const fn new(config: IdConfig) -> Self {
Self { config }
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(IdConfig::default())
}
#[must_use]
pub fn prefix(&self) -> &str {
&self.config.prefix
}
#[must_use]
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap
)]
pub fn optimal_length(&self, issue_count: usize) -> usize {
let n = issue_count as f64;
let max_prob = self.config.max_collision_prob;
for len in self.config.min_hash_length..=self.config.max_hash_length {
let space = 36_f64.powi(len as i32);
let prob = 1.0 - (-n * n / (2.0 * space)).exp();
if prob < max_prob {
return len;
}
}
self.config.max_hash_length
}
#[must_use]
pub fn generate_candidate(
&self,
title: &str,
description: Option<&str>,
creator: Option<&str>,
created_at: DateTime<Utc>,
nonce: u32,
hash_length: usize,
) -> String {
let seed = generate_id_seed(title, description, creator, created_at, nonce);
let hash_str = compute_id_hash(&seed, hash_length);
format!("{}-{hash_str}", self.config.prefix)
}
pub fn generate<F>(
&self,
title: &str,
description: Option<&str>,
creator: Option<&str>,
created_at: DateTime<Utc>,
issue_count: usize,
exists: F,
) -> String
where
F: Fn(&str) -> bool,
{
let mut length = self.optimal_length(issue_count);
loop {
for nonce in 0..10 {
let id =
self.generate_candidate(title, description, creator, created_at, nonce, length);
if !exists(&id) {
return id;
}
}
if length < self.config.max_hash_length {
length += 1;
} else {
let mut nonce = 0;
loop {
let seed = generate_id_seed(title, description, creator, created_at, nonce);
let hash_str = compute_id_hash(&seed, 12);
let id = format!("{}-{hash_str}", self.config.prefix);
if !exists(&id) {
return id;
}
nonce += 1;
if nonce > 1000 {
let desperate_id = format!("{}-{hash_str}{nonce}", self.config.prefix);
if !exists(&desperate_id) {
return desperate_id;
}
}
if nonce > 2000 {
return format!("{}-{hash_str}{nonce}", self.config.prefix);
}
}
}
}
}
}
#[must_use]
pub fn generate_id_seed(
title: &str,
description: Option<&str>,
creator: Option<&str>,
created_at: DateTime<Utc>,
nonce: u32,
) -> String {
let timestamp = created_at.timestamp_nanos_opt().unwrap_or(0).to_string();
let nonce = nonce.to_string();
let mut seed = String::new();
append_seed_part(&mut seed, title);
append_seed_part(&mut seed, description.unwrap_or(""));
append_seed_part(&mut seed, creator.unwrap_or(""));
append_seed_part(&mut seed, ×tamp);
append_seed_part(&mut seed, &nonce);
seed
}
fn append_seed_part(seed: &mut String, value: &str) {
use std::fmt::Write;
write!(seed, "{}:", value.len()).expect("writing to String never fails");
seed.push_str(value);
}
#[must_use]
pub fn compute_id_hash(input: &str, length: usize) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let result = hasher.finalize();
let mut num = 0u64;
for &byte in result.iter().take(8) {
num = (num << 8) | u64::from(byte);
}
let encoded = base36_encode(num);
let mut s = encoded;
if s.len() < length {
s = format!("{s:0>length$}");
}
let start = s.len().saturating_sub(length);
s.chars().skip(start).collect()
}
fn base36_encode(mut num: u64) -> String {
const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
if num == 0 {
return "0".to_string();
}
let mut chars = Vec::new();
while num > 0 {
chars.push(ALPHABET[(num % 36) as usize] as char);
num /= 36;
}
chars.into_iter().rev().collect()
}
#[must_use]
pub fn child_id(parent_id: &str, child_number: u32) -> String {
format!("{parent_id}.{child_number}")
}
fn issue_id_separator(id: &str) -> Option<usize> {
id.rfind('-')
}
pub(crate) fn split_prefix_remainder(id: &str) -> Option<(&str, &str)> {
let dash_pos = issue_id_separator(id)?;
let (prefix, remainder_with_dash) = id.split_at(dash_pos);
let remainder = remainder_with_dash.strip_prefix('-')?;
if prefix.is_empty() || remainder.is_empty() {
return None;
}
Some((prefix, remainder))
}
#[must_use]
pub fn is_child_id(id: &str) -> bool {
split_prefix_remainder(id).map_or_else(
|| id.contains('.'),
|(_, remainder)| remainder.contains('.'),
)
}
#[must_use]
pub fn id_depth(id: &str) -> usize {
split_prefix_remainder(id).map_or_else(
|| id.matches('.').count(),
|(_, remainder)| remainder.matches('.').count(),
)
}
#[must_use]
pub fn generate_id(
title: &str,
description: Option<&str>,
creator: Option<&str>,
created_at: DateTime<Utc>,
) -> String {
let generator = IdGenerator::with_defaults();
generator.generate(title, description, creator, created_at, 0, |_| false)
}
use crate::error::{BeadsError, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedId {
pub prefix: String,
pub hash: String,
pub child_path: Vec<u32>,
}
impl ParsedId {
#[must_use]
pub fn is_root(&self) -> bool {
self.child_path.is_empty()
}
#[must_use]
pub fn depth(&self) -> usize {
self.child_path.len()
}
#[must_use]
pub fn parent(&self) -> Option<String> {
if self.child_path.is_empty() {
return None;
}
let mut parent_path = self.child_path.clone();
parent_path.pop();
if parent_path.is_empty() {
Some(format!("{}-{}", self.prefix, self.hash))
} else {
let path_str = format_child_path(&parent_path);
Some(format!("{}-{}{}", self.prefix, self.hash, path_str))
}
}
#[must_use]
pub fn to_id_string(&self) -> String {
if self.child_path.is_empty() {
format!("{}-{}", self.prefix, self.hash)
} else {
let path_str = format_child_path(&self.child_path);
format!("{}-{}{}", self.prefix, self.hash, path_str)
}
}
#[must_use]
pub fn is_child_of(&self, potential_parent: &str) -> bool {
let full_id = self.to_id_string();
full_id.starts_with(potential_parent)
&& full_id.len() > potential_parent.len()
&& full_id[potential_parent.len()..].starts_with('.')
}
}
fn format_child_path(path: &[u32]) -> String {
let mut out = String::new();
for segment in path {
use std::fmt::Write;
let _ = write!(out, ".{segment}");
}
out
}
pub fn parse_id(id: &str) -> Result<ParsedId> {
let Some((prefix, remainder)) = split_prefix_remainder(id) else {
return Err(BeadsError::InvalidId { id: id.to_string() });
};
if prefix.is_empty() || prefix.len() > MAX_ID_PREFIX_LEN {
return Err(BeadsError::InvalidId { id: id.to_string() });
}
if !prefix.chars().all(|c| {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
|| c == '_'
|| c == '-'
|| c == '.'
|| c == ':'
|| c == '#'
}) {
return Err(BeadsError::InvalidId { id: id.to_string() });
}
let parts: Vec<&str> = remainder.split('.').collect();
let hash = parts[0].to_string();
if hash.is_empty() || hash.len() > MAX_ID_HASH_LEN {
return Err(BeadsError::InvalidId { id: id.to_string() });
}
if !hash
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
{
return Err(BeadsError::InvalidId { id: id.to_string() });
}
let mut child_path = Vec::new();
for part in parts.iter().skip(1) {
if part.is_empty() || !part.chars().all(|c| c.is_ascii_digit()) {
return Err(BeadsError::InvalidId { id: id.to_string() });
}
match part.parse::<u32>() {
Ok(n) => child_path.push(n),
Err(_) => return Err(BeadsError::InvalidId { id: id.to_string() }),
}
}
Ok(ParsedId {
prefix: prefix.to_string(),
hash,
child_path,
})
}
pub fn validate_prefix(id: &str, expected_prefix: &str, allowed_prefixes: &[String]) -> Result<()> {
let parsed = parse_id(id)?;
if parsed.prefix == expected_prefix {
return Ok(());
}
if allowed_prefixes.contains(&parsed.prefix) {
return Ok(());
}
Err(BeadsError::PrefixMismatch {
expected: expected_prefix.to_string(),
found: parsed.prefix,
})
}
#[must_use]
pub fn normalize_id(id: &str) -> String {
id.to_lowercase()
}
#[must_use]
pub fn normalize_prefix(prefix: &str) -> String {
let normalized: String = prefix
.trim()
.chars()
.filter_map(|c| {
let normalized = c.to_ascii_lowercase();
(normalized.is_ascii_lowercase()
|| normalized.is_ascii_digit()
|| matches!(normalized, '_' | '-' | '.' | ':' | '#'))
.then_some(normalized)
})
.take(MAX_ID_PREFIX_LEN)
.collect();
let normalized = normalized
.trim_end_matches(['_', '-', '.', ':', '#'])
.to_string();
if normalized.is_empty() {
"br".to_string()
} else {
normalized
}
}
#[must_use]
pub fn abbreviate_prefix(prefix: &str) -> String {
let normalized = normalize_prefix(prefix);
if normalized.len() <= 6 {
return normalized;
}
let segments: Vec<&str> = normalized
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|segment| !segment.is_empty())
.collect();
if segments.len() > 1 {
let abbrev: String = segments
.iter()
.filter_map(|segment| segment.chars().next())
.collect();
if abbrev.len() > 1 {
return abbrev;
}
}
let fallback: String = normalized
.chars()
.filter(char::is_ascii_alphanumeric)
.take(3)
.collect();
if fallback.is_empty() {
normalized
} else {
fallback
}
}
#[must_use]
pub fn is_valid_id_format(id: &str) -> bool {
parse_id(id).is_ok()
}
#[derive(Debug, Clone)]
pub struct ResolverConfig {
pub default_prefix: String,
pub allowed_prefixes: Vec<String>,
pub allow_substring_match: bool,
}
impl Default for ResolverConfig {
fn default() -> Self {
Self {
default_prefix: "br".to_string(),
allowed_prefixes: Vec::new(),
allow_substring_match: true,
}
}
}
impl ResolverConfig {
#[must_use]
pub fn with_prefix(prefix: impl Into<String>) -> Self {
Self {
default_prefix: normalize_prefix(&prefix.into()),
..Default::default()
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedId {
pub id: String,
pub match_type: MatchType,
pub original_input: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchType {
Exact,
PrefixNormalized,
Substring,
}
#[derive(Debug, Clone)]
pub struct IdResolver {
config: ResolverConfig,
}
impl IdResolver {
#[must_use]
pub const fn new(config: ResolverConfig) -> Self {
Self { config }
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(ResolverConfig::default())
}
#[must_use]
pub fn with_prefix(prefix: impl Into<String>) -> Self {
Self::new(ResolverConfig::with_prefix(prefix))
}
#[must_use]
pub fn default_prefix(&self) -> &str {
&self.config.default_prefix
}
pub fn resolve<F, G>(
&self,
input: &str,
exists_fn: F,
substring_match_fn: G,
) -> Result<ResolvedId>
where
F: Fn(&str) -> bool,
G: Fn(&str) -> Vec<String>,
{
let input = input.trim();
if input.is_empty() {
return Err(BeadsError::InvalidId { id: String::new() });
}
let normalized = normalize_id(input);
if exists_fn(&normalized) {
return Ok(ResolvedId {
id: normalized,
match_type: MatchType::Exact,
original_input: input.to_string(),
});
}
if !normalized.contains('-') {
let with_prefix = format!("{}-{}", self.config.default_prefix, normalized);
if exists_fn(&with_prefix) {
return Ok(ResolvedId {
id: with_prefix,
match_type: MatchType::PrefixNormalized,
original_input: input.to_string(),
});
}
}
if self.config.allow_substring_match {
let (prefix, hash_pattern) = split_prefix_remainder(&normalized)
.map_or((None, normalized.as_str()), |(p, r)| (Some(p), r));
if !hash_pattern.is_empty() {
let mut matches = substring_match_fn(hash_pattern);
if let Some(p) = prefix {
let expected_prefix = format!("{p}-");
matches.retain(|id| id.starts_with(&expected_prefix));
}
match matches.len() {
0 => {
}
1 => {
return Ok(ResolvedId {
id: matches.into_iter().next().unwrap_or_default(),
match_type: MatchType::Substring,
original_input: input.to_string(),
});
}
_ => {
return Err(BeadsError::AmbiguousId {
partial: input.to_string(),
matches,
});
}
}
}
}
Err(BeadsError::IssueNotFound {
id: input.to_string(),
})
}
pub fn resolve_fallible<F, G>(
&self,
input: &str,
exists_fn: F,
substring_match_fn: G,
) -> Result<ResolvedId>
where
F: Fn(&str) -> Result<bool>,
G: Fn(&str) -> Result<Vec<String>>,
{
let input = input.trim();
if input.is_empty() {
return Err(BeadsError::InvalidId { id: String::new() });
}
let normalized = normalize_id(input);
if exists_fn(&normalized)? {
return Ok(ResolvedId {
id: normalized,
match_type: MatchType::Exact,
original_input: input.to_string(),
});
}
if !normalized.contains('-') {
let with_prefix = format!("{}-{}", self.config.default_prefix, normalized);
if exists_fn(&with_prefix)? {
return Ok(ResolvedId {
id: with_prefix,
match_type: MatchType::PrefixNormalized,
original_input: input.to_string(),
});
}
}
if self.config.allow_substring_match {
let (prefix, hash_pattern) = split_prefix_remainder(&normalized)
.map_or((None, normalized.as_str()), |(p, r)| (Some(p), r));
if !hash_pattern.is_empty() {
let mut matches = substring_match_fn(hash_pattern)?;
if let Some(p) = prefix {
let expected_prefix = format!("{p}-");
matches.retain(|id| id.starts_with(&expected_prefix));
}
match matches.len() {
0 => {}
1 => {
return Ok(ResolvedId {
id: matches.into_iter().next().unwrap_or_default(),
match_type: MatchType::Substring,
original_input: input.to_string(),
});
}
_ => {
return Err(BeadsError::AmbiguousId {
partial: input.to_string(),
matches,
});
}
}
}
}
Err(BeadsError::IssueNotFound {
id: input.to_string(),
})
}
pub fn resolve_all<F, G>(
&self,
inputs: &[String],
exists_fn: F,
substring_match_fn: G,
) -> Result<Vec<ResolvedId>>
where
F: Fn(&str) -> bool,
G: Fn(&str) -> Vec<String>,
{
inputs
.iter()
.map(|input| self.resolve(input, &exists_fn, &substring_match_fn))
.collect()
}
pub fn resolve_all_fallible<F, G>(
&self,
inputs: &[String],
exists_fn: F,
substring_match_fn: G,
) -> Result<Vec<ResolvedId>>
where
F: Fn(&str) -> Result<bool>,
G: Fn(&str) -> Result<Vec<String>>,
{
inputs
.iter()
.map(|input| self.resolve_fallible(input, &exists_fn, &substring_match_fn))
.collect()
}
}
#[must_use]
pub fn find_matching_ids(all_ids: &[String], hash_substring: &str) -> Vec<String> {
let (search_base, search_child) = match hash_substring.split_once('.') {
Some((base, child)) => (base, Some(child)),
None => (hash_substring, None),
};
all_ids
.iter()
.filter(|id| {
split_prefix_remainder(id).is_some_and(|(_, remainder)| {
let base_hash = remainder.split('.').next().unwrap_or(remainder);
if !base_hash.contains(search_base) {
return false;
}
match search_child {
Some(child) => remainder
.split_once('.')
.is_some_and(|(_, candidate_child)| candidate_child == child),
None => true,
}
})
})
.cloned()
.collect()
}
pub fn resolve_id<F, G>(input: &str, exists_fn: F, substring_match_fn: G) -> Result<String>
where
F: Fn(&str) -> bool,
G: Fn(&str) -> Vec<String>,
{
let resolver = IdResolver::with_defaults();
resolver
.resolve(input, exists_fn, substring_match_fn)
.map(|r| r.id)
}
pub fn resolve_id_fallible<F, G>(input: &str, exists_fn: F, substring_match_fn: G) -> Result<String>
where
F: Fn(&str) -> Result<bool>,
G: Fn(&str) -> Result<Vec<String>>,
{
let resolver = IdResolver::with_defaults();
resolver
.resolve_fallible(input, exists_fn, substring_match_fn)
.map(|r| r.id)
}
#[cfg(test)]
mod tests {
use super::*;
fn mock_db() -> Vec<String> {
vec![
"br-abc123".to_string(),
"br-abd456".to_string(),
"br-xyz789".to_string(),
"br-abc123.1".to_string(), "other-def111".to_string(), ]
}
fn exists_in_mock(id: &str) -> bool {
mock_db().contains(&id.to_string())
}
fn substring_in_mock(pattern: &str) -> Vec<String> {
find_matching_ids(&mock_db(), pattern)
}
#[test]
fn test_resolve_exact_match() {
let resolver = IdResolver::with_defaults();
let result = resolver
.resolve("br-abc123", exists_in_mock, substring_in_mock)
.unwrap();
assert_eq!(result.id, "br-abc123");
assert_eq!(result.match_type, MatchType::Exact);
}
#[test]
fn test_resolve_prefix_normalized() {
let resolver = IdResolver::with_defaults();
let result = resolver
.resolve("abc123", exists_in_mock, substring_in_mock)
.unwrap();
assert_eq!(result.id, "br-abc123");
assert_eq!(result.match_type, MatchType::PrefixNormalized);
}
#[test]
fn test_resolve_substring_match() {
let resolver = IdResolver::with_defaults();
let result = resolver
.resolve("xyz", exists_in_mock, substring_in_mock)
.unwrap();
assert_eq!(result.id, "br-xyz789");
assert_eq!(result.match_type, MatchType::Substring);
}
#[test]
fn test_resolve_ambiguous() {
let resolver = IdResolver::with_defaults();
let result = resolver.resolve("ab", exists_in_mock, substring_in_mock);
assert!(result.is_err());
if let Err(BeadsError::AmbiguousId { partial, matches }) = result {
assert_eq!(partial, "ab");
assert!(matches.contains(&"br-abc123".to_string()));
assert!(matches.contains(&"br-abd456".to_string()));
} else {
unreachable!("Expected AmbiguousId error");
}
}
#[test]
fn test_resolve_not_found() {
let resolver = IdResolver::with_defaults();
let result = resolver.resolve("nonexistent", exists_in_mock, substring_in_mock);
assert!(result.is_err());
if let Err(BeadsError::IssueNotFound { id }) = result {
assert_eq!(id, "nonexistent");
} else {
unreachable!("Expected IssueNotFound error");
}
}
#[test]
fn test_resolve_child_id() {
let resolver = IdResolver::with_defaults();
let result = resolver
.resolve("br-abc123.1", exists_in_mock, substring_in_mock)
.unwrap();
assert_eq!(result.id, "br-abc123.1");
assert_eq!(result.match_type, MatchType::Exact);
}
#[test]
fn test_resolve_case_insensitive() {
let resolver = IdResolver::with_defaults();
let result = resolver
.resolve("BR-ABC123", exists_in_mock, substring_in_mock)
.unwrap();
assert_eq!(result.id, "br-abc123");
}
#[test]
fn test_resolve_with_custom_prefix() {
let custom_db = vec!["proj-aaa111".to_string()];
let exists = |id: &str| custom_db.contains(&id.to_string());
let substring = |pattern: &str| find_matching_ids(&custom_db, pattern);
let resolver = IdResolver::with_prefix("proj");
let result = resolver.resolve("aaa111", exists, substring).unwrap();
assert_eq!(result.id, "proj-aaa111");
assert_eq!(result.match_type, MatchType::PrefixNormalized);
}
#[test]
fn test_resolve_empty_input() {
let resolver = IdResolver::with_defaults();
let result = resolver.resolve("", exists_in_mock, substring_in_mock);
assert!(result.is_err());
}
#[test]
fn test_resolve_whitespace_trimmed() {
let resolver = IdResolver::with_defaults();
let result = resolver
.resolve(" br-abc123 ", exists_in_mock, substring_in_mock)
.unwrap();
assert_eq!(result.id, "br-abc123");
}
#[test]
fn test_resolve_fallible_propagates_lookup_error() {
let resolver = IdResolver::with_defaults();
let result = resolver.resolve_fallible(
"br-abc123",
|_id| Err(BeadsError::Config("lookup failed".to_string())),
|_hash| Ok(Vec::new()),
);
assert!(matches!(result, Err(BeadsError::Config(message)) if message == "lookup failed"));
}
#[test]
fn test_resolve_all_fallible_propagates_lookup_error() {
let resolver = IdResolver::with_defaults();
let inputs = vec!["br-abc123".to_string(), "br-xyz789".to_string()];
let result = resolver.resolve_all_fallible(
&inputs,
|_id| Err(BeadsError::Config("exists lookup failed".to_string())),
|_hash| Ok(Vec::new()),
);
assert!(
matches!(result, Err(BeadsError::Config(message)) if message == "exists lookup failed")
);
}
#[test]
fn test_find_matching_ids_substring() {
let ids = mock_db();
let matches = find_matching_ids(&ids, "abc");
assert!(matches.contains(&"br-abc123".to_string()));
assert!(matches.contains(&"br-abc123.1".to_string()));
}
#[test]
fn test_find_matching_ids_no_match() {
let ids = mock_db();
let matches = find_matching_ids(&ids, "zzz");
assert!(matches.is_empty());
}
#[test]
fn test_base36_encode() {
assert_eq!(base36_encode(0), "0");
assert_eq!(base36_encode(10), "a");
assert_eq!(base36_encode(35), "z");
assert_eq!(base36_encode(36), "10");
}
#[test]
fn test_compute_id_hash_length() {
let input = "test input";
let hash3 = compute_id_hash(input, 3);
assert_eq!(hash3.len(), 3);
let hash8 = compute_id_hash(input, 8);
assert_eq!(hash8.len(), 8);
}
#[test]
fn test_generate_id_seed() {
let now = Utc::now();
let seed = generate_id_seed("title", Some("desc"), Some("me"), now, 0);
assert!(seed.contains("5:title"));
assert!(seed.contains("4:desc"));
assert!(seed.contains("2:me"));
assert!(seed.ends_with("1:0"));
}
#[test]
fn test_parse_id_root() {
let parsed = parse_id("bd-abc123").unwrap();
assert_eq!(parsed.prefix, "bd");
assert_eq!(parsed.hash, "abc123");
assert!(parsed.child_path.is_empty());
assert!(parsed.is_root());
assert_eq!(parsed.depth(), 0);
}
#[test]
fn test_parse_id_hyphenated_prefix() {
let parsed = parse_id("bead-me-up-3e9").unwrap();
assert_eq!(parsed.prefix, "bead-me-up");
assert_eq!(parsed.hash, "3e9");
assert!(parsed.child_path.is_empty());
let parsed2 = parse_id("document-intelligence-0sa.2").unwrap();
assert_eq!(parsed2.prefix, "document-intelligence");
assert_eq!(parsed2.hash, "0sa");
assert_eq!(parsed2.child_path, vec![2]);
}
#[test]
fn test_parse_id_hyphenated_prefix_word_like_hash() {
let parsed = parse_id("my-proj-abcd").unwrap();
assert_eq!(parsed.prefix, "my-proj");
assert_eq!(parsed.hash, "abcd");
}
#[test]
fn test_parse_id_child() {
let parsed = parse_id("bd-abc123.1").unwrap();
assert_eq!(parsed.prefix, "bd");
assert_eq!(parsed.hash, "abc123");
assert_eq!(parsed.child_path, vec![1]);
assert!(!parsed.is_root());
assert_eq!(parsed.depth(), 1);
}
#[test]
fn test_parse_id_grandchild() {
let parsed = parse_id("bd-abc123.1.2").unwrap();
assert_eq!(parsed.child_path, vec![1, 2]);
assert_eq!(parsed.depth(), 2);
}
#[test]
fn test_parse_id_external_style() {
let parsed = parse_id("external:jira-123").unwrap();
assert_eq!(parsed.prefix, "external:jira");
assert_eq!(parsed.hash, "123");
let parsed2 = parse_id("ext:github#repo-456").unwrap();
assert_eq!(parsed2.prefix, "ext:github#repo");
assert_eq!(parsed2.hash, "456");
}
#[test]
fn test_parse_id_invalid_no_dash() {
assert!(parse_id("bdabc123").is_err());
}
#[test]
fn test_parse_id_invalid_empty_hash() {
assert!(parse_id("bd-").is_err());
}
#[test]
fn test_parse_id_invalid_uppercase() {
assert!(parse_id("bd-ABC123").is_err());
}
#[test]
fn test_parse_id_long_hash() {
let long_id = "bd-abc123456789";
let parsed = parse_id(long_id).unwrap();
assert_eq!(parsed.hash, "abc123456789");
}
#[test]
fn test_parsed_id_parent() {
let child = parse_id("bd-abc123.1").unwrap();
assert_eq!(child.parent(), Some("bd-abc123".to_string()));
let grandchild = parse_id("bd-abc123.1.2").unwrap();
assert_eq!(grandchild.parent(), Some("bd-abc123.1".to_string()));
let root = parse_id("bd-abc123").unwrap();
assert_eq!(root.parent(), None);
}
#[test]
fn test_parsed_id_to_string() {
let root = parse_id("bd-abc123").unwrap();
assert_eq!(root.to_id_string(), "bd-abc123");
let child = parse_id("bd-abc123.1.2").unwrap();
assert_eq!(child.to_id_string(), "bd-abc123.1.2");
}
#[test]
fn test_parsed_id_is_child_of() {
let child = parse_id("bd-abc123.1").unwrap();
assert!(child.is_child_of("bd-abc123"));
assert!(!child.is_child_of("bd-xyz"));
let grandchild = parse_id("bd-abc123.1.2").unwrap();
assert!(grandchild.is_child_of("bd-abc123"));
assert!(grandchild.is_child_of("bd-abc123.1"));
}
#[test]
fn test_validate_prefix() {
assert!(validate_prefix("bd-abc123", "bd", &[]).is_ok());
assert!(validate_prefix("bd-abc123", "other", &["bd".to_string()]).is_ok());
assert!(validate_prefix("bd-abc123", "other", &[]).is_err());
}
#[test]
fn test_normalize_prefix_sanitizes_and_lowercases() {
assert_eq!(normalize_prefix(" Project-Name_2! "), "project-name_2");
assert_eq!(normalize_prefix("!!!"), "br");
}
#[test]
fn test_abbreviate_prefix_handles_mixed_case_and_underscores() {
assert_eq!(abbreviate_prefix("My_Project-Name"), "mpn");
assert_eq!(abbreviate_prefix("superlongname"), "sup");
}
#[test]
fn test_is_valid_id_format() {
assert!(is_valid_id_format("bd-abc123"));
assert!(is_valid_id_format("bd-abc123.1.2"));
assert!(!is_valid_id_format("invalid"));
assert!(!is_valid_id_format("bd-ABC")); }
#[test]
fn test_id_generator_optimal_length() {
let id_gen = IdGenerator::with_defaults();
assert_eq!(id_gen.optimal_length(0), 3);
assert_eq!(id_gen.optimal_length(10), 3);
let len_1000 = id_gen.optimal_length(1000);
assert!(len_1000 >= 3);
assert!(len_1000 <= 8);
}
#[test]
fn test_id_generator_generate() {
let id_gen = IdGenerator::with_defaults();
let now = Utc::now();
let id = id_gen.generate(
"Test Issue",
Some("Description"),
Some("user"),
now,
0,
|_| false,
);
assert!(id.starts_with("br-"));
assert!(is_valid_id_format(&id));
}
#[test]
fn test_id_generator_collision_handling() {
let id_gen = IdGenerator::with_defaults();
let now = Utc::now();
let mut generated = std::collections::HashSet::new();
let id1 = id_gen.generate("Test", None, None, now, 0, |id| generated.contains(id));
generated.insert(id1.clone());
let id2 = id_gen.generate("Test", None, None, now, 0, |id| generated.contains(id));
assert_ne!(id1, id2);
}
#[test]
fn test_desperate_fallback_id_format() {
let prefix = "bd";
let hash = "abc123456789";
let nonce = 1001;
let good_id = format!("{prefix}-{hash}{nonce}");
assert!(
parse_id(&good_id).is_ok(),
"Fixed fallback format should parse correctly"
);
}
}