rusty-beads 0.1.0

Git-backed graph issue tracker for AI coding agents - a Rust implementation with context store, dependency tracking, and semantic compaction
Documentation
//! ID generation for Beads.
//!
//! Generates collision-free, hash-based identifiers in the format `bd-xxxx`
//! where `xxxx` is a 4-character hex string derived from a UUID.

use sha2::{Digest, Sha256};
use uuid::Uuid;

/// Default ID prefix for issues.
pub const DEFAULT_PREFIX: &str = "bd";

/// Generate a new unique issue ID.
///
/// Returns an ID in the format `{prefix}-{4-char-hex}`, e.g., `bd-a1b2`.
pub fn generate_id(prefix: &str) -> String {
    let uuid = Uuid::new_v4();
    let mut hasher = Sha256::new();
    hasher.update(uuid.as_bytes());
    let hash = hasher.finalize();

    // Take first 2 bytes (4 hex chars)
    format!("{}-{}", prefix, hex::encode(&hash[..2]))
}

/// Generate a new unique issue ID with the default prefix.
pub fn generate_default_id() -> String {
    generate_id(DEFAULT_PREFIX)
}

/// Generate a child ID from a parent ID.
///
/// Returns an ID in the format `{parent_id}.{counter}`, e.g., `bd-a1b2.1`.
pub fn generate_child_id(parent_id: &str, counter: u32) -> String {
    format!("{}.{}", parent_id, counter)
}

/// Parse an ID to extract its components.
///
/// Returns `(prefix, hash, child_parts)` where `child_parts` contains
/// any `.n` suffixes for hierarchical IDs.
pub fn parse_id(id: &str) -> Option<IdComponents<'_>> {
    let parts: Vec<&str> = id.splitn(2, '-').collect();
    if parts.len() != 2 {
        return None;
    }

    let prefix = parts[0];
    let rest = parts[1];

    // Split on dots for child hierarchy
    let child_parts: Vec<&str> = rest.split('.').collect();
    let hash = child_parts[0];
    let children: Vec<u32> = child_parts[1..]
        .iter()
        .filter_map(|s| s.parse().ok())
        .collect();

    Some(IdComponents {
        prefix,
        hash,
        children,
    })
}

/// Components of a parsed issue ID.
#[derive(Debug, Clone, PartialEq)]
pub struct IdComponents<'a> {
    /// The prefix (e.g., "bd").
    pub prefix: &'a str,
    /// The hash portion (e.g., "a1b2").
    pub hash: &'a str,
    /// Child hierarchy indices (e.g., [1, 2] for "bd-a1b2.1.2").
    pub children: Vec<u32>,
}

impl<'a> IdComponents<'a> {
    /// Returns the root ID (without child suffixes).
    pub fn root_id(&self) -> String {
        format!("{}-{}", self.prefix, self.hash)
    }

    /// Returns the parent ID if this is a child.
    pub fn parent_id(&self) -> Option<String> {
        if self.children.is_empty() {
            None
        } else if self.children.len() == 1 {
            Some(self.root_id())
        } else {
            let parent_children: Vec<_> = self.children[..self.children.len() - 1]
                .iter()
                .map(|n| n.to_string())
                .collect();
            Some(format!("{}-{}.{}", self.prefix, self.hash, parent_children.join(".")))
        }
    }

    /// Returns the depth of this ID (0 for root, 1 for first-level child, etc.).
    pub fn depth(&self) -> usize {
        self.children.len()
    }
}

/// Validate an issue ID format.
pub fn is_valid_id(id: &str) -> bool {
    parse_id(id).is_some()
}

/// Check if an ID is a child of another ID.
pub fn is_child_of(child_id: &str, parent_id: &str) -> bool {
    child_id.starts_with(parent_id) && child_id.len() > parent_id.len()
        && child_id.chars().nth(parent_id.len()) == Some('.')
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_generate_id() {
        let id1 = generate_id("bd");
        let id2 = generate_id("bd");

        assert!(id1.starts_with("bd-"));
        assert_eq!(id1.len(), 7); // "bd-" + 4 hex chars
        assert_ne!(id1, id2); // Should be unique
    }

    #[test]
    fn test_generate_child_id() {
        let child = generate_child_id("bd-a1b2", 1);
        assert_eq!(child, "bd-a1b2.1");

        let grandchild = generate_child_id("bd-a1b2.1", 2);
        assert_eq!(grandchild, "bd-a1b2.1.2");
    }

    #[test]
    fn test_parse_id() {
        let components = parse_id("bd-a1b2").unwrap();
        assert_eq!(components.prefix, "bd");
        assert_eq!(components.hash, "a1b2");
        assert!(components.children.is_empty());
        assert_eq!(components.depth(), 0);

        let child = parse_id("bd-a1b2.1").unwrap();
        assert_eq!(child.children, vec![1]);
        assert_eq!(child.depth(), 1);
        assert_eq!(child.parent_id(), Some("bd-a1b2".to_string()));

        let grandchild = parse_id("bd-a1b2.1.2").unwrap();
        assert_eq!(grandchild.children, vec![1, 2]);
        assert_eq!(grandchild.depth(), 2);
        assert_eq!(grandchild.parent_id(), Some("bd-a1b2.1".to_string()));
    }

    #[test]
    fn test_is_child_of() {
        assert!(is_child_of("bd-a1b2.1", "bd-a1b2"));
        assert!(is_child_of("bd-a1b2.1.2", "bd-a1b2"));
        assert!(is_child_of("bd-a1b2.1.2", "bd-a1b2.1"));
        assert!(!is_child_of("bd-a1b2", "bd-a1b2"));
        assert!(!is_child_of("bd-a1b3", "bd-a1b2"));
    }
}