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};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CompactStats {
pub compacted: usize,
pub skipped: usize,
pub bytes_saved: i64,
pub errors: Vec<String>,
}
pub struct Compactor {
storage: Arc<SqliteStorage>,
}
impl Compactor {
pub fn new(storage: Arc<SqliteStorage>) -> Self {
Self { storage }
}
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);
}
};
if let Some(current_level) = issue.compaction_level {
if current_level >= level {
stats.skipped += 1;
return Ok(stats);
}
}
if !is_eligible_for_compaction(&issue) {
stats.skipped += 1;
return Ok(stats);
}
let original_size = estimate_issue_size(&issue);
let compacted = compact_issue_content(issue, level);
let new_size = estimate_issue_size(&compacted);
stats.bytes_saved = (original_size - new_size) as i64;
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)
}
pub fn compact_completed(&self, max_level: i32) -> Result<CompactStats> {
let mut stats = CompactStats::default();
let filter = IssueFilter {
status: Some(Status::Closed),
include_tombstones: false,
..Default::default()
};
let issues = self.storage.search_issues(&filter)?;
for issue in issues {
if let Some(current_level) = issue.compaction_level {
if current_level >= max_level {
stats.skipped += 1;
continue;
}
}
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)
}
pub fn restore_issue(&self, _id: &str) -> Result<()> {
anyhow::bail!("Issue restoration not yet implemented")
}
}
fn is_eligible_for_compaction(issue: &Issue) -> bool {
if issue.status != Status::Closed {
return false;
}
if issue.is_template {
return false;
}
if issue.pinned {
return false;
}
issue.description.is_some()
|| issue.design.is_some()
|| issue.acceptance_criteria.is_some()
|| issue.notes.is_some()
}
fn determine_compaction_level(issue: &Issue, max_level: i32) -> i32 {
let size = estimate_issue_size(issue);
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
}
}
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
}
fn compact_issue_content(mut issue: Issue, level: i32) -> Issue {
match level {
1 => {
issue.design = None;
issue.acceptance_criteria = None;
}
2 => {
issue.design = None;
issue.acceptance_criteria = None;
issue.notes = None;
if let Some(ref desc) = issue.description {
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 => {
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());
let compacted1 = compact_issue_content(issue.clone(), 1);
assert!(compacted1.description.is_some());
assert!(compacted1.design.is_none());
assert!(compacted1.acceptance_criteria.is_none());
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());
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));
}
}