use std::sync::Arc;
use async_trait::async_trait;
use chrono::{Datelike, NaiveDate};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::fetcher::{FetchContext, FetchError, Fetcher, Safety};
use crate::options::OptionSchema;
use crate::payload::{
BadgeData, Bar, BarsData, Body, CalendarData, EntriesData, Entry, LinkedLine,
LinkedTextBlockData, MarkdownTextBlockData, Payload, Status, TextBlockData, TextData,
TimelineData, TimelineEvent,
};
use crate::render::Shape;
use crate::samples;
use crate::time as t;
use super::client::{graphql_query, resolve_token};
const SHAPES: &[Shape] = &[
Shape::LinkedTextBlock,
Shape::Text,
Shape::TextBlock,
Shape::MarkdownTextBlock,
Shape::Entries,
Shape::Bars,
Shape::Calendar,
Shape::Badge,
Shape::Timeline,
];
const DEFAULT_LIMIT: usize = 10;
const MAX_LIMIT: usize = 100;
const QUERY: &str = r#"
query Issues($filter: IssueFilter, $first: Int) {
issues(filter: $filter, first: $first, orderBy: updatedAt) {
nodes {
identifier
title
priority
priorityLabel
state { name type }
assignee { displayName email }
team { key name }
project { name }
labels { nodes { name } }
dueDate
url
updatedAt
}
}
}
"#;
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "token",
type_hint: "string",
required: false,
default: None,
description: "Linear personal API key (`lin_api_*`). Falls back to `LINEAR_TOKEN` env.",
},
OptionSchema {
name: "filter_status",
type_hint: "\"open\" | \"unstarted\" | \"started\" | \"completed\" | \"canceled\" | \"triage\" | \"backlog\"",
required: false,
default: Some("\"open\""),
description: "Workflow state filter. `open` excludes completed and canceled.",
},
OptionSchema {
name: "filter_assignee",
type_hint: "\"me\" | \"unassigned\" | email | \"any\"",
required: false,
default: Some("\"me\""),
description: "Assignee scope.",
},
OptionSchema {
name: "filter_team",
type_hint: "string",
required: false,
default: None,
description: "Team key (e.g. `\"ENG\"`).",
},
OptionSchema {
name: "filter_project",
type_hint: "string",
required: false,
default: None,
description: "Project name match.",
},
OptionSchema {
name: "filter_priority",
type_hint: "\"urgent\" | \"high\" | \"medium\" | \"low\" | \"none\"",
required: false,
default: None,
description: "Priority filter.",
},
OptionSchema {
name: "filter_due",
type_hint: "\"overdue\" | \"today\" | \"this_week\" | \"no_due\" | \"any\"",
required: false,
default: Some("\"any\""),
description: "Due-date window.",
},
OptionSchema {
name: "filter_label",
type_hint: "string",
required: false,
default: None,
description: "Label name match.",
},
OptionSchema {
name: "group_by",
type_hint: "\"priority\" | \"status\" | \"team\" | \"assignee\" | \"project\"",
required: false,
default: Some("\"priority\""),
description: "Bars grouping. Ignored by other shapes.",
},
OptionSchema {
name: "limit",
type_hint: "integer (1..=100)",
required: false,
default: Some("10"),
description: "Max rows for list-shaped renderers.",
},
];
pub struct LinearIssues;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
#[serde(default)]
token: Option<String>,
#[serde(default)]
filter_status: Option<String>,
#[serde(default)]
filter_assignee: Option<String>,
#[serde(default)]
filter_team: Option<String>,
#[serde(default)]
filter_project: Option<String>,
#[serde(default)]
filter_priority: Option<String>,
#[serde(default)]
filter_due: Option<String>,
#[serde(default)]
filter_label: Option<String>,
#[serde(default)]
group_by: Option<String>,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct ApiResponse {
issues: ApiConnection<ApiIssue>,
}
#[derive(Debug, Deserialize)]
struct ApiConnection<T> {
nodes: Vec<T>,
}
#[derive(Debug, Deserialize)]
struct ApiIssue {
identifier: String,
title: String,
priority: f32,
#[serde(rename = "priorityLabel")]
priority_label: Option<String>,
state: ApiState,
#[serde(default)]
assignee: Option<ApiUser>,
#[serde(default)]
team: Option<ApiTeam>,
#[serde(default)]
project: Option<ApiProject>,
#[serde(default)]
labels: Option<ApiConnection<ApiLabel>>,
#[serde(rename = "dueDate", default)]
due_date: Option<String>,
url: String,
#[serde(rename = "updatedAt")]
updated_at: String,
}
#[derive(Debug, Deserialize)]
struct ApiState {
name: String,
#[serde(rename = "type")]
state_type: String,
}
#[derive(Debug, Deserialize)]
struct ApiUser {
#[serde(rename = "displayName", default)]
display_name: Option<String>,
#[serde(default)]
email: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ApiTeam {
key: String,
name: String,
}
#[derive(Debug, Deserialize)]
struct ApiProject {
name: String,
}
#[derive(Debug, Deserialize)]
struct ApiLabel {
name: String,
}
#[derive(Debug)]
struct IssueView {
identifier: String,
title: String,
priority: u8,
priority_label: String,
state_name: String,
state_type: String,
assignee: Option<String>,
team_name: Option<String>,
project: Option<String>,
labels: Vec<String>,
due_date: Option<NaiveDate>,
url: String,
updated_at: i64,
}
#[async_trait]
impl Fetcher for LinearIssues {
fn name(&self) -> &str {
"linear_issues"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Linear issues snapshot with filter-first options (status, assignee, team, project, priority, due window, label). `Bars` groups by `group_by` (default priority). `Calendar` highlights due dates; list shapes link each row to the Linear issue."
}
fn refresh_interval(&self) -> u64 {
60 * 10
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn default_shape(&self) -> Shape {
Shape::LinkedTextBlock
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn cache_key(&self, ctx: &FetchContext) -> String {
super::cache_key("linear_issues", ctx)
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
Some(match shape {
Shape::LinkedTextBlock => samples::linked_text_block(&[
(
"ENG-123 Fix login flow [In Progress] · today · P1",
Some("https://linear.app/acme/issue/ENG-123"),
),
(
"ENG-118 Polish dashboard layout [Todo] · P3",
Some("https://linear.app/acme/issue/ENG-118"),
),
]),
Shape::Text => samples::text("7 issues"),
Shape::TextBlock => samples::text_block(&[
"ENG-123 Fix login flow [In Progress] · today · P1",
"ENG-118 Polish dashboard layout [Todo] · P3",
]),
Shape::MarkdownTextBlock => samples::markdown(
"- **ENG-123** Fix login flow *[In Progress]* · today · P1\n- **ENG-118** Polish dashboard layout *[Todo]* · P3",
),
Shape::Entries => samples::entries(&[
("ENG-123", "Fix login flow"),
("ENG-118", "Polish dashboard layout"),
]),
Shape::Bars => samples::bars(&[("Urgent", 1), ("High", 3), ("Medium", 2), ("Low", 1)]),
Shape::Calendar => samples::calendar(2026, 4, Some(29), &[2, 14, 22]),
Shape::Badge => samples::badge(Status::Warn, "linear 7"),
Shape::Timeline => samples::timeline(&[
(
1_745_896_800,
"ENG-123 Fix login flow",
Some("In Progress · P1"),
),
(
1_745_810_400,
"ENG-118 Polish dashboard layout",
Some("Todo · P3"),
),
]),
_ => return None,
})
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let opts: Options = parse_options(ctx.options.as_ref())?;
let token = resolve_token(opts.token.as_deref())?;
let now = t::now_in(ctx.timezone.as_deref());
let today = now.date_naive();
let filter = build_filter(&opts, today);
let limit = opts.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
let response: ApiResponse = graphql_query(
&token,
QUERY,
json!({ "filter": filter, "first": limit as i64 }),
)
.await?;
let mut views: Vec<IssueView> = response.issues.nodes.into_iter().map(to_view).collect();
views.sort_by(cmp_views);
let shape = ctx.shape.unwrap_or(Shape::LinkedTextBlock);
Ok(payload(render_body(&views, shape, &opts, limit)))
}
}
pub fn fetcher() -> Arc<dyn Fetcher> {
Arc::new(LinearIssues)
}
fn parse_options(raw: Option<&toml::Value>) -> Result<Options, FetchError> {
raw.map(|v| {
v.clone()
.try_into::<Options>()
.map_err(|e| FetchError::Failed(format!("invalid options: {e}")))
})
.unwrap_or_else(|| Ok(Options::default()))
}
fn build_filter(opts: &Options, today: NaiveDate) -> Value {
let mut clauses: Vec<(&str, Value)> = Vec::new();
if let Some(v) = state_filter(opts.filter_status.as_deref()) {
clauses.push(("state", v));
}
if let Some(v) = assignee_filter(opts.filter_assignee.as_deref()) {
clauses.push(("assignee", v));
}
if let Some(team) = opts.filter_team.as_deref().filter(|s| !s.is_empty()) {
clauses.push(("team", json!({ "key": { "eq": team } })));
}
if let Some(project) = opts.filter_project.as_deref().filter(|s| !s.is_empty()) {
clauses.push(("project", json!({ "name": { "eq": project } })));
}
if let Some(p) = priority_filter(opts.filter_priority.as_deref()) {
clauses.push(("priority", p));
}
if let Some(v) = due_filter(opts.filter_due.as_deref(), today) {
clauses.push(("dueDate", v));
}
if let Some(label) = opts.filter_label.as_deref().filter(|s| !s.is_empty()) {
clauses.push(("labels", json!({ "name": { "eq": label } })));
}
let mut map = serde_json::Map::with_capacity(clauses.len());
for (k, v) in clauses {
map.insert(k.into(), v);
}
Value::Object(map)
}
fn state_filter(raw: Option<&str>) -> Option<Value> {
let kind = raw.unwrap_or("open").trim().to_lowercase();
match kind.as_str() {
"any" => None,
"open" => Some(json!({ "type": { "nin": ["completed", "canceled"] } })),
"unstarted" | "started" | "completed" | "canceled" | "triage" | "backlog" => {
Some(json!({ "type": { "eq": kind } }))
}
_ => Some(json!({ "name": { "eq": raw.unwrap_or_default() } })),
}
}
fn assignee_filter(raw: Option<&str>) -> Option<Value> {
match raw.unwrap_or("me").trim().to_lowercase().as_str() {
"me" => Some(json!({ "isMe": { "eq": true } })),
"unassigned" => Some(json!({ "null": true })),
"any" => None,
other => Some(json!({ "email": { "eq": other } })),
}
}
fn priority_filter(raw: Option<&str>) -> Option<Value> {
let v = raw?.trim().to_lowercase();
match v.as_str() {
"none" => Some(json!({ "eq": 0 })),
"urgent" => Some(json!({ "eq": 1 })),
"high" => Some(json!({ "eq": 2 })),
"medium" => Some(json!({ "eq": 3 })),
"low" => Some(json!({ "eq": 4 })),
_ => v.parse::<u8>().ok().map(|n| json!({ "eq": n })),
}
}
fn due_filter(raw: Option<&str>, today: NaiveDate) -> Option<Value> {
let kind = raw.unwrap_or("any").trim().to_lowercase();
let iso = today.format("%Y-%m-%d").to_string();
let week = (today + chrono::Duration::days(7))
.format("%Y-%m-%d")
.to_string();
match kind.as_str() {
"any" => None,
"no_due" => Some(json!({ "null": true })),
"overdue" => Some(json!({ "lt": iso })),
"today" => Some(json!({ "eq": iso })),
"this_week" => Some(json!({ "gte": iso, "lte": week })),
_ => None,
}
}
fn to_view(api: ApiIssue) -> IssueView {
IssueView {
identifier: api.identifier,
title: api.title,
priority: priority_to_u8(api.priority),
priority_label: api.priority_label.unwrap_or_else(|| "—".into()),
state_name: api.state.name,
state_type: api.state.state_type,
assignee: api.assignee.and_then(|a| a.display_name.or(a.email)),
team_name: api.team.map(|t| t.name),
project: api.project.map(|p| p.name),
labels: api
.labels
.map(|c| c.nodes.into_iter().map(|l| l.name).collect())
.unwrap_or_default(),
due_date: api
.due_date
.as_deref()
.and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok()),
url: api.url,
updated_at: parse_timestamp(&api.updated_at),
}
}
fn priority_to_u8(p: f32) -> u8 {
let n = p.round() as i32;
n.clamp(0, 4) as u8
}
fn parse_timestamp(raw: &str) -> i64 {
chrono::DateTime::parse_from_rfc3339(raw)
.map(|dt| dt.timestamp())
.unwrap_or(0)
}
fn cmp_views(a: &IssueView, b: &IssueView) -> std::cmp::Ordering {
priority_rank(a.priority)
.cmp(&priority_rank(b.priority))
.then_with(|| due_rank(a.due_date).cmp(&due_rank(b.due_date)))
.then_with(|| b.updated_at.cmp(&a.updated_at))
}
fn priority_rank(p: u8) -> u8 {
match p {
0 => 5,
n => n,
}
}
fn due_rank(d: Option<NaiveDate>) -> i64 {
d.map(|x| x.num_days_from_ce() as i64).unwrap_or(i64::MAX)
}
fn render_body(views: &[IssueView], shape: Shape, opts: &Options, limit: usize) -> Body {
match shape {
Shape::Text => Body::Text(TextData {
value: format!("{} issues", views.len()),
}),
Shape::TextBlock => Body::TextBlock(TextBlockData {
lines: views.iter().take(limit).map(line_for).collect(),
}),
Shape::MarkdownTextBlock => Body::MarkdownTextBlock(MarkdownTextBlockData {
value: views
.iter()
.take(limit)
.map(markdown_line_for)
.collect::<Vec<_>>()
.join("\n"),
}),
Shape::LinkedTextBlock => Body::LinkedTextBlock(LinkedTextBlockData {
items: views
.iter()
.take(limit)
.map(|v| LinkedLine {
text: line_for(v),
url: Some(v.url.clone()),
})
.collect(),
}),
Shape::Entries => Body::Entries(EntriesData {
items: views
.iter()
.take(limit)
.map(|v| Entry {
key: v.identifier.clone(),
value: Some(v.title.clone()),
status: state_to_status(&v.state_type),
})
.collect(),
}),
Shape::Bars => bars_body(views, opts.group_by.as_deref()),
Shape::Calendar => calendar_body(views),
Shape::Badge => Body::Badge(BadgeData {
status: badge_status(views.len()),
label: format!("linear {}", views.len()),
}),
Shape::Timeline => Body::Timeline(TimelineData {
events: views
.iter()
.take(limit)
.map(|v| TimelineEvent {
timestamp: v.updated_at,
title: format!("{} {}", v.identifier, v.title),
detail: Some(format!("{} · P{}", v.state_name, v.priority)),
status: state_to_status(&v.state_type),
})
.collect(),
}),
_ => Body::TextBlock(TextBlockData {
lines: views.iter().take(limit).map(line_for).collect(),
}),
}
}
fn line_for(v: &IssueView) -> String {
let due = v
.due_date
.map(|d| format!(" · due {:02}-{:02}", d.month(), d.day()))
.unwrap_or_default();
format!(
"{} {} [{}]{} · P{}",
v.identifier, v.title, v.state_name, due, v.priority
)
}
fn markdown_line_for(v: &IssueView) -> String {
let due = v
.due_date
.map(|d| format!(" · due {:02}-{:02}", d.month(), d.day()))
.unwrap_or_default();
format!(
"- **{}** {} *[{}]*{} · P{}",
v.identifier, v.title, v.state_name, due, v.priority
)
}
fn state_to_status(state_type: &str) -> Option<Status> {
match state_type {
"completed" => Some(Status::Ok),
"started" => Some(Status::Warn),
"canceled" => Some(Status::Error),
_ => None,
}
}
fn badge_status(total: usize) -> Status {
match total {
0 => Status::Ok,
1..=3 => Status::Ok,
4..=9 => Status::Warn,
_ => Status::Error,
}
}
fn bars_body(views: &[IssueView], group_by: Option<&str>) -> Body {
let key = group_by.unwrap_or("priority").trim().to_lowercase();
let extract: Box<dyn Fn(&IssueView) -> String> = match key.as_str() {
"status" => Box::new(|v| v.state_name.clone()),
"team" => Box::new(|v| v.team_name.clone().unwrap_or_else(|| "—".into())),
"assignee" => Box::new(|v| v.assignee.clone().unwrap_or_else(|| "Unassigned".into())),
"project" => Box::new(|v| v.project.clone().unwrap_or_else(|| "—".into())),
_ => Box::new(|v| v.priority_label.clone()),
};
Body::Bars(BarsData {
bars: tally(views, extract.as_ref()),
})
}
fn tally(views: &[IssueView], extract: &dyn Fn(&IssueView) -> String) -> Vec<Bar> {
let mut counts: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
for v in views {
*counts.entry(extract(v)).or_insert(0) += 1;
}
let mut bars: Vec<Bar> = counts
.into_iter()
.map(|(label, value)| Bar {
label,
value,
value_label: None,
})
.collect();
bars.sort_by(|a, b| b.value.cmp(&a.value).then_with(|| a.label.cmp(&b.label)));
bars
}
fn calendar_body(views: &[IssueView]) -> Body {
let now = t::now_in(None);
let today = now.date_naive();
let events: Vec<u8> = views
.iter()
.filter_map(|v| v.due_date)
.filter(|d| d.year() == today.year() && d.month() == today.month())
.map(|d| d.day() as u8)
.collect();
Body::Calendar(CalendarData {
year: today.year(),
month: today.month() as u8,
day: Some(today.day() as u8),
events,
})
}
fn payload(body: Body) -> Payload {
Payload {
icon: None,
status: None,
format: None,
body,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn options(raw: &str) -> Options {
let value: toml::Value = toml::from_str(raw).unwrap();
parse_options(Some(&value)).unwrap()
}
#[test]
fn fetcher_contract_and_samples_cover_catalog_surface() {
let fetcher = LinearIssues;
assert_eq!(fetcher.name(), "linear_issues");
assert_eq!(fetcher.safety(), Safety::Safe);
assert_eq!(fetcher.default_shape(), Shape::LinkedTextBlock);
assert_eq!(fetcher.shapes(), SHAPES);
assert_eq!(fetcher.option_schemas().len(), OPTION_SCHEMAS.len());
assert_eq!(fetcher.option_schemas()[0].name, "token");
assert_eq!(fetcher.option_schemas().last().unwrap().name, "limit");
for &shape in SHAPES {
assert!(fetcher.sample_body(shape).is_some());
}
assert!(fetcher.sample_body(Shape::Image).is_none());
}
#[test]
fn parse_options_and_build_filter_cover_custom_state_project_label_and_today() {
let raw: toml::Value = toml::from_str("bogus = 1").unwrap();
assert!(matches!(
parse_options(Some(&raw)),
Err(FetchError::Failed(message)) if message.contains("invalid options")
));
let opts = options(
r#"
filter_status = "Needs Review"
filter_assignee = "any"
filter_project = "Roadmap"
filter_due = "today"
filter_label = "bug"
"#,
);
let today = NaiveDate::from_ymd_opt(2026, 4, 29).unwrap();
let filter = build_filter(&opts, today);
assert_eq!(filter["state"]["name"]["eq"], json!("Needs Review"));
assert!(filter.get("assignee").is_none(), "got: {filter}");
assert_eq!(filter["project"]["name"]["eq"], json!("Roadmap"));
assert_eq!(filter["dueDate"]["eq"], json!("2026-04-29"));
assert_eq!(filter["labels"]["name"]["eq"], json!("bug"));
}
#[test]
fn helper_filters_cover_numeric_priority_due_variants_and_unknowns() {
let today = NaiveDate::from_ymd_opt(2026, 4, 29).unwrap();
assert!(assignee_filter(Some("any")).is_none());
assert_eq!(priority_filter(Some(" 3 ")).unwrap()["eq"], json!(3));
assert!(priority_filter(Some("mystery")).is_none());
assert_eq!(
due_filter(Some("no_due"), today).unwrap()["null"],
json!(true)
);
assert!(due_filter(Some("someday"), today).is_none());
assert_eq!(badge_status(4), Status::Warn);
assert_eq!(state_to_status("triage"), None);
}
#[test]
fn to_view_and_formatters_cover_optional_and_invalid_fields() {
let view = to_view(ApiIssue {
identifier: "ENG-42".into(),
title: "Ship it".into(),
priority: 9.7,
priority_label: None,
state: ApiState {
name: "Todo".into(),
state_type: "unstarted".into(),
},
assignee: Some(ApiUser {
display_name: None,
email: Some("ops@example.com".into()),
}),
team: Some(ApiTeam {
key: "ENG".into(),
name: "Engineering".into(),
}),
project: Some(ApiProject {
name: "Roadmap".into(),
}),
labels: Some(ApiConnection {
nodes: vec![
ApiLabel { name: "bug".into() },
ApiLabel { name: "ops".into() },
],
}),
due_date: Some("not-a-date".into()),
url: "https://linear.app/acme/issue/ENG-42".into(),
updated_at: "not-rfc3339".into(),
});
assert_eq!(priority_to_u8(-1.2), 0);
assert_eq!(view.priority, 4);
assert_eq!(view.priority_label, "—");
assert_eq!(view.assignee.as_deref(), Some("ops@example.com"));
assert_eq!(view.team_name.as_deref(), Some("Engineering"));
assert_eq!(view.project.as_deref(), Some("Roadmap"));
assert_eq!(view.labels, vec!["bug", "ops"]);
assert!(view.due_date.is_none());
assert_eq!(view.updated_at, 0);
let due = NaiveDate::from_ymd_opt(2026, 12, 5).unwrap();
let due_view = view_for_test("ENG-9", 3, Some(due), 0);
assert_eq!(line_for(&due_view), "ENG-9 title [Todo] · due 12-05 · P3");
assert_eq!(
markdown_line_for(&due_view),
"- **ENG-9** title *[Todo]* · due 12-05 · P3"
);
}
#[test]
fn bars_and_render_fallback_cover_remaining_groupings() {
let mut team_a = view_for_test("ENG-1", 1, None, 0);
team_a.team_name = Some("Platform".into());
let team_b = view_for_test("ENG-2", 1, None, 0);
let mut team_c = view_for_test("ENG-3", 1, None, 0);
team_c.team_name = Some("Platform".into());
let Body::Bars(team_bars) = bars_body(&[team_a, team_b, team_c], Some("team")) else {
panic!("expected team bars");
};
assert_eq!(team_bars.bars[0].label, "Platform");
assert_eq!(team_bars.bars[0].value, 2);
assert_eq!(team_bars.bars[1].label, "—");
let mut assignee_a = view_for_test("ENG-4", 1, None, 0);
assignee_a.assignee = Some("Amy".into());
let assignee_b = view_for_test("ENG-5", 1, None, 0);
let Body::Bars(assignee_bars) = bars_body(&[assignee_a, assignee_b], Some("assignee"))
else {
panic!("expected assignee bars");
};
assert_eq!(assignee_bars.bars[0].label, "Amy");
assert_eq!(assignee_bars.bars[1].label, "Unassigned");
let mut project_a = view_for_test("ENG-6", 1, None, 0);
project_a.project = Some("Atlas".into());
let project_b = view_for_test("ENG-7", 1, None, 0);
let Body::Bars(project_bars) = bars_body(&[project_a, project_b], Some("project")) else {
panic!("expected project bars");
};
assert_eq!(project_bars.bars[0].label, "Atlas");
assert_eq!(project_bars.bars[1].label, "—");
let views = vec![view_for_test("ENG-8", 2, None, 0)];
let body = render_body(&views, Shape::Image, &Options::default(), 10);
let Body::TextBlock(text) = body else {
panic!("expected text fallback");
};
assert_eq!(text.lines, vec![line_for(&views[0])]);
}
#[test]
fn state_filter_open_excludes_completed_and_canceled() {
let v = state_filter(Some("open")).unwrap();
assert_eq!(
v["type"]["nin"],
json!(["completed", "canceled"]),
"got: {v}"
);
}
#[test]
fn state_filter_named_state_uses_eq() {
let v = state_filter(Some("started")).unwrap();
assert_eq!(v["type"]["eq"], json!("started"));
}
#[test]
fn state_filter_any_means_no_filter() {
assert!(state_filter(Some("any")).is_none());
}
#[test]
fn assignee_filter_me_uses_isme() {
let v = assignee_filter(Some("me")).unwrap();
assert_eq!(v["isMe"]["eq"], json!(true));
}
#[test]
fn assignee_filter_unassigned_uses_null() {
let v = assignee_filter(Some("unassigned")).unwrap();
assert_eq!(v["null"], json!(true));
}
#[test]
fn assignee_filter_email_uses_eq() {
let v = assignee_filter(Some("a@b.com")).unwrap();
assert_eq!(v["email"]["eq"], json!("a@b.com"));
}
#[test]
fn priority_filter_named_to_numeric() {
assert_eq!(priority_filter(Some("urgent")).unwrap()["eq"], json!(1));
assert_eq!(priority_filter(Some("low")).unwrap()["eq"], json!(4));
assert_eq!(priority_filter(Some("none")).unwrap()["eq"], json!(0));
}
#[test]
fn due_filter_overdue_uses_lt_today() {
let today = NaiveDate::from_ymd_opt(2026, 4, 29).unwrap();
let v = due_filter(Some("overdue"), today).unwrap();
assert_eq!(v["lt"], json!("2026-04-29"));
}
#[test]
fn due_filter_this_week_spans_seven_days() {
let today = NaiveDate::from_ymd_opt(2026, 4, 29).unwrap();
let v = due_filter(Some("this_week"), today).unwrap();
assert_eq!(v["gte"], json!("2026-04-29"));
assert_eq!(v["lte"], json!("2026-05-06"));
}
#[test]
fn build_filter_combines_clauses() {
let opts = options(
"filter_status = \"started\"\nfilter_team = \"ENG\"\nfilter_priority = \"high\"\n",
);
let f = build_filter(&opts, NaiveDate::from_ymd_opt(2026, 4, 29).unwrap());
assert_eq!(f["state"]["type"]["eq"], json!("started"));
assert_eq!(f["team"]["key"]["eq"], json!("ENG"));
assert_eq!(f["priority"]["eq"], json!(2));
assert_eq!(f["assignee"]["isMe"]["eq"], json!(true));
}
#[test]
fn cmp_views_promotes_urgent_above_high() {
let urgent = view_for_test("ENG-1", 1, None, 0);
let high = view_for_test("ENG-2", 2, None, 0);
let mut list = [high, urgent];
list.sort_by(cmp_views);
assert_eq!(list[0].identifier, "ENG-1");
}
#[test]
fn cmp_views_demotes_no_priority_below_low() {
let none = view_for_test("ENG-1", 0, None, 0);
let low = view_for_test("ENG-2", 4, None, 0);
let mut list = [none, low];
list.sort_by(cmp_views);
assert_eq!(list[0].identifier, "ENG-2");
}
#[test]
fn render_text_emits_count() {
let views = vec![view_for_test("ENG-1", 1, None, 0)];
let body = render_body(&views, Shape::Text, &Options::default(), 10);
let Body::Text(t) = body else {
panic!("expected text");
};
assert!(t.value.contains("1"));
}
#[test]
fn render_linked_text_block_includes_url() {
let views = vec![view_for_test("ENG-1", 1, None, 0)];
let body = render_body(&views, Shape::LinkedTextBlock, &Options::default(), 10);
let Body::LinkedTextBlock(b) = body else {
panic!("expected linked");
};
assert!(b.items[0].url.as_deref().unwrap().contains("ENG-1"));
}
#[test]
fn render_bars_groups_by_priority_label_by_default() {
let views = vec![
view_for_test("ENG-1", 1, None, 0),
view_for_test("ENG-2", 1, None, 0),
view_for_test("ENG-3", 2, None, 0),
];
let body = render_body(&views, Shape::Bars, &Options::default(), 10);
let Body::Bars(b) = body else {
panic!("expected bars");
};
let urgent = b.bars.iter().find(|x| x.label == "Urgent").unwrap();
assert_eq!(urgent.value, 2);
}
#[test]
fn render_badge_status_scales_with_count() {
let one = view_for_test("E-1", 1, None, 0);
let body = render_body(&[one], Shape::Badge, &Options::default(), 10);
let Body::Badge(b) = body else {
panic!("expected badge");
};
assert_eq!(b.status, Status::Ok);
let many: Vec<IssueView> = (0..15).map(|i| view_for_test("E", 1, None, i)).collect();
let body = render_body(&many, Shape::Badge, &Options::default(), 10);
let Body::Badge(b) = body else {
panic!("expected badge");
};
assert_eq!(b.status, Status::Error);
}
fn view_for_test(id: &str, priority: u8, due: Option<NaiveDate>, ts: i64) -> IssueView {
IssueView {
identifier: id.into(),
title: "title".into(),
priority,
priority_label: match priority {
1 => "Urgent".into(),
2 => "High".into(),
3 => "Medium".into(),
4 => "Low".into(),
_ => "No priority".into(),
},
state_name: "Todo".into(),
state_type: "unstarted".into(),
assignee: None,
team_name: None,
project: None,
labels: vec![],
due_date: due,
url: format!("https://linear.app/acme/issue/{id}"),
updated_at: ts,
}
}
#[test]
fn render_text_block_caps_at_limit() {
let views: Vec<IssueView> = (0..15)
.map(|i| view_for_test(&format!("ENG-{i}"), 1, None, 0))
.collect();
let body = render_body(&views, Shape::TextBlock, &Options::default(), 5);
let Body::TextBlock(b) = body else {
panic!("expected text_block");
};
assert_eq!(b.lines.len(), 5);
}
#[test]
fn render_markdown_emboldens_identifier() {
let views = vec![view_for_test("ENG-7", 2, None, 0)];
let body = render_body(&views, Shape::MarkdownTextBlock, &Options::default(), 10);
let Body::MarkdownTextBlock(b) = body else {
panic!("expected markdown");
};
assert!(b.value.contains("**ENG-7**"), "got: {}", b.value);
assert!(b.value.contains("*[Todo]*"), "got: {}", b.value);
}
#[test]
fn render_entries_maps_state_type_to_status() {
let mut started = view_for_test("ENG-1", 2, None, 0);
started.state_type = "started".into();
let mut completed = view_for_test("ENG-2", 2, None, 0);
completed.state_type = "completed".into();
let mut canceled = view_for_test("ENG-3", 2, None, 0);
canceled.state_type = "canceled".into();
let body = render_body(
&[started, completed, canceled],
Shape::Entries,
&Options::default(),
10,
);
let Body::Entries(e) = body else {
panic!("expected entries");
};
assert_eq!(e.items[0].status, Some(Status::Warn));
assert_eq!(e.items[1].status, Some(Status::Ok));
assert_eq!(e.items[2].status, Some(Status::Error));
}
#[test]
fn render_calendar_filters_due_dates_to_current_month() {
let now = t::now_in(None).date_naive();
let in_month = NaiveDate::from_ymd_opt(now.year(), now.month(), 5).unwrap();
let last_year = NaiveDate::from_ymd_opt(now.year() - 1, now.month(), 9).unwrap();
let views = vec![
view_for_test("ENG-1", 1, Some(in_month), 0),
view_for_test("ENG-2", 2, Some(last_year), 0),
view_for_test("ENG-3", 3, None, 0),
];
let body = render_body(&views, Shape::Calendar, &Options::default(), 10);
let Body::Calendar(c) = body else {
panic!("expected calendar");
};
assert_eq!(c.year, now.year());
assert!(c.events.contains(&5));
assert!(!c.events.contains(&9));
}
#[test]
fn render_timeline_carries_state_status_and_priority_detail() {
let mut started = view_for_test("ENG-1", 1, None, 1_700_000_000);
started.state_type = "started".into();
started.state_name = "In Progress".into();
let body = render_body(&[started], Shape::Timeline, &Options::default(), 10);
let Body::Timeline(t) = body else {
panic!("expected timeline");
};
assert_eq!(t.events[0].status, Some(Status::Warn));
assert_eq!(t.events[0].timestamp, 1_700_000_000);
let detail = t.events[0].detail.as_deref().unwrap();
assert!(detail.contains("In Progress"), "got: {detail}");
assert!(detail.contains("P1"), "got: {detail}");
}
#[test]
fn bars_group_by_status_uses_state_name() {
let mut a = view_for_test("ENG-1", 1, None, 0);
a.state_name = "In Progress".into();
let mut b = view_for_test("ENG-2", 1, None, 0);
b.state_name = "Todo".into();
let mut c = view_for_test("ENG-3", 1, None, 0);
c.state_name = "Todo".into();
let opts = Options {
group_by: Some("status".into()),
..Default::default()
};
let body = render_body(&[a, b, c], Shape::Bars, &opts, 10);
let Body::Bars(bars) = body else {
panic!("expected bars");
};
let todo = bars.bars.iter().find(|x| x.label == "Todo").unwrap();
assert_eq!(todo.value, 2);
}
#[test]
fn cmp_views_breaks_ties_with_due_date_then_recency() {
let dec1 = NaiveDate::from_ymd_opt(2026, 12, 1).unwrap();
let dec5 = NaiveDate::from_ymd_opt(2026, 12, 5).unwrap();
let earlier = view_for_test("ENG-A", 2, Some(dec5), 100);
let later = view_for_test("ENG-B", 2, Some(dec1), 50);
let mut list = [earlier, later];
list.sort_by(cmp_views);
assert_eq!(list[0].identifier, "ENG-B");
}
}