use ratatui::style::Color;
use serde::Deserialize;
use crate::config::Theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Priority {
#[default]
None,
Urgent,
High,
Medium,
Low,
}
impl Priority {
pub fn label(&self) -> &'static str {
match self {
Self::None => "None",
Self::Urgent => "Urgent",
Self::High => "High",
Self::Medium => "Medium",
Self::Low => "Low",
}
}
pub fn glyph(&self) -> &'static str {
match self {
Self::Urgent => "!",
Self::High => "\u{2587}",
Self::Medium => "\u{2585}",
Self::Low => "\u{2583}",
Self::None => "\u{00b7}",
}
}
pub fn color(&self, theme: &Theme) -> Color {
match self {
Self::Urgent => theme.pri_urgent,
Self::High => theme.pri_high,
Self::Medium => theme.pri_medium,
Self::Low => theme.pri_low,
Self::None => theme.muted,
}
}
pub fn as_u8(self) -> u8 {
match self {
Self::None => 0,
Self::Urgent => 1,
Self::High => 2,
Self::Medium => 3,
Self::Low => 4,
}
}
pub fn as_index(self) -> usize {
self.as_u8() as usize
}
pub fn from_index(index: usize) -> Self {
match index {
1 => Self::Urgent,
2 => Self::High,
3 => Self::Medium,
4 => Self::Low,
_ => Self::None,
}
}
}
impl From<f64> for Priority {
fn from(v: f64) -> Self {
match v as u8 {
1 => Self::Urgent,
2 => Self::High,
3 => Self::Medium,
4 => Self::Low,
_ => Self::None,
}
}
}
impl<'de> Deserialize<'de> for Priority {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = f64::deserialize(deserializer)?;
Ok(Self::from(v))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StateType {
Triage,
Backlog,
Unstarted,
Started,
Completed,
Cancelled,
Duplicate,
Unknown,
}
impl<'de> Deserialize<'de> for StateType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Ok(match raw.as_str() {
"triage" => Self::Triage,
"backlog" => Self::Backlog,
"unstarted" => Self::Unstarted,
"started" => Self::Started,
"completed" => Self::Completed,
"canceled" | "cancelled" => Self::Cancelled,
"duplicate" => Self::Duplicate,
other => {
tracing::debug!(state_type = %other, "unrecognised workflow state type");
Self::Unknown
}
})
}
}
impl StateType {
pub fn rank(&self) -> u8 {
match self {
Self::Triage => 0,
Self::Started => 1,
Self::Unstarted => 2,
Self::Backlog => 3,
Self::Completed => 4,
Self::Cancelled | Self::Duplicate => 5,
Self::Unknown => 6,
}
}
pub fn glyph(&self) -> &'static str {
match self {
Self::Triage => "\u{25c8}",
Self::Backlog => "\u{25cc}",
Self::Unstarted => "\u{25cb}",
Self::Started => "\u{25d0}",
Self::Completed => "\u{25cf}",
Self::Cancelled | Self::Duplicate => "\u{2298}",
Self::Unknown => "\u{25cb}",
}
}
pub fn is_active(&self) -> bool {
matches!(self, Self::Started | Self::Unstarted)
}
pub fn is_backlog(&self) -> bool {
matches!(self, Self::Backlog | Self::Triage)
}
pub fn color(&self) -> Color {
match self {
Self::Started => Color::Yellow,
Self::Completed => Color::Green,
Self::Cancelled | Self::Duplicate => Color::DarkGray,
Self::Backlog => Color::DarkGray,
Self::Unstarted => Color::White,
Self::Triage => Color::Magenta,
Self::Unknown => Color::White,
}
}
}
pub fn hex_color(hex: &str) -> Option<Color> {
let hex = hex.strip_prefix('#').unwrap_or(hex);
if hex.len() != 6 {
return None;
}
let byte = |r: std::ops::Range<usize>| u8::from_str_radix(hex.get(r)?, 16).ok();
Some(Color::Rgb(byte(0..2)?, byte(2..4)?, byte(4..6)?))
}
#[derive(Debug, Clone, Deserialize)]
pub struct Connection<T> {
pub nodes: Vec<T>,
#[serde(default, rename = "pageInfo")]
pub page_info: PageInfo,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PageInfo {
#[serde(default, rename = "hasNextPage")]
pub has_next_page: bool,
#[serde(default, rename = "endCursor")]
pub end_cursor: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct User {
pub id: String,
pub name: String,
#[serde(default)]
pub email: Option<String>,
#[serde(default, rename = "displayName")]
pub display_name: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct Team {
pub id: String,
pub name: String,
pub key: String,
#[serde(default)]
pub color: Option<String>,
#[serde(default = "default_true", rename = "cyclesEnabled")]
pub cycles_enabled: bool,
}
fn default_true() -> bool {
true
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Issue {
pub id: String,
pub identifier: String,
pub title: String,
#[serde(default)]
pub priority: Priority,
#[serde(default, rename = "priorityLabel")]
pub priority_label: Option<String>,
pub state: Option<WorkflowState>,
pub assignee: Option<User>,
#[serde(default)]
pub labels: Option<Connection<Label>>,
pub description: Option<String>,
#[serde(default, rename = "createdAt")]
pub created_at: Option<String>,
#[serde(default, rename = "updatedAt")]
pub updated_at: Option<String>,
pub comments: Option<Connection<Comment>>,
pub project: Option<Project>,
#[serde(default, rename = "projectMilestone")]
pub project_milestone: Option<Milestone>,
pub cycle: Option<Cycle>,
#[serde(default)]
pub creator: Option<User>,
#[serde(default)]
pub estimate: Option<f64>,
#[serde(default, rename = "dueDate")]
pub due_date: Option<String>,
#[serde(default)]
pub parent: Option<IssueRef>,
#[serde(default)]
pub children: Option<Connection<IssueRef>>,
#[serde(default)]
pub url: Option<String>,
#[serde(default, rename = "branchName")]
pub branch_name: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct IssueRef {
pub id: String,
pub identifier: String,
pub title: String,
#[serde(default)]
pub state: Option<WorkflowState>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct Milestone {
pub id: String,
pub name: String,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct WorkflowState {
pub id: String,
pub name: String,
#[serde(default)]
pub color: Option<String>,
#[serde(default, rename = "type")]
pub state_type: Option<StateType>,
#[serde(default)]
pub position: Option<f64>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct Label {
pub id: String,
pub name: String,
#[serde(default)]
pub color: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct Comment {
pub id: String,
pub body: String,
#[serde(default, rename = "createdAt")]
pub created_at: Option<String>,
#[serde(default, rename = "editedAt")]
pub edited_at: Option<String>,
pub user: Option<User>,
#[serde(default)]
pub parent: Option<ParentRef>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ParentRef {
pub id: String,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct Project {
pub id: String,
pub name: String,
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub health: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub progress: Option<f64>,
#[serde(default, rename = "startDate")]
pub start_date: Option<String>,
#[serde(default, rename = "targetDate")]
pub target_date: Option<String>,
pub lead: Option<User>,
pub issues: Option<Connection<Issue>>,
#[serde(default)]
pub url: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct Cycle {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub number: Option<f64>,
#[serde(default, rename = "startsAt")]
pub starts_at: Option<String>,
#[serde(default, rename = "endsAt")]
pub ends_at: Option<String>,
#[serde(default)]
pub progress: Option<f64>,
pub issues: Option<Connection<Issue>>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct CustomView {
pub id: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub shared: bool,
#[serde(default, rename = "modelName")]
pub model_name: Option<String>,
#[serde(default)]
pub team: Option<Team>,
#[serde(default)]
pub owner: Option<User>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct Favorite {
pub id: String,
#[serde(rename = "type")]
pub kind: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default, rename = "sortOrder")]
pub sort_order: f64,
#[serde(default)]
pub parent: Option<ParentRef>,
#[serde(default, rename = "folderName")]
pub folder_name: Option<String>,
#[serde(default, rename = "predefinedViewType")]
pub predefined_view_type: Option<String>,
#[serde(default, rename = "predefinedViewTeam")]
pub predefined_view_team: Option<ParentRef>,
#[serde(default, rename = "customView")]
pub custom_view: Option<ParentRef>,
#[serde(default)]
pub issue: Option<IssueRef>,
#[serde(default)]
pub project: Option<Project>,
#[serde(default)]
pub cycle: Option<Cycle>,
}
impl Favorite {
pub fn is_folder(&self) -> bool {
self.kind == "folder"
}
pub fn label(&self) -> String {
self.title
.clone()
.or_else(|| self.folder_name.clone())
.or_else(|| self.project.as_ref().map(|p| p.name.clone()))
.or_else(|| self.issue.as_ref().map(|i| i.title.clone()))
.unwrap_or_else(|| self.kind.clone())
}
}
impl CustomView {
pub fn lists_issues(&self) -> bool {
self.model_name.as_deref().is_none_or(|m| m == "Issue")
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct Viewer {
pub id: String,
pub name: String,
#[serde(default, rename = "displayName")]
pub display_name: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct MutationSuccess {
pub success: bool,
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> String {
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read fixture {path}: {e}"))
}
#[test]
fn deserialize_teams() {
#[derive(Deserialize)]
struct Resp {
teams: Connection<Team>,
}
let resp: Resp = serde_json::from_str(&fixture("teams.json")).unwrap();
assert_eq!(resp.teams.nodes.len(), 2);
assert_eq!(resp.teams.nodes[0].key, "ENG");
}
#[test]
fn deserialize_issues_with_pagination() {
#[derive(Deserialize)]
struct Resp {
issues: Connection<Issue>,
}
let resp: Resp = serde_json::from_str(&fixture("issues.json")).unwrap();
assert_eq!(resp.issues.nodes.len(), 2);
assert!(resp.issues.page_info.has_next_page);
assert_eq!(
resp.issues.page_info.end_cursor.as_deref(),
Some("cursor-abc123")
);
}
#[test]
fn deserialize_issue_url_and_branch_name() {
#[derive(Deserialize)]
struct Resp {
issues: Connection<Issue>,
}
let resp: Resp = serde_json::from_str(&fixture("issues.json")).unwrap();
let issue = &resp.issues.nodes[0];
assert_eq!(
issue.url.as_deref(),
Some("https://linear.app/acme/issue/ENG-100")
);
assert_eq!(
issue.branch_name.as_deref(),
Some("test-user/eng-100-fix-login-bug")
);
}
#[test]
fn deserialize_issue_without_url_fields() {
let issue: Issue = serde_json::from_str(
r#"{"id":"i1","identifier":"ENG-1","title":"t","state":null,"assignee":null,
"description":null,"comments":null,"project":null,"cycle":null}"#,
)
.unwrap();
assert!(issue.url.is_none());
assert!(issue.branch_name.is_none());
}
#[test]
fn deserialize_issue_with_null_fields() {
#[derive(Deserialize)]
struct Resp {
issues: Connection<Issue>,
}
let resp: Resp = serde_json::from_str(&fixture("issues.json")).unwrap();
let issue = &resp.issues.nodes[1];
assert!(issue.assignee.is_none());
assert!(issue.description.is_none());
}
#[test]
fn deserialize_canceled_state_type() {
#[derive(Deserialize)]
struct Resp {
issues: Connection<Issue>,
}
let resp: Resp = serde_json::from_str(&fixture("issues.json")).unwrap();
let state = resp.issues.nodes[1].state.as_ref().unwrap();
assert_eq!(state.state_type, Some(StateType::Cancelled));
}
#[test]
fn deserialize_issue_detail_with_comments() {
#[derive(Deserialize)]
struct Resp {
issue: Issue,
}
let resp: Resp = serde_json::from_str(&fixture("issue_detail.json")).unwrap();
let comments = resp.issue.comments.as_ref().unwrap();
assert_eq!(comments.nodes.len(), 1);
assert_eq!(comments.nodes[0].body, "This is a comment");
assert!(resp.issue.project.is_some());
assert!(resp.issue.cycle.is_some());
}
#[test]
fn deserialize_workflow_states_all_types() {
#[derive(Deserialize)]
struct Resp {
#[serde(rename = "workflowStates")]
workflow_states: Connection<WorkflowState>,
}
let resp: Resp = serde_json::from_str(&fixture("workflow_states.json")).unwrap();
let types: Vec<_> = resp
.workflow_states
.nodes
.iter()
.filter_map(|s| s.state_type)
.collect();
assert!(types.contains(&StateType::Started));
assert!(types.contains(&StateType::Backlog));
assert!(types.contains(&StateType::Cancelled));
assert!(types.contains(&StateType::Unstarted));
assert!(types.contains(&StateType::Completed));
assert!(types.contains(&StateType::Triage));
assert!(types.contains(&StateType::Duplicate));
assert!(types.contains(&StateType::Unknown));
assert_eq!(resp.workflow_states.nodes.len(), 9);
assert!(
resp.workflow_states
.nodes
.iter()
.any(|s| s.state_type.is_none())
);
}
#[test]
fn unknown_state_type_does_not_fail_the_query() {
assert_eq!(
serde_json::from_str::<StateType>("\"duplicate\"").unwrap(),
StateType::Duplicate
);
assert_eq!(
serde_json::from_str::<StateType>("\"somethingNewIn2027\"").unwrap(),
StateType::Unknown
);
}
#[test]
fn deserialize_team_members() {
#[derive(Deserialize)]
struct TeamResp {
team: TeamWithMembers,
}
#[derive(Deserialize)]
struct TeamWithMembers {
members: Connection<User>,
}
let resp: TeamResp = serde_json::from_str(&fixture("team_members.json")).unwrap();
assert_eq!(resp.team.members.nodes.len(), 2);
}
#[test]
fn deserialize_viewer() {
#[derive(Deserialize)]
struct Resp {
viewer: Viewer,
}
let resp: Resp = serde_json::from_str(&fixture("viewer.json")).unwrap();
assert_eq!(resp.viewer.name, "Test User");
}
#[test]
fn deserialize_my_issues_with_float_priority() {
#[derive(Deserialize)]
struct Resp {
issues: Connection<Issue>,
}
let resp: Resp = serde_json::from_str(&fixture("my_issues.json")).unwrap();
assert_eq!(resp.issues.nodes[0].priority, Priority::Medium);
assert_eq!(resp.issues.nodes[1].priority, Priority::None);
let state = resp.issues.nodes[0].state.as_ref().unwrap();
assert_eq!(state.state_type, Some(StateType::Cancelled));
assert!(
resp.issues.nodes[1]
.description
.as_ref()
.unwrap()
.contains("日本語")
);
}
#[test]
fn deserialize_projects() {
#[derive(Deserialize)]
struct TeamResp {
team: TeamWithProjects,
}
#[derive(Deserialize)]
struct TeamWithProjects {
projects: Connection<Project>,
}
let resp: TeamResp = serde_json::from_str(&fixture("projects.json")).unwrap();
assert_eq!(resp.team.projects.nodes.len(), 2);
assert!(resp.team.projects.nodes[0].lead.is_some());
assert!(resp.team.projects.nodes[1].lead.is_none());
assert!(resp.team.projects.nodes[1].start_date.is_none());
}
#[test]
fn deserialize_cycles() {
#[derive(Deserialize)]
struct TeamResp {
team: TeamWithCycles,
}
#[derive(Deserialize)]
struct TeamWithCycles {
cycles: Connection<Cycle>,
}
let resp: TeamResp = serde_json::from_str(&fixture("cycles.json")).unwrap();
assert_eq!(resp.team.cycles.nodes.len(), 2);
assert_eq!(resp.team.cycles.nodes[0].name.as_deref(), Some("Sprint 1"));
assert!(resp.team.cycles.nodes[1].name.is_none());
}
#[test]
fn deserialize_priority_edge_cases() {
assert_eq!(
serde_json::from_str::<Priority>("0").unwrap(),
Priority::None
);
assert_eq!(
serde_json::from_str::<Priority>("1").unwrap(),
Priority::Urgent
);
assert_eq!(
serde_json::from_str::<Priority>("4").unwrap(),
Priority::Low
);
assert_eq!(
serde_json::from_str::<Priority>("2.0").unwrap(),
Priority::High
);
assert_eq!(
serde_json::from_str::<Priority>("99").unwrap(),
Priority::None
);
}
#[test]
fn deserialize_state_type_both_spellings() {
assert_eq!(
serde_json::from_str::<StateType>("\"canceled\"").unwrap(),
StateType::Cancelled
);
assert_eq!(
serde_json::from_str::<StateType>("\"cancelled\"").unwrap(),
StateType::Cancelled
);
}
#[test]
fn deserialize_issue_detail_metadata() {
#[derive(Deserialize)]
struct Resp {
issue: Issue,
}
let resp: Resp = serde_json::from_str(&fixture("issue_detail_full.json")).unwrap();
let issue = resp.issue;
assert_eq!(issue.creator.as_ref().unwrap().name, "Other Person");
assert_eq!(issue.estimate, Some(3.0));
assert_eq!(issue.due_date.as_deref(), Some("2026-10-01"));
assert_eq!(issue.parent.as_ref().unwrap().identifier, "ENG-151");
assert_eq!(issue.children.as_ref().unwrap().nodes.len(), 1);
assert_eq!(issue.project_milestone.as_ref().unwrap().name, "Beta");
assert_eq!(issue.state.as_ref().unwrap().position, Some(2.0));
let comments = &issue.comments.as_ref().unwrap().nodes;
assert!(comments[0].parent.is_none());
assert_eq!(comments[1].parent.as_ref().unwrap().id, "comment-001");
assert!(comments[1].edited_at.is_some());
}
#[test]
fn deserialize_issue_without_the_new_metadata() {
#[derive(Deserialize)]
struct Resp {
issue: Issue,
}
let resp: Resp = serde_json::from_str(&fixture("issue_detail.json")).unwrap();
let issue = resp.issue;
assert!(issue.creator.is_none());
assert!(issue.estimate.is_none());
assert!(issue.due_date.is_none());
assert!(issue.parent.is_none());
assert!(issue.children.is_none());
assert!(issue.project_milestone.is_none());
assert!(issue.comments.unwrap().nodes[0].parent.is_none());
}
#[test]
fn deserialize_custom_views() {
#[derive(Deserialize)]
struct Resp {
#[serde(rename = "customViews")]
custom_views: Connection<CustomView>,
}
let resp: Resp = serde_json::from_str(&fixture("custom_views.json")).unwrap();
let views = resp.custom_views.nodes;
assert_eq!(views.len(), 3);
assert!(!views[0].shared);
assert!(views[1].shared);
assert!(views[0].team.is_none());
assert!(!views[1].team.as_ref().unwrap().cycles_enabled);
assert!(views[1].description.is_none());
assert!(views[0].lists_issues());
assert!(!views[1].lists_issues(), "a project view");
assert!(views[2].lists_issues(), "no modelName: an old issue view");
}
#[test]
fn deserialize_view_issues_with_pagination() {
#[derive(Deserialize)]
struct Resp {
#[serde(rename = "customView")]
custom_view: ViewIssues,
}
#[derive(Deserialize)]
struct ViewIssues {
issues: Connection<Issue>,
}
let resp: Resp = serde_json::from_str(&fixture("view_issues.json")).unwrap();
assert_eq!(resp.custom_view.issues.nodes.len(), 1);
assert_eq!(
resp.custom_view.issues.page_info.end_cursor.as_deref(),
Some("view-cursor-1")
);
}
#[test]
fn a_team_without_the_new_fields_defaults_sensibly() {
#[derive(Deserialize)]
struct Resp {
teams: Connection<Team>,
}
let resp: Resp = serde_json::from_str(&fixture("teams.json")).unwrap();
assert!(resp.teams.nodes[0].color.is_none());
assert!(resp.teams.nodes[0].cycles_enabled);
}
#[test]
fn deserialize_favorites() {
#[derive(Deserialize)]
struct Resp {
favorites: Connection<Favorite>,
}
let resp: Resp = serde_json::from_str(&fixture("favorites.json")).unwrap();
let favs = resp.favorites.nodes;
assert_eq!(favs.len(), 6);
assert_eq!(favs[0].kind, "project");
assert_eq!(favs[0].project.as_ref().unwrap().name, "Q1 Release");
assert_eq!(favs[1].custom_view.as_ref().unwrap().id, "view-001");
assert!(favs[2].is_folder());
assert_eq!(favs[2].label(), "Ops");
assert_eq!(favs[3].parent.as_ref().unwrap().id, "fav-3");
assert_eq!(favs[3].issue.as_ref().unwrap().identifier, "ENG-100");
assert_eq!(favs[4].predefined_view_type.as_deref(), Some("projects"));
assert_eq!(favs[5].label(), "Onboarding doc");
assert!(favs[5].title.is_some());
}
#[test]
fn hex_colours_parse() {
assert_eq!(hex_color("#5e6ad2"), Some(Color::Rgb(0x5e, 0x6a, 0xd2)));
assert_eq!(hex_color("f2c94c"), Some(Color::Rgb(0xf2, 0xc9, 0x4c)));
assert_eq!(hex_color("#fff"), None);
assert_eq!(hex_color("#zzzzzz"), None);
}
}