crosslink 0.8.0

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

use crate::db::Database;
use crate::shared_writer::SharedWriter;
use crate::utils::format_issue_id;

pub fn add(
    db: &Database,
    writer: Option<&SharedWriter>,
    issue_id: i64,
    related_id: i64,
) -> Result<()> {
    db.require_issue(issue_id)?;
    db.require_issue(related_id)?;

    if let Some(w) = writer {
        w.add_relation(db, issue_id, related_id)?;
        println!(
            "Linked {}{}",
            format_issue_id(issue_id),
            format_issue_id(related_id)
        );
    } else if db.add_relation(issue_id, related_id)? {
        println!(
            "Linked {}{}",
            format_issue_id(issue_id),
            format_issue_id(related_id)
        );
    } else {
        println!(
            "Issues {} and {} are already related",
            format_issue_id(issue_id),
            format_issue_id(related_id)
        );
    }

    Ok(())
}

pub fn remove(
    db: &Database,
    writer: Option<&SharedWriter>,
    issue_id: i64,
    related_id: i64,
) -> Result<()> {
    if let Some(w) = writer {
        w.remove_relation(db, issue_id, related_id)?;
        println!(
            "Unlinked {}{}",
            format_issue_id(issue_id),
            format_issue_id(related_id)
        );
    } else if db.remove_relation(issue_id, related_id)? {
        println!(
            "Unlinked {}{}",
            format_issue_id(issue_id),
            format_issue_id(related_id)
        );
    } else {
        println!(
            "No relation found between {} and {}",
            format_issue_id(issue_id),
            format_issue_id(related_id)
        );
    }

    Ok(())
}

pub fn list(db: &Database, issue_id: i64) -> Result<()> {
    db.require_issue(issue_id)?;

    let related = db.get_related_issues(issue_id)?;

    if related.is_empty() {
        println!("No related issues for {}", format_issue_id(issue_id));
        return Ok(());
    }

    println!("Related to {}:", format_issue_id(issue_id));
    for r in related {
        let status_marker = if r.status == crate::models::IssueStatus::Closed {
            ""
        } else {
            " "
        };
        println!(
            "  {:<5} [{}] {:8} {}",
            format_issue_id(r.id),
            status_marker,
            r.priority,
            r.title
        );
    }

    Ok(())
}

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

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

    #[test]
    fn test_add_relation() {
        let (db, _dir) = setup_test_db();
        let id1 = db.create_issue("Issue 1", None, "medium").unwrap();
        let id2 = db.create_issue("Issue 2", None, "medium").unwrap();

        let result = add(&db, None, id1, id2);
        assert!(result.is_ok());

        let related = db.get_related_issues(id1).unwrap();
        assert_eq!(related.len(), 1);
        assert_eq!(related[0].id, id2);
    }

    #[test]
    fn test_add_relation_bidirectional() {
        let (db, _dir) = setup_test_db();
        let id1 = db.create_issue("Issue 1", None, "medium").unwrap();
        let id2 = db.create_issue("Issue 2", None, "medium").unwrap();

        add(&db, None, id1, id2).unwrap();

        let related1 = db.get_related_issues(id1).unwrap();
        let related2 = db.get_related_issues(id2).unwrap();
        assert_eq!(related1.len(), 1);
        assert_eq!(related2.len(), 1);
    }

    #[test]
    fn test_add_relation_nonexistent_issue() {
        let (db, _dir) = setup_test_db();
        let id = db.create_issue("Issue 1", None, "medium").unwrap();

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

    #[test]
    fn test_add_duplicate_relation() {
        let (db, _dir) = setup_test_db();
        let id1 = db.create_issue("Issue 1", None, "medium").unwrap();
        let id2 = db.create_issue("Issue 2", None, "medium").unwrap();

        add(&db, None, id1, id2).unwrap();
        let result = add(&db, None, id1, id2);
        assert!(result.is_ok());

        let related = db.get_related_issues(id1).unwrap();
        assert_eq!(related.len(), 1);
    }

    #[test]
    fn test_remove_relation() {
        let (db, _dir) = setup_test_db();
        let id1 = db.create_issue("Issue 1", None, "medium").unwrap();
        let id2 = db.create_issue("Issue 2", None, "medium").unwrap();

        add(&db, None, id1, id2).unwrap();
        let result = remove(&db, None, id1, id2);
        assert!(result.is_ok());

        let related = db.get_related_issues(id1).unwrap();
        assert_eq!(related.len(), 0);
    }

    #[test]
    fn test_remove_nonexistent_relation() {
        let (db, _dir) = setup_test_db();
        let id1 = db.create_issue("Issue 1", None, "medium").unwrap();
        let id2 = db.create_issue("Issue 2", None, "medium").unwrap();

        let result = remove(&db, None, id1, id2);
        assert!(result.is_ok());
    }

    #[test]
    fn test_list_relations() {
        let (db, _dir) = setup_test_db();
        let id1 = db.create_issue("Issue 1", None, "medium").unwrap();
        let id2 = db.create_issue("Issue 2", None, "medium").unwrap();
        let id3 = db.create_issue("Issue 3", None, "medium").unwrap();

        add(&db, None, id1, id2).unwrap();
        add(&db, None, id1, id3).unwrap();

        let result = list(&db, id1);
        assert!(result.is_ok());
    }

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

        let result = list(&db, 99999);
        assert!(result.is_err());
    }

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

        let result = list(&db, id);
        assert!(result.is_ok());
    }

    proptest! {
        #[test]
        fn prop_add_remove_roundtrip(a in 0i64..3, b in 0i64..3) {
            if a != b {
                let (db, _dir) = setup_test_db();
                let ids: Vec<i64> = (0..5).map(|i| db.create_issue(&format!("Issue {i}"), None, "medium").unwrap()).collect();

                let id1 = ids[a as usize % ids.len()];
                let id2 = ids[b as usize % ids.len()];

                add(&db, None, id1, id2).unwrap();
                let related = db.get_related_issues(id1).unwrap();
                prop_assert!(!related.is_empty());

                remove(&db, None, id1, id2).unwrap();
                let related = db.get_related_issues(id1).unwrap();
                prop_assert!(related.is_empty());
            }
        }
    }
}