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
//! Issue type definition - the central entity in Beads.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use super::{AgentState, IssueType, MolType, Status};

/// The central entity representing a trackable work item.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
    /// Unique identifier (format: bd-xxxx or bd-xxxx.n for children).
    pub id: String,

    /// SHA256 hash of canonical content for deduplication.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub content_hash: Option<String>,

    // === Content fields ===
    /// Work item name (max 500 chars).
    pub title: String,

    /// Detailed explanation.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// Design specifications.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub design: Option<String>,

    /// Definition of done.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub acceptance_criteria: Option<String>,

    /// Additional notes.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub notes: Option<String>,

    // === Status & Workflow ===
    /// Current workflow state.
    #[serde(default)]
    pub status: Status,

    /// Numeric priority (0-4, lower is higher priority).
    #[serde(default)]
    pub priority: i32,

    /// Type of work.
    #[serde(default)]
    pub issue_type: IssueType,

    // === Assignment ===
    /// Primary worker.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub assignee: Option<String>,

    /// Responsible party.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub owner: Option<String>,

    /// Time estimate in minutes.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub estimated_minutes: Option<i32>,

    // === Timestamps ===
    /// When the issue was created.
    pub created_at: DateTime<Utc>,

    /// Who created the issue.
    pub created_by: String,

    /// When the issue was last modified.
    pub updated_at: DateTime<Utc>,

    /// When the issue was closed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub closed_at: Option<DateTime<Utc>>,

    /// Why the issue was closed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub close_reason: Option<String>,

    // === Scheduling ===
    /// Deadline.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub due_at: Option<DateTime<Utc>>,

    /// Postpone until date.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub defer_until: Option<DateTime<Utc>>,

    // === External Integration ===
    /// Reference to external system (Linear, Jira, etc.).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub external_ref: Option<String>,

    /// Origin system identifier.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_system: Option<String>,

    // === Labels ===
    /// Associated tags.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub labels: Vec<String>,

    // === Soft Delete ===
    /// When the issue was soft-deleted.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub deleted_at: Option<DateTime<Utc>>,

    /// Who deleted the issue.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub deleted_by: Option<String>,

    /// Why the issue was deleted.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub delete_reason: Option<String>,

    // === Compaction ===
    /// Level of compaction applied (0 = none).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub compaction_level: Option<i32>,

    /// When the issue was compacted.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub compacted_at: Option<DateTime<Utc>>,

    /// Git commit at time of compaction.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub compacted_at_commit: Option<String>,

    /// Original size before compaction.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub original_size: Option<i32>,

    // === Agent Fields ===
    /// Self-reported agent state.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub agent_state: Option<AgentState>,

    /// Type of molecule.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub mol_type: Option<MolType>,

    /// Hook bead reference.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hook_bead: Option<String>,

    /// Role bead reference.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub role_bead: Option<String>,

    /// Rig reference.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rig: Option<String>,

    /// Last activity timestamp.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_activity: Option<DateTime<Utc>>,

    // === Flags ===
    /// Whether this issue is pinned.
    #[serde(default)]
    pub pinned: bool,

    /// Whether this issue is a template.
    #[serde(default)]
    pub is_template: bool,

    /// Whether this is ephemeral (not persisted).
    #[serde(default)]
    pub ephemeral: bool,
}

impl Issue {
    /// Create a new issue with minimal required fields.
    pub fn new(id: impl Into<String>, title: impl Into<String>, created_by: impl Into<String>) -> Self {
        let now = Utc::now();
        Self {
            id: id.into(),
            content_hash: None,
            title: title.into(),
            description: None,
            design: None,
            acceptance_criteria: None,
            notes: None,
            status: Status::Open,
            priority: 2,
            issue_type: IssueType::Task,
            assignee: None,
            owner: None,
            estimated_minutes: None,
            created_at: now,
            created_by: created_by.into(),
            updated_at: now,
            closed_at: None,
            close_reason: None,
            due_at: None,
            defer_until: None,
            external_ref: None,
            source_system: None,
            labels: Vec::new(),
            deleted_at: None,
            deleted_by: None,
            delete_reason: None,
            compaction_level: None,
            compacted_at: None,
            compacted_at_commit: None,
            original_size: None,
            agent_state: None,
            mol_type: None,
            hook_bead: None,
            role_bead: None,
            rig: None,
            last_activity: None,
            pinned: false,
            is_template: false,
            ephemeral: false,
        }
    }

    /// Compute the content hash for this issue.
    pub fn compute_content_hash(&self) -> String {
        let mut hasher = Sha256::new();

        // Hash canonical content fields
        hasher.update(self.title.as_bytes());
        if let Some(ref desc) = self.description {
            hasher.update(desc.as_bytes());
        }
        if let Some(ref design) = self.design {
            hasher.update(design.as_bytes());
        }
        if let Some(ref ac) = self.acceptance_criteria {
            hasher.update(ac.as_bytes());
        }
        if let Some(ref notes) = self.notes {
            hasher.update(notes.as_bytes());
        }

        hex::encode(hasher.finalize())
    }

    /// Update the content hash.
    pub fn update_content_hash(&mut self) {
        self.content_hash = Some(self.compute_content_hash());
    }

    /// Mark this issue as updated.
    pub fn touch(&mut self) {
        self.updated_at = Utc::now();
    }

    /// Close this issue.
    pub fn close(&mut self, reason: Option<String>) {
        self.status = Status::Closed;
        self.closed_at = Some(Utc::now());
        self.close_reason = reason;
        self.touch();
    }

    /// Soft-delete this issue (tombstone).
    pub fn tombstone(&mut self, actor: &str, reason: Option<String>) {
        self.status = Status::Tombstone;
        self.deleted_at = Some(Utc::now());
        self.deleted_by = Some(actor.to_string());
        self.delete_reason = reason;
        self.touch();
    }

    /// Returns true if this issue is soft-deleted.
    pub fn is_deleted(&self) -> bool {
        self.deleted_at.is_some() || self.status == Status::Tombstone
    }

    /// Returns true if this issue is ready for work (no blocking dependencies).
    /// Note: This only checks the issue's own status; dependency checking is done in storage.
    pub fn is_potentially_ready(&self) -> bool {
        matches!(self.status, Status::Open) && !self.is_deleted()
    }

    /// Returns the parent ID if this is a child issue (bd-xxxx.n format).
    pub fn parent_id(&self) -> Option<&str> {
        self.id.rsplit_once('.').map(|(parent, _)| parent)
    }

    /// Returns true if this issue has a parent.
    pub fn has_parent(&self) -> bool {
        self.id.contains('.')
    }

    /// Builder method to set description.
    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
        self.description = Some(desc.into());
        self
    }

    /// Builder method to set issue type.
    pub fn with_type(mut self, issue_type: IssueType) -> Self {
        self.issue_type = issue_type;
        self
    }

    /// Builder method to set priority.
    pub fn with_priority(mut self, priority: i32) -> Self {
        self.priority = priority.clamp(0, 4);
        self
    }

    /// Builder method to set assignee.
    pub fn with_assignee(mut self, assignee: impl Into<String>) -> Self {
        self.assignee = Some(assignee.into());
        self
    }

    /// Builder method to add a label.
    pub fn with_label(mut self, label: impl Into<String>) -> Self {
        self.labels.push(label.into());
        self
    }
}

impl Default for Issue {
    fn default() -> Self {
        Self::new("", "", "unknown")
    }
}

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

    #[test]
    fn test_issue_creation() {
        let issue = Issue::new("bd-a1b2", "Test task", "alice");
        assert_eq!(issue.id, "bd-a1b2");
        assert_eq!(issue.title, "Test task");
        assert_eq!(issue.created_by, "alice");
        assert_eq!(issue.status, Status::Open);
        assert_eq!(issue.priority, 2);
    }

    #[test]
    fn test_content_hash() {
        let mut issue = Issue::new("bd-a1b2", "Test task", "alice");
        issue.description = Some("Description".to_string());

        let hash1 = issue.compute_content_hash();

        issue.title = "Changed title".to_string();
        let hash2 = issue.compute_content_hash();

        assert_ne!(hash1, hash2);
    }

    #[test]
    fn test_parent_id() {
        let issue = Issue::new("bd-a1b2.1", "Child task", "alice");
        assert_eq!(issue.parent_id(), Some("bd-a1b2"));
        assert!(issue.has_parent());

        let parent = Issue::new("bd-a1b2", "Parent task", "alice");
        assert_eq!(parent.parent_id(), None);
        assert!(!parent.has_parent());
    }

    #[test]
    fn test_tombstone() {
        let mut issue = Issue::new("bd-a1b2", "Test task", "alice");
        issue.tombstone("bob", Some("Duplicate".to_string()));

        assert!(issue.is_deleted());
        assert_eq!(issue.status, Status::Tombstone);
        assert_eq!(issue.deleted_by.as_deref(), Some("bob"));
        assert_eq!(issue.delete_reason.as_deref(), Some("Duplicate"));
    }
}