crosslink 0.8.0

A synced issue tracker CLI for multi-agent AI development
Documentation
use anyhow::{bail, Result};

use crate::db::Database;
use crate::utils::format_issue_id;
use crate::ArchiveCommands;

pub fn run(command: ArchiveCommands, db: &Database) -> Result<()> {
    match command {
        ArchiveCommands::Add { id } => archive(db, id),
        ArchiveCommands::Remove { id } => unarchive(db, id),
        ArchiveCommands::List => list(db),
        ArchiveCommands::Older { days } => archive_older(db, days),
    }
}

pub fn archive(db: &Database, id: i64) -> Result<()> {
    let Some(issue) = db.get_issue(id)? else {
        bail!("Issue {} not found", format_issue_id(id));
    };

    if issue.status != crate::models::IssueStatus::Closed {
        bail!(
            "Can only archive closed issues. Issue {} is '{}'",
            format_issue_id(id),
            issue.status
        );
    }

    if db.archive_issue(id)? {
        println!("Archived issue {}", format_issue_id(id));
    } else {
        println!("Issue {} could not be archived", format_issue_id(id));
    }

    Ok(())
}

pub fn unarchive(db: &Database, id: i64) -> Result<()> {
    if db.unarchive_issue(id)? {
        println!("Unarchived issue {} (now closed)", format_issue_id(id));
    } else {
        bail!("Issue {} not found or not archived", format_issue_id(id));
    }

    Ok(())
}

pub fn list(db: &Database) -> Result<()> {
    let issues = db.list_archived_issues()?;

    if issues.is_empty() {
        println!("No archived issues.");
        return Ok(());
    }

    println!("Archived issues:\n");
    for issue in issues {
        let parent_str = issue
            .parent_id
            .map(|p| format!(" (sub of {})", format_issue_id(p)))
            .unwrap_or_default();
        println!(
            "{:<5} {:8} {}{}",
            format_issue_id(issue.id),
            issue.priority,
            issue.title,
            parent_str
        );
    }

    Ok(())
}

pub fn archive_older(db: &Database, days: i64) -> Result<()> {
    let count = db.archive_older_than(days)?;
    if count > 0 {
        println!("Archived {count} issue(s) closed more than {days} days ago");
    } else {
        println!("No issues to archive (none closed more than {days} days ago)");
    }

    Ok(())
}

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

    fn setup_test_db() -> (Database, tempfile::TempDir) {
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.path().join("test.db");
        let db = Database::open(&db_path).unwrap();
        (db, dir)
    }

    #[test]
    fn test_archive_closed_issue() {
        let (db, _dir) = setup_test_db();
        let id = db.create_issue("Test issue", None, "medium").unwrap();
        db.close_issue(id).unwrap();

        archive(&db, id).unwrap();
        let archived = db.list_archived_issues().unwrap();
        assert!(
            archived.iter().any(|i| i.id == id),
            "Issue should appear in archived list"
        );
    }

    #[test]
    fn test_archive_open_issue_fails() {
        let (db, _dir) = setup_test_db();
        let id = db.create_issue("Test issue", None, "medium").unwrap();

        let result = archive(&db, id);
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("only archive closed"));
    }

    #[test]
    fn test_archive_nonexistent_fails() {
        let (db, _dir) = setup_test_db();

        let result = archive(&db, 99999);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("not found"));
    }

    #[test]
    fn test_unarchive_issue() {
        let (db, _dir) = setup_test_db();
        let id = db.create_issue("Test issue", None, "medium").unwrap();
        db.close_issue(id).unwrap();
        archive(&db, id).unwrap();

        unarchive(&db, id).unwrap();
        let archived = db.list_archived_issues().unwrap();
        assert!(
            !archived.iter().any(|i| i.id == id),
            "Issue should no longer be archived"
        );
        let closed = db.list_issues(Some("closed"), None, None).unwrap();
        assert!(
            closed.iter().any(|i| i.id == id),
            "Issue should be back in closed list"
        );
    }

    #[test]
    fn test_unarchive_not_archived() {
        let (db, _dir) = setup_test_db();
        let id = db.create_issue("Test issue", None, "medium").unwrap();

        let result = unarchive(&db, id);
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("not found or not archived"));
    }

    #[test]
    fn test_list_empty() {
        let (db, _dir) = setup_test_db();

        list(&db).unwrap();
        let archived = db.list_archived_issues().unwrap();
        assert!(archived.is_empty());
    }

    #[test]
    fn test_list_with_archived() {
        let (db, _dir) = setup_test_db();
        let id = db.create_issue("Test issue", None, "medium").unwrap();
        db.close_issue(id).unwrap();
        archive(&db, id).unwrap();

        list(&db).unwrap();
        let archived = db.list_archived_issues().unwrap();
        assert_eq!(archived.len(), 1);
        assert_eq!(archived[0].id, id);
    }

    #[test]
    fn test_archive_older_none() {
        let (db, _dir) = setup_test_db();

        archive_older(&db, 30).unwrap();
        let archived = db.list_archived_issues().unwrap();
        assert!(
            archived.is_empty(),
            "No issues should be archived with empty DB"
        );
    }

    #[test]
    fn test_archive_unarchive_roundtrip() {
        let (db, _dir) = setup_test_db();
        let id = db.create_issue("Test issue", None, "medium").unwrap();
        db.close_issue(id).unwrap();

        archive(&db, id).unwrap();
        let archived = db.list_archived_issues().unwrap();
        assert!(archived.iter().any(|i| i.id == id));

        unarchive(&db, id).unwrap();
        let archived = db.list_archived_issues().unwrap();
        assert!(!archived.iter().any(|i| i.id == id));
    }

    #[test]
    fn test_archived_issue_not_in_open_or_closed_list() {
        let (db, _dir) = setup_test_db();
        let id = db.create_issue("Test issue", None, "medium").unwrap();
        db.close_issue(id).unwrap();
        archive(&db, id).unwrap();

        let open_issues = db.list_issues(Some("open"), None, None).unwrap();
        let closed_issues = db.list_issues(Some("closed"), None, None).unwrap();
        assert!(!open_issues.iter().any(|i| i.id == id));
        assert!(!closed_issues.iter().any(|i| i.id == id));
    }

    proptest! {
        #[test]
        fn prop_archive_requires_closed(title in "[a-zA-Z0-9 ]{1,30}") {
            let (db, _dir) = setup_test_db();
            let id = db.create_issue(&title, None, "medium").unwrap();

            let result = archive(&db, id);
            prop_assert!(result.is_err());
        }

        #[test]
        fn prop_archive_closed_succeeds(title in "[a-zA-Z0-9 ]{1,30}") {
            let (db, _dir) = setup_test_db();
            let id = db.create_issue(&title, None, "medium").unwrap();
            db.close_issue(id).unwrap();

            archive(&db, id).unwrap();
            let archived = db.list_archived_issues().unwrap();
            prop_assert!(archived.iter().any(|i| i.id == id));
        }
    }
}