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
//! Semantic compaction for Beads.
//!
//! Compaction reduces the size of completed issues by summarizing their content,
//! helping to manage context window limits for AI coding agents.

use std::sync::Arc;

use anyhow::Result;
use chrono::Utc;
use serde::{Deserialize, Serialize};

use crate::storage::{SqliteStorage, Storage};
use crate::types::{Issue, IssueFilter, Status};

/// Statistics from a compaction run.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CompactStats {
    /// Number of issues compacted.
    pub compacted: usize,
    /// Number of issues skipped (already compacted or not eligible).
    pub skipped: usize,
    /// Total bytes saved.
    pub bytes_saved: i64,
    /// Errors encountered.
    pub errors: Vec<String>,
}

/// Compactor for reducing issue sizes.
pub struct Compactor {
    storage: Arc<SqliteStorage>,
}

impl Compactor {
    /// Create a new compactor.
    pub fn new(storage: Arc<SqliteStorage>) -> Self {
        Self { storage }
    }

    /// Compact a single issue.
    ///
    /// Level 1: Remove design and acceptance_criteria.
    /// Level 2: Summarize description to first paragraph.
    /// Level 3: Remove description, keep only title.
    pub fn compact_issue(&self, id: &str, level: i32) -> Result<CompactStats> {
        let mut stats = CompactStats::default();

        let issue = match self.storage.get_issue(id)? {
            Some(issue) => issue,
            None => {
                stats.errors.push(format!("Issue not found: {}", id));
                return Ok(stats);
            }
        };

        // Check if already compacted at this level or higher
        if let Some(current_level) = issue.compaction_level {
            if current_level >= level {
                stats.skipped += 1;
                return Ok(stats);
            }
        }

        // Check if eligible for compaction
        if !is_eligible_for_compaction(&issue) {
            stats.skipped += 1;
            return Ok(stats);
        }

        // Calculate original size
        let original_size = estimate_issue_size(&issue);

        // Apply compaction
        let compacted = compact_issue_content(issue, level);

        // Calculate new size
        let new_size = estimate_issue_size(&compacted);
        stats.bytes_saved = (original_size - new_size) as i64;

        // Update issue
        let mut updated = compacted;
        updated.compaction_level = Some(level);
        updated.compacted_at = Some(Utc::now());
        updated.original_size = Some(original_size as i32);
        updated.touch();

        self.storage.update_issue(&updated)?;
        stats.compacted += 1;

        Ok(stats)
    }

    /// Compact all eligible closed issues up to the given level.
    pub fn compact_completed(&self, max_level: i32) -> Result<CompactStats> {
        let mut stats = CompactStats::default();

        // Find closed issues
        let filter = IssueFilter {
            status: Some(Status::Closed),
            include_tombstones: false,
            ..Default::default()
        };

        let issues = self.storage.search_issues(&filter)?;

        for issue in issues {
            // Skip if already at max level
            if let Some(current_level) = issue.compaction_level {
                if current_level >= max_level {
                    stats.skipped += 1;
                    continue;
                }
            }

            // Determine appropriate level
            let level = determine_compaction_level(&issue, max_level);
            if level == 0 {
                stats.skipped += 1;
                continue;
            }

            match self.compact_issue(&issue.id, level) {
                Ok(issue_stats) => {
                    stats.compacted += issue_stats.compacted;
                    stats.skipped += issue_stats.skipped;
                    stats.bytes_saved += issue_stats.bytes_saved;
                }
                Err(e) => {
                    stats.errors.push(format!("{}: {}", issue.id, e));
                }
            }
        }

        Ok(stats)
    }

    /// Restore a compacted issue (if snapshot exists).
    pub fn restore_issue(&self, _id: &str) -> Result<()> {
        // Restoration requires stored snapshots, not yet implemented
        anyhow::bail!("Issue restoration not yet implemented")
    }
}

/// Check if an issue is eligible for compaction.
fn is_eligible_for_compaction(issue: &Issue) -> bool {
    // Must be closed
    if issue.status != Status::Closed {
        return false;
    }

    // Must not be a template
    if issue.is_template {
        return false;
    }

    // Must not be pinned
    if issue.pinned {
        return false;
    }

    // Must have some content to compact
    issue.description.is_some()
        || issue.design.is_some()
        || issue.acceptance_criteria.is_some()
        || issue.notes.is_some()
}

/// Determine the appropriate compaction level for an issue.
fn determine_compaction_level(issue: &Issue, max_level: i32) -> i32 {
    let size = estimate_issue_size(issue);

    // Larger issues get more aggressive compaction
    if size > 10000 && max_level >= 3 {
        3
    } else if size > 5000 && max_level >= 2 {
        2
    } else if size > 1000 && max_level >= 1 {
        1
    } else {
        0
    }
}

/// Estimate the size of an issue in bytes.
fn estimate_issue_size(issue: &Issue) -> usize {
    let mut size = issue.title.len();
    size += issue.description.as_ref().map_or(0, |s| s.len());
    size += issue.design.as_ref().map_or(0, |s| s.len());
    size += issue.acceptance_criteria.as_ref().map_or(0, |s| s.len());
    size += issue.notes.as_ref().map_or(0, |s| s.len());
    size
}

/// Apply compaction to issue content.
fn compact_issue_content(mut issue: Issue, level: i32) -> Issue {
    match level {
        1 => {
            // Level 1: Remove design and acceptance_criteria
            issue.design = None;
            issue.acceptance_criteria = None;
        }
        2 => {
            // Level 2: Also summarize description
            issue.design = None;
            issue.acceptance_criteria = None;
            issue.notes = None;

            if let Some(ref desc) = issue.description {
                // Keep first paragraph only
                let first_para = desc.split("\n\n")
                    .next()
                    .unwrap_or(desc);
                if first_para.len() < desc.len() {
                    issue.description = Some(format!("{} [compacted]", first_para.trim()));
                }
            }
        }
        3 => {
            // Level 3: Remove all content except title
            issue.description = Some("[compacted: content removed]".to_string());
            issue.design = None;
            issue.acceptance_criteria = None;
            issue.notes = None;
        }
        _ => {}
    }

    issue
}

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

    #[test]
    fn test_compaction_levels() {
        let mut issue = Issue::new("bd-test", "Test issue", "alice");
        issue.description = Some("Long description.\n\nSecond paragraph.".to_string());
        issue.design = Some("Design notes".to_string());
        issue.acceptance_criteria = Some("AC".to_string());

        // Level 1
        let compacted1 = compact_issue_content(issue.clone(), 1);
        assert!(compacted1.description.is_some());
        assert!(compacted1.design.is_none());
        assert!(compacted1.acceptance_criteria.is_none());

        // Level 2
        let compacted2 = compact_issue_content(issue.clone(), 2);
        assert!(compacted2.description.as_ref().unwrap().contains("Long description"));
        assert!(compacted2.description.as_ref().unwrap().contains("[compacted]"));
        assert!(compacted2.design.is_none());

        // Level 3
        let compacted3 = compact_issue_content(issue.clone(), 3);
        assert!(compacted3.description.as_ref().unwrap().contains("[compacted"));
    }

    #[test]
    fn test_eligibility() {
        let mut issue = Issue::new("bd-test", "Test", "alice");
        issue.status = Status::Open;
        assert!(!is_eligible_for_compaction(&issue));

        issue.status = Status::Closed;
        issue.description = Some("desc".to_string());
        assert!(is_eligible_for_compaction(&issue));

        issue.pinned = true;
        assert!(!is_eligible_for_compaction(&issue));
    }
}