1use std::cmp::Ordering;
20
21use chrono::{DateTime, Utc};
22use onetaskgraph_plugin_api::asset_sha256;
23use serde::Serialize;
24use serde_json::Value;
25
26pub const PREFIX: &str = "lin1|";
28
29const RELATIONS: &str = "r:";
31
32pub const RELATIONS_PAGE: usize = 10;
35
36pub const DEFAULT_LABEL_PAGE: usize = 50;
39
40type LabelFacet<'a> = (&'a str, &'a str, Option<&'a str>);
42
43type NamedType<'a> = (&'a str, &'a str);
45
46#[derive(Serialize)]
48struct TaskFacets<'a> {
49 key: &'a str,
50 labels: Vec<LabelFacet<'a>>,
51 project: Option<&'a str>,
52 state: NamedType<'a>,
53 url: Option<&'a str>,
54}
55
56#[derive(Serialize)]
58struct ProjectFacets<'a> {
59 labels: Vec<LabelFacet<'a>>,
60 status: NamedType<'a>,
61}
62
63#[derive(Debug, PartialEq, Eq, Serialize)]
67#[serde(transparent)]
68struct Sha256Hex(String);
69
70impl Sha256Hex {
71 fn of(bytes: &[u8]) -> Self {
72 Self(asset_sha256(bytes))
73 }
74
75 fn parse(text: &str) -> Option<Self> {
76 (text.len() == 64
77 && text
78 .bytes()
79 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
80 .then(|| Self(text.to_owned()))
81 }
82}
83
84impl std::fmt::Display for Sha256Hex {
85 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 formatter.write_str(&self.0)
87 }
88}
89
90#[derive(Serialize)]
92struct DocumentFacets<'a> {
93 content: Sha256Hex,
94 project: Option<&'a str>,
95 title: &'a str,
96 url: &'a str,
97}
98
99fn digest(facets: &impl Serialize) -> Option<Sha256Hex> {
101 Some(Sha256Hex::of(
102 serde_json::to_string(facets).ok()?.as_bytes(),
103 ))
104}
105
106fn text<'a>(item: &'a Value, key: &str) -> Option<&'a str> {
109 item.get(key)?.as_str()
110}
111
112fn nullable_text<'a>(item: &'a Value, key: &str) -> Option<Option<&'a str>> {
115 match item.get(key)? {
116 Value::Null => Some(None),
117 Value::String(text) => Some(Some(text)),
118 _ => None,
119 }
120}
121
122fn labels(item: &Value) -> Option<Vec<LabelFacet<'_>>> {
126 let nodes = item.pointer("/labels/nodes")?.as_array()?;
127 if nodes.len() >= DEFAULT_LABEL_PAGE {
128 return None;
129 }
130 let mut labels = nodes
131 .iter()
132 .map(|label| {
133 Some((
134 text(label, "id")?,
135 text(label, "name")?,
136 nullable_text(label, "color")?,
137 ))
138 })
139 .collect::<Option<Vec<_>>>()?;
140 labels.sort_unstable();
141 Some(labels)
142}
143
144fn project(item: &Value) -> Option<Option<&str>> {
147 match item.get("project")? {
148 Value::Null => Some(None),
149 project => Some(Some(text(project, "id")?)),
150 }
151}
152
153fn named_type(status: &Value) -> Option<NamedType<'_>> {
155 Some((text(status, "name")?, text(status, "type")?))
156}
157
158fn updated(item: &Value) -> Option<String> {
160 let at: DateTime<Utc> = item.get("updatedAt")?.as_str()?.parse().ok()?;
161 Some(at.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true))
162}
163
164fn relations(issue: &Value) -> Option<Sha256Hex> {
167 let page = issue.get("relations")?;
168 if page.pointer("/pageInfo/hasNextPage") != Some(&Value::Bool(false)) {
169 return None;
170 }
171 let nodes = page.get("nodes")?.as_array()?;
172 if nodes.len() > RELATIONS_PAGE {
173 return None;
174 }
175 let mut rows = nodes
176 .iter()
177 .map(|node| {
178 Some((
179 node.get("id")?.as_str()?,
180 node.get("type")?.as_str()?,
181 node.pointer("/relatedIssue/id")?.as_str()?,
182 ))
183 })
184 .collect::<Option<Vec<_>>>()?;
185 rows.sort_unstable();
186 Some(Sha256Hex::of(serde_json::to_string(&rows).ok()?.as_bytes()))
187}
188
189#[must_use]
193pub fn task(issue: &Value, attached: bool) -> Option<String> {
194 let facets = TaskFacets {
195 key: text(issue, "identifier")?,
196 labels: labels(issue)?,
197 project: project(issue)?,
198 state: named_type(issue.get("state")?)?,
199 url: nullable_text(issue, "url")?,
200 };
201 let base = format!("{PREFIX}{}|{}", updated(issue)?, digest(&facets)?);
202 if attached {
203 Some(format!("{base}|{RELATIONS}{}", relations(issue)?))
204 } else {
205 Some(base)
206 }
207}
208
209#[must_use]
211pub fn project_token(project: &Value) -> Option<String> {
212 let facets = ProjectFacets {
213 labels: labels(project)?,
214 status: named_type(project.get("status")?)?,
215 };
216 Some(format!(
217 "{PREFIX}{}|{}",
218 updated(project)?,
219 digest(&facets)?
220 ))
221}
222
223#[must_use]
226pub fn document_token(document: &Value) -> Option<String> {
227 let facets = DocumentFacets {
228 content: Sha256Hex::of(
229 nullable_text(document, "content")?
230 .unwrap_or_default()
231 .as_bytes(),
232 ),
233 project: project(document)?,
234 title: text(document, "title")?,
235 url: text(document, "url")?,
236 };
237 Some(format!(
238 "{PREFIX}{}|{}",
239 updated(document)?,
240 digest(&facets)?
241 ))
242}
243
244#[must_use]
247pub fn without_relations(token: &str) -> &str {
248 token
249 .split_once(&format!("|{RELATIONS}"))
250 .map_or(token, |(base, _)| base)
251}
252
253struct Parts {
255 updated: DateTime<Utc>,
256 facets: Sha256Hex,
257 relations: Option<Sha256Hex>,
258}
259
260fn parts(token: &str) -> Option<Parts> {
261 let mut parts = token.strip_prefix(PREFIX)?.split('|');
262 let updated = DateTime::parse_from_rfc3339(parts.next()?)
263 .ok()?
264 .with_timezone(&Utc);
265 let facets = Sha256Hex::parse(parts.next()?)?;
266 let relations = match parts.next() {
267 None => None,
268 Some(part) => Some(Sha256Hex::parse(part.strip_prefix(RELATIONS)?)?),
269 };
270 parts.next().is_none().then_some(Parts {
271 updated,
272 facets,
273 relations,
274 })
275}
276
277#[must_use]
282pub fn order(a: &str, b: &str) -> Option<Ordering> {
283 let (a, b) = (parts(a)?, parts(b)?);
284 (a.facets == b.facets && a.relations == b.relations).then(|| a.updated.cmp(&b.updated))
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use serde_json::json;
291
292 fn issue(updated: &str, label: &str, relations: Value) -> Value {
293 json!({
294 "id": "i1", "identifier": "ENG-1", "url": "https://linear.app/x/issue/ENG-1",
295 "updatedAt": updated, "state": {"name": "Todo", "type": "unstarted"},
296 "project": {"id": "p1"},
297 "labels": {"nodes": [{"id": "l1", "name": label, "color": "#ff0000"}]},
298 "relations": relations,
299 })
300 }
301
302 fn page(rows: &[(&str, &str)], more: bool) -> Value {
303 json!({
304 "nodes": rows.iter().map(|(id, far)| json!({"id": id, "type": "blocks",
305 "relatedIssue": {"id": far}}))
306 .collect::<Vec<_>>(),
307 "pageInfo": {"hasNextPage": more},
308 })
309 }
310
311 #[test]
312 fn a_later_time_with_the_same_facets_and_relations_orders_after() {
313 let none = page(&[], false);
314 let before = task(&issue("2026-10-01T00:00:00Z", "bug", none.clone()), true).unwrap();
315 let after = task(&issue("2026-10-01T00:00:01Z", "bug", none), true).unwrap();
316 assert_eq!(order(&after, &before), Some(Ordering::Greater));
317 assert_eq!(order(&before, &after), Some(Ordering::Less));
318 assert_eq!(order(&before, &before), Some(Ordering::Equal));
319 }
320
321 #[test]
322 fn another_facet_or_relations_part_is_never_ordered() {
323 let none = page(&[], false);
324 let before = task(&issue("2026-10-01T00:00:00Z", "bug", none.clone()), true).unwrap();
325 let renamed = task(&issue("2026-10-01T00:00:05Z", "defect", none.clone()), true).unwrap();
326 assert_eq!(order(&renamed, &before), None, "a label renamed");
327 let related = task(
328 &issue("2026-10-01T00:00:05Z", "bug", page(&[("r1", "i2")], false)),
329 true,
330 )
331 .unwrap();
332 assert_eq!(order(&related, &before), None, "a relation added");
333 let unattached = task(&issue("2026-10-01T00:00:00Z", "bug", none), false).unwrap();
334 assert!(!unattached.contains("|r:"));
335 assert_eq!(
336 order(&unattached, &before),
337 None,
338 "a token with relations and one without"
339 );
340 assert_eq!(order("gh3|x", &before), None);
341 assert_eq!(order(&format!("{before}|extra"), &before), None);
342 }
343
344 #[test]
345 fn a_part_read_from_part_of_a_connection_gives_no_token() {
346 let more = page(&[("r1", "i2")], true);
347 assert_eq!(
348 task(&issue("2026-10-01T00:00:00Z", "bug", more), true),
349 None
350 );
351 let rows: Vec<(String, String)> = (0..=RELATIONS_PAGE)
352 .map(|n| (format!("r{n}"), format!("i{n}")))
353 .collect();
354 let eleven: Vec<(&str, &str)> = rows
355 .iter()
356 .map(|(id, far)| (id.as_str(), far.as_str()))
357 .collect();
358 assert_eq!(
359 task(
360 &issue("2026-10-01T00:00:00Z", "bug", page(&eleven, false)),
361 true
362 ),
363 None
364 );
365 let mut crowded = issue("2026-10-01T00:00:00Z", "bug", page(&[], false));
366 crowded["labels"]["nodes"] = Value::Array(
367 (0..DEFAULT_LABEL_PAGE)
368 .map(|n| json!({"id": format!("l{n}"), "name": "x", "color": null}))
369 .collect(),
370 );
371 assert_eq!(task(&crowded, true), None);
372 assert_eq!(task(&crowded, false), None);
373 }
374
375 #[test]
376 fn a_part_of_another_json_type_than_linears_schema_gives_no_token() {
377 let whole = issue("2026-10-01T00:00:00Z", "bug", page(&[], false));
378 assert!(task(&whole, true).is_some());
379 for (part, value) in [
380 ("/identifier", json!(7)),
381 ("/url", json!(["a"])),
382 ("/project", json!({"id": 7})),
383 ("/project", json!("p1")),
384 ("/state/type", json!(null)),
385 ("/state/name", json!(1)),
386 ("/labels/nodes/0/name", json!(null)),
387 ("/labels/nodes/0/id", json!(1)),
388 ("/labels/nodes/0/color", json!(0)),
389 ] {
390 let mut broken = whole.clone();
391 *broken.pointer_mut(part).expect("the part") = value.clone();
392 assert_eq!(task(&broken, true), None, "{part} as {value}");
393 }
394 let document = json!({"title": "Design", "url": "https://linear.app/x/document/design",
395 "project": null, "updatedAt": "2026-10-01T00:00:00Z",
396 "content": null});
397 assert!(
398 document_token(&document).is_some(),
399 "Linear's content may be null"
400 );
401 for (part, value) in [
402 ("/title", json!(null)),
403 ("/url", json!(null)),
404 ("/content", json!(1)),
405 ] {
406 let mut broken = document.clone();
407 *broken.pointer_mut(part).expect("the part") = value.clone();
408 assert_eq!(document_token(&broken), None, "{part} as {value}");
409 }
410 }
411
412 #[test]
413 fn the_relations_page_a_token_is_read_from_is_the_one_the_cache_selects() {
414 assert!(
415 crate::graphql::RELATIONS_PAGE.contains(&format!("relations(first:{RELATIONS_PAGE})")),
416 "{} selects a page of {RELATIONS_PAGE}",
417 crate::graphql::RELATIONS_PAGE
418 );
419 }
420
421 #[test]
422 fn a_documents_token_moves_with_its_content_and_the_relations_part_comes_off() {
423 let document = |content: &str| {
424 json!({"title": "Design", "url": "https://linear.app/x/document/design", "project": null,
425 "updatedAt": "2026-10-01T00:00:00Z", "content": content})
426 };
427 let one = document_token(&document("a")).unwrap();
428 let other = document_token(&document("a, edited")).unwrap();
429 assert_eq!(order(&one, &other), None);
430 let token = task(
431 &issue("2026-10-01T00:00:00Z", "bug", page(&[], false)),
432 true,
433 )
434 .unwrap();
435 assert_eq!(
436 without_relations(&token),
437 task(
438 &issue("2026-10-01T00:00:00Z", "bug", page(&[], false)),
439 false
440 )
441 .unwrap()
442 );
443 }
444}