use std::cmp::Ordering;
use chrono::{DateTime, Utc};
use onetaskgraph_plugin_api::asset_sha256;
use serde::Serialize;
use serde_json::Value;
pub const PREFIX: &str = "lin1|";
pub const PAGE: usize = 10;
pub const DEFAULT_LABEL_PAGE: usize = 50;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Spelling {
Plain,
Attached,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Part {
Comments,
Relations,
Inverse,
}
impl Part {
const ALL: [Self; 3] = [Self::Comments, Self::Relations, Self::Inverse];
fn tag(self) -> &'static str {
match self {
Self::Comments => "c:",
Self::Relations => "r:",
Self::Inverse => "i:",
}
}
}
type LabelFacet<'a> = (&'a str, &'a str, Option<&'a str>);
type NamedType<'a> = (&'a str, &'a str);
#[derive(Serialize)]
struct TaskFacets<'a> {
key: &'a str,
labels: Vec<LabelFacet<'a>>,
project: Option<&'a str>,
state: NamedType<'a>,
url: Option<&'a str>,
}
#[derive(Serialize)]
struct ProjectFacets<'a> {
labels: Vec<LabelFacet<'a>>,
status: NamedType<'a>,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(transparent)]
struct Sha256Hex(String);
impl Sha256Hex {
fn of(bytes: &[u8]) -> Self {
Self(asset_sha256(bytes))
}
fn parse(text: &str) -> Option<Self> {
(text.len() == 64
&& text
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
.then(|| Self(text.to_owned()))
}
}
impl std::fmt::Display for Sha256Hex {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Serialize)]
struct DocumentFacets<'a> {
content: Sha256Hex,
project: Option<&'a str>,
title: &'a str,
url: &'a str,
}
fn digest(facets: &impl Serialize) -> Option<Sha256Hex> {
Some(Sha256Hex::of(
serde_json::to_string(facets).ok()?.as_bytes(),
))
}
fn text<'a>(item: &'a Value, key: &str) -> Option<&'a str> {
item.get(key)?.as_str()
}
fn nullable_text<'a>(item: &'a Value, key: &str) -> Option<Option<&'a str>> {
match item.get(key)? {
Value::Null => Some(None),
Value::String(text) => Some(Some(text)),
_ => None,
}
}
fn labels(item: &Value) -> Option<Vec<LabelFacet<'_>>> {
let nodes = item.pointer("/labels/nodes")?.as_array()?;
if nodes.len() >= DEFAULT_LABEL_PAGE {
return None;
}
let mut labels = nodes
.iter()
.map(|label| {
Some((
text(label, "id")?,
text(label, "name")?,
nullable_text(label, "color")?,
))
})
.collect::<Option<Vec<_>>>()?;
labels.sort_unstable();
Some(labels)
}
fn project(item: &Value) -> Option<Option<&str>> {
match item.get("project")? {
Value::Null => Some(None),
project => Some(Some(text(project, "id")?)),
}
}
fn named_type(status: &Value) -> Option<NamedType<'_>> {
Some((text(status, "name")?, text(status, "type")?))
}
fn instant(value: &Value) -> Option<String> {
let at: DateTime<Utc> = value.as_str()?.parse().ok()?;
Some(at.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true))
}
fn updated(item: &Value) -> Option<String> {
instant(item.get("updatedAt")?)
}
fn whole_page<'a>(item: &'a Value, key: &str) -> Option<&'a Vec<Value>> {
let page = item.get(key)?;
let info = page.get("pageInfo")?;
let more = info
.get("hasNextPage")
.or_else(|| info.get("hasPreviousPage"))?;
if more != &Value::Bool(false) {
return None;
}
let nodes = page.get("nodes")?.as_array()?;
(nodes.len() <= PAGE).then_some(nodes)
}
fn rows_digest<T: Ord + Serialize>(mut rows: Vec<T>) -> Option<Sha256Hex> {
rows.sort_unstable();
Some(Sha256Hex::of(serde_json::to_string(&rows).ok()?.as_bytes()))
}
fn relation_digest(item: &Value, key: &str, far: &str) -> Option<Sha256Hex> {
rows_digest(
whole_page(item, key)?
.iter()
.map(|node| {
Some((
node.get("id")?.as_str()?,
node.get("type")?.as_str()?,
node.get(far)?.get("id")?.as_str()?,
))
})
.collect::<Option<Vec<_>>>()?,
)
}
fn comment_digest(issue: &Value) -> Option<Sha256Hex> {
rows_digest(
whole_page(issue, "comments")?
.iter()
.map(|node| Some((node.get("id")?.as_str()?, instant(node.get("updatedAt")?)?)))
.collect::<Option<Vec<_>>>()?,
)
}
fn task_part(issue: &Value, part: Part) -> Option<Sha256Hex> {
match part {
Part::Comments => comment_digest(issue),
Part::Relations => relation_digest(issue, "relations", "relatedIssue"),
Part::Inverse => relation_digest(issue, "inverseRelations", "issue"),
}
}
fn task_base(issue: &Value) -> Option<String> {
let facets = TaskFacets {
key: text(issue, "identifier")?,
labels: labels(issue)?,
project: project(issue)?,
state: named_type(issue.get("state")?)?,
url: nullable_text(issue, "url")?,
};
Some(format!("{PREFIX}{}|{}", updated(issue)?, digest(&facets)?))
}
fn with_parts(
base: String,
parts: &[Part],
read: impl Fn(Part) -> Option<Sha256Hex>,
) -> Option<String> {
let mut token = base;
for part in parts {
token.push('|');
token.push_str(part.tag());
token.push_str(&read(*part)?.0);
}
Some(token)
}
#[must_use]
pub fn task(issue: &Value, spelling: Spelling) -> Option<String> {
let base = task_base(issue)?;
match spelling {
Spelling::Attached => with_parts(base, &Part::ALL, |part| task_part(issue, part)),
Spelling::Plain => Some(base),
}
}
#[must_use]
pub fn task_before_relations(issue: &Value) -> Option<String> {
with_parts(task_base(issue)?, &[Part::Comments], |part| {
task_part(issue, part)
})
}
#[must_use]
pub fn project_token(project: &Value, spelling: Spelling) -> Option<String> {
let base = project_before_relations(project)?;
match spelling {
Spelling::Attached => {
with_parts(base, &[Part::Relations, Part::Inverse], |part| match part {
Part::Inverse => relation_digest(project, "inverseRelations", "project"),
_ => relation_digest(project, "relations", "relatedProject"),
})
}
Spelling::Plain => Some(base),
}
}
#[must_use]
pub fn project_before_relations(project: &Value) -> Option<String> {
let facets = ProjectFacets {
labels: labels(project)?,
status: named_type(project.get("status")?)?,
};
Some(format!(
"{PREFIX}{}|{}",
updated(project)?,
digest(&facets)?
))
}
#[must_use]
pub fn document_token(document: &Value) -> Option<String> {
let facets = DocumentFacets {
content: Sha256Hex::of(
nullable_text(document, "content")?
.unwrap_or_default()
.as_bytes(),
),
project: project(document)?,
title: text(document, "title")?,
url: text(document, "url")?,
};
Some(format!(
"{PREFIX}{}|{}",
updated(document)?,
digest(&facets)?
))
}
#[must_use]
pub fn without_relations(token: &str) -> &str {
token
.split_once(&format!("|{}", Part::Relations.tag()))
.map_or(token, |(base, _)| base)
}
#[derive(Debug, PartialEq, Eq)]
enum Extra {
None,
Project {
relations: Sha256Hex,
inverse: Sha256Hex,
},
Task {
comments: Sha256Hex,
relations: Sha256Hex,
inverse: Sha256Hex,
},
}
struct Parts {
updated: DateTime<Utc>,
facets: Sha256Hex,
extra: Extra,
}
fn tagged(text: &str, part: Part) -> Option<Sha256Hex> {
Sha256Hex::parse(text.strip_prefix(part.tag())?)
}
fn parts(token: &str) -> Option<Parts> {
let mut parts = token.strip_prefix(PREFIX)?.split('|');
let updated = DateTime::parse_from_rfc3339(parts.next()?)
.ok()?
.with_timezone(&Utc);
let facets = Sha256Hex::parse(parts.next()?)?;
let rest: Vec<&str> = parts.collect();
let extra = match rest.as_slice() {
[] => Extra::None,
[relations, inverse] => Extra::Project {
relations: tagged(relations, Part::Relations)?,
inverse: tagged(inverse, Part::Inverse)?,
},
[comments, relations, inverse] => Extra::Task {
comments: tagged(comments, Part::Comments)?,
relations: tagged(relations, Part::Relations)?,
inverse: tagged(inverse, Part::Inverse)?,
},
_ => return None,
};
Some(Parts {
updated,
facets,
extra,
})
}
#[must_use]
pub fn order(a: &str, b: &str) -> Option<Ordering> {
let (a, b) = (parts(a)?, parts(b)?);
(a.facets == b.facets && a.extra == b.extra).then(|| a.updated.cmp(&b.updated))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn issue(updated: &str, label: &str, relations: Value) -> Value {
json!({
"id": "i1", "identifier": "ENG-1", "url": "https://linear.app/x/issue/ENG-1",
"updatedAt": updated, "state": {"name": "Todo", "type": "unstarted"},
"project": {"id": "p1"},
"labels": {"nodes": [{"id": "l1", "name": label, "color": "#ff0000"}]},
"comments": {"nodes": [], "pageInfo": {"hasNextPage": false}},
"relations": relations,
"inverseRelations": inverse(&[], false),
})
}
fn page(rows: &[(&str, &str)], more: bool) -> Value {
json!({
"nodes": rows.iter().map(|(id, far)| json!({"id": id, "type": "blocks",
"relatedIssue": {"id": far}}))
.collect::<Vec<_>>(),
"pageInfo": {"hasNextPage": more},
})
}
fn inverse(rows: &[(&str, &str)], more: bool) -> Value {
json!({
"nodes": rows.iter().map(|(id, near)| json!({"id": id, "type": "blocks",
"issue": {"id": near}}))
.collect::<Vec<_>>(),
"pageInfo": {"hasNextPage": more},
})
}
fn comments(rows: &[(&str, &str)], more: bool) -> Value {
json!({
"nodes": rows.iter().map(|(id, updated)| json!({"id": id, "updatedAt": updated}))
.collect::<Vec<_>>(),
"pageInfo": {"hasNextPage": more},
})
}
#[test]
fn a_later_time_with_the_same_facets_and_parts_orders_after() {
let none = page(&[], false);
let before = task(
&issue("2026-10-01T00:00:00Z", "bug", none.clone()),
Spelling::Attached,
)
.unwrap();
let after = task(
&issue("2026-10-01T00:00:01Z", "bug", none),
Spelling::Attached,
)
.unwrap();
assert_eq!(order(&after, &before), Some(Ordering::Greater));
assert_eq!(order(&before, &after), Some(Ordering::Less));
assert_eq!(order(&before, &before), Some(Ordering::Equal));
}
#[test]
fn another_facet_or_part_is_never_ordered() {
let none = page(&[], false);
let held = issue("2026-10-01T00:00:00Z", "bug", none.clone());
let before = task(&held, Spelling::Attached).unwrap();
let renamed = task(
&issue("2026-10-01T00:00:05Z", "defect", none.clone()),
Spelling::Attached,
)
.unwrap();
assert_eq!(order(&renamed, &before), None, "a label renamed");
let related = task(
&issue("2026-10-01T00:00:05Z", "bug", page(&[("r1", "i2")], false)),
Spelling::Attached,
)
.unwrap();
assert_eq!(order(&related, &before), None, "a relation added");
let mut depended = held.clone();
depended["inverseRelations"] = inverse(&[("r2", "i3")], false);
assert_eq!(
order(&task(&depended, Spelling::Attached).unwrap(), &before),
None,
"an inverse relation added"
);
let mut commented = held.clone();
commented["comments"] = comments(&[("c1", "2026-10-01T00:00:00Z")], false);
let commented_token = task(&commented, Spelling::Attached).unwrap();
assert_eq!(order(&commented_token, &before), None, "a comment added");
let mut edited = commented.clone();
edited["comments"] = comments(&[("c1", "2026-10-01T00:00:09Z")], false);
assert_eq!(
order(
&task(&edited, Spelling::Attached).unwrap(),
&commented_token
),
None,
"a comment edited"
);
assert_eq!(order(&before, &commented_token), None, "a comment deleted");
let unattached =
task(&issue("2026-10-01T00:00:00Z", "bug", none), Spelling::Plain).unwrap();
assert!(!unattached.contains("|r:") && !unattached.contains("|c:"));
assert_eq!(
order(&unattached, &before),
None,
"a token with the cache's parts and one without"
);
assert_eq!(order("gh3|x", &before), None);
assert_eq!(order(&format!("{before}|extra"), &before), None);
}
#[test]
fn the_earlier_spelling_of_an_attached_token_is_never_ordered_beside_this_one() {
let held = issue("2026-10-01T00:00:00Z", "bug", page(&[], false));
let now = task(&held, Spelling::Attached).unwrap();
let relations = relation_digest(&held, "relations", "relatedIssue").unwrap();
let earlier = format!("{}|r:{relations}", task(&held, Spelling::Plain).unwrap());
assert!(
parts(&earlier).is_none(),
"a relations part alone is no shape of this one"
);
assert_eq!(order(&earlier, &now), None);
assert_eq!(order(&now, &earlier), None);
let (base, rest) = now.split_once("|c:").unwrap();
let comments_part = rest.split('|').next().unwrap();
assert_eq!(
order(&format!("{base}|r:{relations}|c:{comments_part}"), &now),
None
);
assert!(parts(&format!("{base}|r:{relations}|c:{comments_part}")).is_none());
assert!(parts(&format!("{base}|c:{comments_part}|c:{comments_part}")).is_none());
for incomplete in [
format!("{base}|c:{comments_part}"),
format!("{base}|c:{comments_part}|r:{relations}"),
format!("{base}|i:{relations}"),
] {
assert!(parts(&incomplete).is_none(), "{incomplete}");
assert_eq!(order(&incomplete, &incomplete), None, "{incomplete}");
}
}
#[test]
fn a_part_read_from_part_of_a_connection_gives_no_token() {
let more = page(&[("r1", "i2")], true);
assert_eq!(
task(
&issue("2026-10-01T00:00:00Z", "bug", more),
Spelling::Attached
),
None
);
let rows: Vec<(String, String)> = (0..=PAGE)
.map(|n| (format!("r{n}"), format!("i{n}")))
.collect();
let eleven: Vec<(&str, &str)> = rows
.iter()
.map(|(id, far)| (id.as_str(), far.as_str()))
.collect();
assert_eq!(
task(
&issue("2026-10-01T00:00:00Z", "bug", page(&eleven, false)),
Spelling::Attached
),
None
);
let whole = issue("2026-10-01T00:00:00Z", "bug", page(&[], false));
for (connection, value) in [
("inverseRelations", inverse(&[("r1", "i2")], true)),
("inverseRelations", inverse(&eleven, false)),
(
"comments",
comments(&[("c1", "2026-10-01T00:00:00Z")], true),
),
(
"comments",
json!({"nodes": [], "pageInfo": {"hasPreviousPage": true}}),
),
] {
let mut partial = whole.clone();
partial[connection] = value.clone();
assert_eq!(
task(&partial, Spelling::Attached),
None,
"{connection}: {value}"
);
assert!(
task(&partial, Spelling::Plain).is_some(),
"{connection}: a token without the cache's parts reads none of them"
);
}
let stamps: Vec<String> = (0..=PAGE).map(|n| format!("c{n}")).collect();
let crowd: Vec<(&str, &str)> = stamps
.iter()
.map(|id| (id.as_str(), "2026-10-01T00:00:00Z"))
.collect();
let mut crowded = whole.clone();
crowded["comments"] = comments(&crowd, false);
assert_eq!(task(&crowded, Spelling::Attached), None, "eleven comments");
let mut crowded = whole;
crowded["labels"]["nodes"] = Value::Array(
(0..DEFAULT_LABEL_PAGE)
.map(|n| json!({"id": format!("l{n}"), "name": "x", "color": null}))
.collect(),
);
assert_eq!(task(&crowded, Spelling::Attached), None);
assert_eq!(task(&crowded, Spelling::Plain), None);
}
#[test]
fn a_comments_page_read_backwards_or_forwards_spells_one_part() {
let mut forwards = issue("2026-10-01T00:00:00Z", "bug", page(&[], false));
forwards["comments"] = comments(
&[
("c1", "2026-10-01T00:00:00Z"),
("c2", "2026-10-01T00:00:01.000Z"),
],
false,
);
let mut backwards = forwards.clone();
backwards["comments"] = json!({
"nodes": [
{"id": "c2", "body": "two", "updatedAt": "2026-10-01T00:00:01Z",
"user": {"displayName": "a person"}},
{"id": "c1", "body": "one", "updatedAt": "2026-10-01T00:00:00Z", "user": null},
],
"pageInfo": {"hasPreviousPage": false, "startCursor": "c1"},
});
assert_eq!(
task(&forwards, Spelling::Attached),
task(&backwards, Spelling::Attached)
);
assert!(task(&forwards, Spelling::Attached).is_some());
}
#[test]
fn a_part_of_another_json_type_than_linears_schema_gives_no_token() {
let whole = issue("2026-10-01T00:00:00Z", "bug", page(&[], false));
assert!(task(&whole, Spelling::Attached).is_some());
for (part, value) in [
("/identifier", json!(7)),
("/url", json!(["a"])),
("/project", json!({"id": 7})),
("/project", json!("p1")),
("/state/type", json!(null)),
("/state/name", json!(1)),
("/labels/nodes/0/name", json!(null)),
("/labels/nodes/0/id", json!(1)),
("/labels/nodes/0/color", json!(0)),
] {
let mut broken = whole.clone();
*broken.pointer_mut(part).expect("the part") = value.clone();
assert_eq!(task(&broken, Spelling::Attached), None, "{part} as {value}");
}
let mut broken = whole;
broken["comments"] = comments(&[("c1", "yesterday")], false);
assert_eq!(
task(&broken, Spelling::Attached),
None,
"a comment time that is no instant"
);
let document = json!({"title": "Design", "url": "https://linear.app/x/document/design",
"project": null, "updatedAt": "2026-10-01T00:00:00Z",
"content": null});
assert!(
document_token(&document).is_some(),
"Linear's content may be null"
);
for (part, value) in [
("/title", json!(null)),
("/url", json!(null)),
("/content", json!(1)),
] {
let mut broken = document.clone();
*broken.pointer_mut(part).expect("the part") = value.clone();
assert_eq!(document_token(&broken), None, "{part} as {value}");
}
}
#[test]
fn the_pages_a_token_is_read_from_are_the_ones_the_cache_selects() {
for selected in [
crate::graphql::RELATIONS_PAGE,
crate::graphql::INVERSE_RELATIONS_PAGE,
crate::graphql::COMMENTS_PAGE,
crate::graphql::PROJECT_RELATIONS_PAGE,
crate::graphql::PROJECT_INVERSE_RELATIONS_PAGE,
] {
assert!(
selected.contains(&format!("(first:{PAGE})")),
"{selected} selects a page of {PAGE}"
);
}
}
#[test]
fn a_projects_token_carries_its_relations_and_inverse_relations_only_attached() {
let none = json!({"nodes": [], "pageInfo": {"hasNextPage": false}});
let with = |relations: Value, inverse: Value| {
json!({"updatedAt": "2026-10-01T00:00:00Z", "status": {"name": "Doing", "type": "started"},
"labels": {"nodes": []}, "relations": relations, "inverseRelations": inverse})
};
let project = |relations: Value| with(relations, none.clone());
let related = json!({"nodes": [{"id": "pr1", "type": "dependency",
"relatedProject": {"id": "p2"}}],
"pageInfo": {"hasNextPage": false}});
let none = json!({"nodes": [], "pageInfo": {"hasNextPage": false}});
let plain = project_token(&project(none.clone()), Spelling::Plain).unwrap();
assert!(!plain.contains("|r:"));
let attached = project_token(&project(none.clone()), Spelling::Attached).unwrap();
let added = project_token(&project(related), Spelling::Attached).unwrap();
assert_eq!(order(&attached, &added), None, "a project relation added");
assert_eq!(order(&plain, &attached), None);
assert_eq!(without_relations(&added), plain);
let depended = with(
none.clone(),
json!({"nodes": [{"id": "pr2", "type": "dependency", "project": {"id": "p3"}}],
"pageInfo": {"hasNextPage": false}}),
);
let depended = project_token(&depended, Spelling::Attached).unwrap();
assert_eq!(
order(&attached, &depended),
None,
"a project depending on it added"
);
assert_eq!(without_relations(&depended), plain);
let more = json!({"nodes": [], "pageInfo": {"hasNextPage": true}});
assert_eq!(
project_token(&project(more.clone()), Spelling::Attached),
None
);
assert_eq!(
project_token(&with(none.clone(), more), Spelling::Attached),
None
);
}
#[test]
fn a_documents_token_moves_with_its_content_and_the_relations_parts_come_off() {
let document = |content: &str| {
json!({"title": "Design", "url": "https://linear.app/x/document/design", "project": null,
"updatedAt": "2026-10-01T00:00:00Z", "content": content})
};
let one = document_token(&document("a")).unwrap();
let other = document_token(&document("a, edited")).unwrap();
assert_eq!(order(&one, &other), None);
let held = issue("2026-10-01T00:00:00Z", "bug", page(&[], false));
let token = task(&held, Spelling::Attached).unwrap();
assert_eq!(
without_relations(&token),
task_before_relations(&held).unwrap()
);
assert!(without_relations(&token).contains("|c:"));
assert!(!without_relations(&token).contains("|i:"));
}
}