crosslink 0.8.0

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

use crate::db::Database;
use crate::models::Issue;
use crate::utils::format_issue_id;

const fn status_icon(status: crate::models::IssueStatus) -> &'static str {
    use crate::models::IssueStatus;
    match status {
        IssueStatus::Open => " ",
        IssueStatus::Closed => "x",
        IssueStatus::Archived => "?",
    }
}

fn print_issue(issue: &Issue, indent: usize) {
    let prefix = "  ".repeat(indent);
    let icon = status_icon(issue.status);
    println!(
        "{}[{}] {} {} - {}",
        prefix,
        icon,
        format_issue_id(issue.id),
        issue.priority,
        issue.title
    );
}

fn print_tree_recursive(
    db: &Database,
    parent_id: i64,
    indent: usize,
    status_filter: Option<&str>,
) -> Result<()> {
    let subissues = db.get_subissues(parent_id)?;
    for sub in subissues {
        let dominated_by_filter = match status_filter {
            Some("all") | None => false,
            Some(filter) => sub.status.as_str() != filter,
        };
        if dominated_by_filter {
            continue;
        }
        print_issue(&sub, indent);
        print_tree_recursive(db, sub.id, indent + 1, status_filter)?;
    }
    Ok(())
}

#[derive(Serialize)]
struct TreeNode {
    id: i64,
    display_id: String,
    title: String,
    status: String,
    priority: String,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    children: Vec<TreeNode>,
}

fn build_tree_node(db: &Database, issue: &Issue, status_filter: Option<&str>) -> Result<TreeNode> {
    let subissues = db.get_subissues(issue.id)?;
    let children: Vec<TreeNode> = subissues
        .iter()
        .filter(|sub| match status_filter {
            Some("all") | None => true,
            Some(filter) => sub.status.as_str() == filter,
        })
        .map(|sub| build_tree_node(db, sub, status_filter))
        .collect::<Result<Vec<_>>>()?;

    Ok(TreeNode {
        id: issue.id,
        display_id: format_issue_id(issue.id),
        title: issue.title.clone(),
        status: issue.status.to_string(),
        priority: issue.priority.to_string(),
        children,
    })
}

pub fn run(db: &Database, status_filter: Option<&str>, json: bool) -> Result<()> {
    // Get all top-level issues (no parent)
    let all_issues = db.list_issues(status_filter, None, None)?;
    let top_level: Vec<_> = all_issues
        .into_iter()
        .filter(|i| i.parent_id.is_none())
        .collect();

    if json {
        let nodes: Vec<TreeNode> = top_level
            .iter()
            .map(|issue| build_tree_node(db, issue, status_filter))
            .collect::<Result<Vec<_>>>()?;
        println!("{}", serde_json::to_string_pretty(&nodes)?);
        return Ok(());
    }

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

    for issue in top_level {
        print_issue(&issue, 0);
        print_tree_recursive(db, issue.id, 1, status_filter)?;
    }

    // Legend
    println!();
    println!("Legend: [ ] open, [x] closed");

    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_status_icon_open() {
        assert_eq!(status_icon(crate::models::IssueStatus::Open), " ");
    }

    #[test]
    fn test_status_icon_closed() {
        assert_eq!(status_icon(crate::models::IssueStatus::Closed), "x");
    }

    #[test]
    fn test_status_icon_unknown() {
        assert_eq!(status_icon(crate::models::IssueStatus::Archived), "?");
    }

    #[test]
    fn test_run_empty() {
        let (db, _dir) = setup_test_db();
        run(&db, None, false).unwrap();
        let issues = db.list_issues(None, None, None).unwrap();
        assert!(issues.is_empty());
    }

    #[test]
    fn test_run_single_issue() {
        let (db, _dir) = setup_test_db();
        let id = db.create_issue("Test issue", None, "medium").unwrap();
        run(&db, None, false).unwrap();
        let issues = db.list_issues(None, None, None).unwrap();
        assert_eq!(issues.len(), 1);
        assert_eq!(issues[0].id, id);
    }

    #[test]
    fn test_run_with_hierarchy() {
        let (db, _dir) = setup_test_db();
        let parent = db.create_issue("Parent", None, "high").unwrap();
        let c1 = db
            .create_subissue(parent, "Child 1", None, "medium")
            .unwrap();
        let c2 = db.create_subissue(parent, "Child 2", None, "low").unwrap();
        run(&db, None, false).unwrap();
        let subs = db.get_subissues(parent).unwrap();
        assert_eq!(subs.len(), 2);
        assert!(subs.iter().any(|s| s.id == c1));
        assert!(subs.iter().any(|s| s.id == c2));
    }

    #[test]
    fn test_run_nested_hierarchy() {
        let (db, _dir) = setup_test_db();
        let grandparent = db.create_issue("Grandparent", None, "high").unwrap();
        let parent = db
            .create_subissue(grandparent, "Parent", None, "medium")
            .unwrap();
        let child = db.create_subissue(parent, "Child", None, "low").unwrap();
        run(&db, None, false).unwrap();
        let child_issue = db.get_issue(child).unwrap().unwrap();
        assert_eq!(child_issue.parent_id, Some(parent));
        let parent_issue = db.get_issue(parent).unwrap().unwrap();
        assert_eq!(parent_issue.parent_id, Some(grandparent));
    }

    #[test]
    fn test_run_with_status_filter() {
        let (db, _dir) = setup_test_db();
        let closed_id = db.create_issue("Closed issue", None, "medium").unwrap();
        let open_id = db.create_issue("Open issue", None, "medium").unwrap();
        db.close_issue(closed_id).unwrap();
        run(&db, Some("open"), false).unwrap();
        let open_issues = db.list_issues(Some("open"), None, None).unwrap();
        assert_eq!(open_issues.len(), 1);
        assert_eq!(open_issues[0].id, open_id);
    }

    #[test]
    fn test_run_closed_filter() {
        let (db, _dir) = setup_test_db();
        let id = db.create_issue("Issue", None, "medium").unwrap();
        db.close_issue(id).unwrap();
        run(&db, Some("closed"), false).unwrap();
        let closed = db.list_issues(Some("closed"), None, None).unwrap();
        assert_eq!(closed.len(), 1);
        assert_eq!(closed[0].id, id);
    }

    #[test]
    fn test_run_all_filter() {
        let (db, _dir) = setup_test_db();
        db.create_issue("Open issue", None, "medium").unwrap();
        let id = db.create_issue("Closed issue", None, "medium").unwrap();
        db.close_issue(id).unwrap();
        run(&db, Some("all"), false).unwrap();
        let all = db.list_issues(Some("all"), None, None).unwrap();
        assert_eq!(all.len(), 2);
    }

    proptest! {
        #[test]
        fn prop_run_never_panics(count in 0usize..5) {
            let (db, _dir) = setup_test_db();
            for i in 0..count {
                db.create_issue(&format!("Issue {i}"), None, "medium").unwrap();
            }
            let result = run(&db, None, false);
            prop_assert!(result.is_ok());
        }

        #[test]
        fn prop_hierarchy_never_panics(depth in 1usize..4) {
            let (db, _dir) = setup_test_db();
            let mut parent_id = db.create_issue("Root", None, "high").unwrap();
            for i in 0..depth {
                parent_id = db.create_subissue(parent_id, &format!("Child {i}"), None, "medium").unwrap();
            }
            let result = run(&db, None, false);
            prop_assert!(result.is_ok());
        }
    }
}