lincli 2026.4.1

Linear CLI — manage issues, projects, cycles, and more from the terminal
use crate::error::LinearError;
use dashmap::DashMap;
use serde::Deserialize;
use tokio::sync::OnceCell;

#[derive(Debug, Deserialize, Clone)]
pub struct CachedTeam {
    pub id: String,
    pub name: String,
    pub key: String,
}

#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CachedUser {
    pub id: String,
    pub name: Option<String>,
    pub display_name: Option<String>,
    #[allow(dead_code)]
    pub email: Option<String>,
    #[allow(dead_code)]
    pub active: bool,
}

#[derive(Debug, Deserialize, Clone)]
pub struct CachedProject {
    pub id: String,
    pub name: String,
}

#[derive(Debug, Deserialize, Clone)]
pub struct CachedState {
    pub id: String,
    pub name: String,
    #[serde(rename = "type")]
    #[allow(dead_code)]
    pub state_type: String,
}

#[derive(Debug, Deserialize, Clone)]
pub struct CachedLabel {
    pub id: String,
    pub name: String,
}

pub struct Cache {
    pub teams: OnceCell<Vec<CachedTeam>>,
    pub users: OnceCell<Vec<CachedUser>>,
    pub projects: OnceCell<Vec<CachedProject>>,
    pub states: DashMap<String, Vec<CachedState>>,
    pub labels: DashMap<String, Vec<CachedLabel>>,
}

impl Cache {
    pub fn new() -> Self {
        Self {
            teams: OnceCell::new(),
            users: OnceCell::new(),
            projects: OnceCell::new(),
            states: DashMap::new(),
            labels: DashMap::new(),
        }
    }
}

impl Default for Cache {
    fn default() -> Self {
        Self::new()
    }
}

pub fn find_team<'a>(
    teams: &'a [CachedTeam],
    key_or_name: &str,
) -> Result<&'a CachedTeam, LinearError> {
    let needle = key_or_name.to_lowercase();
    teams
        .iter()
        .find(|t| t.key.to_lowercase() == needle || t.name.to_lowercase() == needle)
        .ok_or_else(|| LinearError::NotFound {
            entity: "Team",
            name: key_or_name.to_string(),
        })
}

pub fn find_user<'a>(users: &'a [CachedUser], name: &str) -> Result<&'a CachedUser, LinearError> {
    let needle = name.to_lowercase();
    users
        .iter()
        .find(|u| {
            let display = u
                .display_name
                .as_deref()
                .or(u.name.as_deref())
                .unwrap_or("")
                .to_lowercase();
            display == needle || display.contains(&needle)
        })
        .ok_or_else(|| LinearError::NotFound {
            entity: "User",
            name: name.to_string(),
        })
}

pub fn find_project<'a>(
    projects: &'a [CachedProject],
    name: &str,
) -> Result<&'a CachedProject, LinearError> {
    let needle = name.to_lowercase();
    projects
        .iter()
        .find(|p| p.name.to_lowercase().contains(&needle))
        .ok_or_else(|| LinearError::NotFound {
            entity: "Project",
            name: name.to_string(),
        })
}

pub fn find_state<'a>(
    states: &'a [CachedState],
    state_name: &str,
) -> Result<&'a CachedState, LinearError> {
    let needle = state_name.to_lowercase();
    states
        .iter()
        .find(|s| s.name.to_lowercase() == needle)
        .ok_or_else(|| LinearError::NotFound {
            entity: "State",
            name: state_name.to_string(),
        })
}

pub fn find_labels(labels: &[CachedLabel], names: &[&str]) -> Result<Vec<String>, LinearError> {
    names
        .iter()
        .map(|name| {
            let needle = name.to_lowercase().trim().to_string();
            labels
                .iter()
                .find(|l| l.name.to_lowercase() == needle)
                .map(|l| l.id.clone())
                .ok_or_else(|| LinearError::NotFound {
                    entity: "Label",
                    name: name.to_string(),
                })
        })
        .collect()
}

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

    fn test_teams() -> Vec<CachedTeam> {
        vec![
            CachedTeam {
                id: "t1".into(),
                name: "Engineering".into(),
                key: "ENG".into(),
            },
            CachedTeam {
                id: "t2".into(),
                name: "Design".into(),
                key: "DES".into(),
            },
        ]
    }

    fn test_users() -> Vec<CachedUser> {
        vec![
            CachedUser {
                id: "u1".into(),
                name: Some("Alice Smith".into()),
                display_name: Some("Alice".into()),
                email: None,
                active: true,
            },
            CachedUser {
                id: "u2".into(),
                name: Some("Bob Jones".into()),
                display_name: None,
                email: None,
                active: true,
            },
        ]
    }

    #[test]
    fn test_find_team_by_key() {
        let teams = test_teams();
        let team = find_team(&teams, "ENG").unwrap();
        assert_eq!(team.id, "t1");
    }

    #[test]
    fn test_find_team_by_name_case_insensitive() {
        let teams = test_teams();
        let team = find_team(&teams, "engineering").unwrap();
        assert_eq!(team.id, "t1");
    }

    #[test]
    fn test_find_team_not_found() {
        let teams = test_teams();
        let result = find_team(&teams, "NOPE");
        assert!(result.is_err());
    }

    #[test]
    fn test_find_user_by_display_name() {
        let users = test_users();
        let user = find_user(&users, "Alice").unwrap();
        assert_eq!(user.id, "u1");
    }

    #[test]
    fn test_find_user_partial_match() {
        let users = test_users();
        let user = find_user(&users, "ali").unwrap();
        assert_eq!(user.id, "u1");
    }

    #[test]
    fn test_find_user_falls_back_to_name() {
        let users = test_users();
        let user = find_user(&users, "Bob Jones").unwrap();
        assert_eq!(user.id, "u2");
    }

    #[test]
    fn test_find_labels_all_found() {
        let labels = vec![
            CachedLabel {
                id: "l1".into(),
                name: "bug".into(),
            },
            CachedLabel {
                id: "l2".into(),
                name: "feature".into(),
            },
        ];
        let ids = find_labels(&labels, &["bug", "feature"]).unwrap();
        assert_eq!(ids, vec!["l1", "l2"]);
    }

    #[test]
    fn test_find_labels_one_missing() {
        let labels = vec![CachedLabel {
            id: "l1".into(),
            name: "bug".into(),
        }];
        let result = find_labels(&labels, &["bug", "nope"]);
        assert!(result.is_err());
    }
}