Skip to main content

onetaskgraph_linear/
change_token.rs

1//! A Linear item's change token, `lin1`: what [`Task::version`](onetaskgraph_plugin_api::Task::version),
2//! [`Project::version`](onetaskgraph_plugin_api::Project::version) and
3//! [`Document::version`](onetaskgraph_plugin_api::Document::version) hold here, and the one
4//! ordering of two of them.
5//!
6//! `docs/plugin-protocol.md` §4.13d is the specification. Every part is spelled from the response
7//! the source already receives: the item's `updatedAt`, and the digest of its *facets* — what a
8//! person can change about it without moving that time, which `spike-linear-coverage` measured
9//! `updatedAt` alone missing: a label renamed or recoloured, a workflow state renamed, a
10//! document's content and slot edited. With the store's cache attached a task's token also
11//! carries digests of its first page of comments, relations and inverse relations, and a
12//! project's of its first page of relations and inverse relations — the parts §4.13d says each
13//! covers.
14//!
15//! An item whose labels came back as 50 nodes or more — Linear's default page, which the
16//! documents here select without a `first` or a `pageInfo` — gives no token, and so does an item
17//! whose comments, relations or inverse relations page reports another page or holds more than
18//! [`PAGE`] rows: a part read from part of a connection vouches for nothing.
19
20use std::cmp::Ordering;
21
22use chrono::{DateTime, Utc};
23use onetaskgraph_plugin_api::asset_sha256;
24use serde::Serialize;
25use serde_json::Value;
26
27/// What every token this plugin gives starts with.
28pub const PREFIX: &str = "lin1|";
29
30/// The rows a comments, relations or inverse relations page a token is spelled from holds at
31/// most: the `first` of the pages the cache's documents select.
32pub const PAGE: usize = 10;
33
34/// The labels Linear answers a `labels` connection with when it names no `first`: a response
35/// holding this many may be a first page of more, so it gives no token.
36pub const DEFAULT_LABEL_PAGE: usize = 50;
37
38/// Which spelling of a token a response is read as: the one a source with no cache attached gives,
39/// or the one the cache's widened reads let it give, carrying their first pages' digests.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Spelling {
42    /// `lin1|<updatedAt>|<facets>`, from what every read selects.
43    Plain,
44    /// With the parts the cache's widened reads carry: a task's comments, relations and inverse
45    /// relations, a project's relations and inverse relations.
46    Attached,
47}
48
49/// One of the parts the cache's widened reads add to a token, each the digest of one first page.
50///
51/// Spelled in this order, which is the only order a token holds them in, so the part a later
52/// page of one walk is held to — everything before [`Part::Relations`] — is a prefix of it.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum Part {
55    /// A task's comments, as `[id, updatedAt]` rows.
56    Comments,
57    /// A task's or a project's relations, as `[id, type, far end]` rows.
58    Relations,
59    /// A task's or a project's inverse relations, as `[id, type, near end]` rows.
60    Inverse,
61}
62
63impl Part {
64    const ALL: [Self; 3] = [Self::Comments, Self::Relations, Self::Inverse];
65
66    fn tag(self) -> &'static str {
67        match self {
68            Self::Comments => "c:",
69            Self::Relations => "r:",
70            Self::Inverse => "i:",
71        }
72    }
73}
74
75/// One label as a facet spells it: its id, its name and its colour, which Linear may leave null.
76type LabelFacet<'a> = (&'a str, &'a str, Option<&'a str>);
77
78/// A workflow state's or a project status's name and type.
79type NamedType<'a> = (&'a str, &'a str);
80
81/// A task's facets, as its token's digest spells them.
82#[derive(Serialize)]
83struct TaskFacets<'a> {
84    key: &'a str,
85    labels: Vec<LabelFacet<'a>>,
86    project: Option<&'a str>,
87    state: NamedType<'a>,
88    url: Option<&'a str>,
89}
90
91/// A project's facets.
92#[derive(Serialize)]
93struct ProjectFacets<'a> {
94    labels: Vec<LabelFacet<'a>>,
95    status: NamedType<'a>,
96}
97
98/// A lowercase hex SHA-256 — of a document's content, of a facets part's canonical JSON, of a
99/// first page — made only by hashing, or read back out of a token and refused when it is not
100/// one.
101#[derive(Debug, PartialEq, Eq, Serialize)]
102#[serde(transparent)]
103struct Sha256Hex(String);
104
105impl Sha256Hex {
106    fn of(bytes: &[u8]) -> Self {
107        Self(asset_sha256(bytes))
108    }
109
110    fn parse(text: &str) -> Option<Self> {
111        (text.len() == 64
112            && text
113                .bytes()
114                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
115        .then(|| Self(text.to_owned()))
116    }
117}
118
119impl std::fmt::Display for Sha256Hex {
120    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        formatter.write_str(&self.0)
122    }
123}
124
125/// A document's facets.
126#[derive(Serialize)]
127struct DocumentFacets<'a> {
128    content: Sha256Hex,
129    project: Option<&'a str>,
130    title: &'a str,
131    url: &'a str,
132}
133
134/// The lowercase hex SHA-256 of `facets`' canonical JSON.
135fn digest(facets: &impl Serialize) -> Option<Sha256Hex> {
136    Some(Sha256Hex::of(
137        serde_json::to_string(facets).ok()?.as_bytes(),
138    ))
139}
140
141/// `key` of `item` when it is a string — and `None`, so no token, when it is missing or anything
142/// else: a part read as something Linear's schema does not give vouches for nothing.
143fn text<'a>(item: &'a Value, key: &str) -> Option<&'a str> {
144    item.get(key)?.as_str()
145}
146
147/// `key` of `item` when it is a string or null, `None` within when null — and `None` when it is
148/// missing or anything else.
149fn nullable_text<'a>(item: &'a Value, key: &str) -> Option<Option<&'a str>> {
150    match item.get(key)? {
151        Value::Null => Some(None),
152        Value::String(text) => Some(Some(text)),
153        _ => None,
154    }
155}
156
157/// Every label of `item`, as `[id, name, color]` sorted by id — or `None` when the connection is
158/// missing, may be only a first page, or holds a label without a string id and name and a
159/// string or null colour.
160fn labels(item: &Value) -> Option<Vec<LabelFacet<'_>>> {
161    let nodes = item.pointer("/labels/nodes")?.as_array()?;
162    if nodes.len() >= DEFAULT_LABEL_PAGE {
163        return None;
164    }
165    let mut labels = nodes
166        .iter()
167        .map(|label| {
168            Some((
169                text(label, "id")?,
170                text(label, "name")?,
171                nullable_text(label, "color")?,
172            ))
173        })
174        .collect::<Option<Vec<_>>>()?;
175    labels.sort_unstable();
176    Some(labels)
177}
178
179/// The project `item` is filed under, as its facets name it: its id, or `Some(None)` when it is
180/// in none — and `None` when the member is missing or names a project without a string id.
181fn project(item: &Value) -> Option<Option<&str>> {
182    match item.get("project")? {
183        Value::Null => Some(None),
184        project => Some(Some(text(project, "id")?)),
185    }
186}
187
188/// A `{name, type}` status or workflow state, as a facet names it, when both are strings.
189fn named_type(status: &Value) -> Option<NamedType<'_>> {
190    Some((text(status, "name")?, text(status, "type")?))
191}
192
193/// An instant, read and spelled back the one way this module spells it.
194fn instant(value: &Value) -> Option<String> {
195    let at: DateTime<Utc> = value.as_str()?.parse().ok()?;
196    Some(at.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true))
197}
198
199/// `updatedAt`, read as an instant and spelled back the one way this module spells it.
200fn updated(item: &Value) -> Option<String> {
201    instant(item.get("updatedAt")?)
202}
203
204/// The nodes of the connection `key` of `item` when they are the whole of it — a page reporting
205/// no further page either way it walks, of at most [`PAGE`] rows — or `None`.
206///
207/// A page walked forwards says so with `hasNextPage`, one walked backwards with
208/// `hasPreviousPage`; the caller of a page that resumed from a cursor knows it is not the whole
209/// and asks for none.
210fn whole_page<'a>(item: &'a Value, key: &str) -> Option<&'a Vec<Value>> {
211    let page = item.get(key)?;
212    let info = page.get("pageInfo")?;
213    let more = info
214        .get("hasNextPage")
215        .or_else(|| info.get("hasPreviousPage"))?;
216    if more != &Value::Bool(false) {
217        return None;
218    }
219    let nodes = page.get("nodes")?.as_array()?;
220    (nodes.len() <= PAGE).then_some(nodes)
221}
222
223/// The digest of `rows`, sorted.
224fn rows_digest<T: Ord + Serialize>(mut rows: Vec<T>) -> Option<Sha256Hex> {
225    rows.sort_unstable();
226    Some(Sha256Hex::of(serde_json::to_string(&rows).ok()?.as_bytes()))
227}
228
229/// The digest of the relations page `key` of `item`, each row `[id, type, <far>.id]`.
230fn relation_digest(item: &Value, key: &str, far: &str) -> Option<Sha256Hex> {
231    rows_digest(
232        whole_page(item, key)?
233            .iter()
234            .map(|node| {
235                Some((
236                    node.get("id")?.as_str()?,
237                    node.get("type")?.as_str()?,
238                    node.get(far)?.get("id")?.as_str()?,
239                ))
240            })
241            .collect::<Option<Vec<_>>>()?,
242    )
243}
244
245/// The digest of an issue's comments page, each row `[id, updatedAt]`.
246fn comment_digest(issue: &Value) -> Option<Sha256Hex> {
247    rows_digest(
248        whole_page(issue, "comments")?
249            .iter()
250            .map(|node| Some((node.get("id")?.as_str()?, instant(node.get("updatedAt")?)?)))
251            .collect::<Option<Vec<_>>>()?,
252    )
253}
254
255/// The digest one part of a task's token is spelled from, read from `issue`.
256fn task_part(issue: &Value, part: Part) -> Option<Sha256Hex> {
257    match part {
258        Part::Comments => comment_digest(issue),
259        Part::Relations => relation_digest(issue, "relations", "relatedIssue"),
260        Part::Inverse => relation_digest(issue, "inverseRelations", "issue"),
261    }
262}
263
264/// `lin1|<updatedAt>|<facets>` of the issue `issue`.
265fn task_base(issue: &Value) -> Option<String> {
266    let facets = TaskFacets {
267        key: text(issue, "identifier")?,
268        labels: labels(issue)?,
269        project: project(issue)?,
270        state: named_type(issue.get("state")?)?,
271        url: nullable_text(issue, "url")?,
272    };
273    Some(format!("{PREFIX}{}|{}", updated(issue)?, digest(&facets)?))
274}
275
276/// `base` with each of `parts` of `item` appended, or `None` when any is not whole.
277fn with_parts(
278    base: String,
279    parts: &[Part],
280    read: impl Fn(Part) -> Option<Sha256Hex>,
281) -> Option<String> {
282    let mut token = base;
283    for part in parts {
284        token.push('|');
285        token.push_str(part.tag());
286        token.push_str(&read(*part)?.0);
287    }
288    Some(token)
289}
290
291/// The token of the issue `issue`, as one response answered it — with the digests of its first
292/// page of comments, relations and inverse relations when [`Spelling::Attached`], and none at all when any of
293/// those pages is not whole — or `None` when the response does not carry every part whole.
294#[must_use]
295pub fn task(issue: &Value, spelling: Spelling) -> Option<String> {
296    let base = task_base(issue)?;
297    match spelling {
298        Spelling::Attached => with_parts(base, &Part::ALL, |part| task_part(issue, part)),
299        Spelling::Plain => Some(base),
300    }
301}
302
303/// The part of the attached token of the issue `issue` that a page of a forward-edge walk past
304/// its first is held to: the token up to its relations part, which a page answering only part of
305/// the relations cannot spell.
306#[must_use]
307pub fn task_before_relations(issue: &Value) -> Option<String> {
308    with_parts(task_base(issue)?, &[Part::Comments], |part| {
309        task_part(issue, part)
310    })
311}
312
313/// The token of the project `project`, as one response answered it — with the digests of its
314/// first page of relations and of inverse relations when [`Spelling::Attached`], and none at all
315/// when either page is not whole.
316#[must_use]
317pub fn project_token(project: &Value, spelling: Spelling) -> Option<String> {
318    let base = project_before_relations(project)?;
319    match spelling {
320        Spelling::Attached => {
321            with_parts(base, &[Part::Relations, Part::Inverse], |part| match part {
322                Part::Inverse => relation_digest(project, "inverseRelations", "project"),
323                _ => relation_digest(project, "relations", "relatedProject"),
324            })
325        }
326        Spelling::Plain => Some(base),
327    }
328}
329
330/// The part of the attached token of the project `project` that a page of a forward-edge walk
331/// past its first is held to: the token without its relations part.
332#[must_use]
333pub fn project_before_relations(project: &Value) -> Option<String> {
334    let facets = ProjectFacets {
335        labels: labels(project)?,
336        status: named_type(project.get("status")?)?,
337    };
338    Some(format!(
339        "{PREFIX}{}|{}",
340        updated(project)?,
341        digest(&facets)?
342    ))
343}
344
345/// The token of the document `document`, as one response answered it: its content's digest is
346/// of the content as Linear holds it, metadata slot and all.
347#[must_use]
348pub fn document_token(document: &Value) -> Option<String> {
349    let facets = DocumentFacets {
350        content: Sha256Hex::of(
351            nullable_text(document, "content")?
352                .unwrap_or_default()
353                .as_bytes(),
354        ),
355        project: project(document)?,
356        title: text(document, "title")?,
357        url: text(document, "url")?,
358    };
359    Some(format!(
360        "{PREFIX}{}|{}",
361        updated(document)?,
362        digest(&facets)?
363    ))
364}
365
366/// The token `token` with its relations part and every part after it taken off: what a later
367/// page of one walk, read with no whole relations page of its own, is held to.
368#[must_use]
369pub fn without_relations(token: &str) -> &str {
370    token
371        .split_once(&format!("|{}", Part::Relations.tag()))
372        .map_or(token, |(base, _)| base)
373}
374
375/// The parts a token carries beside its time and its facets: none, as every plain token and a
376/// document's; a relations and an inverse relations part, as an attached project's; or, as an
377/// attached task's, a comments, a relations and an inverse relations part, in that order.
378/// Nothing else is a token.
379#[derive(Debug, PartialEq, Eq)]
380enum Extra {
381    None,
382    Project {
383        relations: Sha256Hex,
384        inverse: Sha256Hex,
385    },
386    Task {
387        comments: Sha256Hex,
388        relations: Sha256Hex,
389        inverse: Sha256Hex,
390    },
391}
392
393/// What one token of this plugin is made of.
394struct Parts {
395    updated: DateTime<Utc>,
396    facets: Sha256Hex,
397    extra: Extra,
398}
399
400/// The digest `text` spells after `part`'s tag, when it spells one.
401fn tagged(text: &str, part: Part) -> Option<Sha256Hex> {
402    Sha256Hex::parse(text.strip_prefix(part.tag())?)
403}
404
405fn parts(token: &str) -> Option<Parts> {
406    let mut parts = token.strip_prefix(PREFIX)?.split('|');
407    let updated = DateTime::parse_from_rfc3339(parts.next()?)
408        .ok()?
409        .with_timezone(&Utc);
410    let facets = Sha256Hex::parse(parts.next()?)?;
411    let rest: Vec<&str> = parts.collect();
412    let extra = match rest.as_slice() {
413        [] => Extra::None,
414        [relations, inverse] => Extra::Project {
415            relations: tagged(relations, Part::Relations)?,
416            inverse: tagged(inverse, Part::Inverse)?,
417        },
418        [comments, relations, inverse] => Extra::Task {
419            comments: tagged(comments, Part::Comments)?,
420            relations: tagged(relations, Part::Relations)?,
421            inverse: tagged(inverse, Part::Inverse)?,
422        },
423        _ => return None,
424    };
425    Some(Parts {
426        updated,
427        facets,
428        extra,
429    })
430}
431
432/// How the version `a` of one item stands to its version `b`: by `updatedAt` as an instant when
433/// their facets and every other part — which parts are present, and each one's value — are
434/// equal, `Equal` only when every part is, and `None` when either is not a token of this plugin
435/// or any of those differ, which says nothing about which reading came first.
436///
437/// So a token of the spelling an earlier release gave a task or a project with the cache
438/// attached — a relations part alone — is never ordered beside one of this spelling, and an entry
439/// that release wrote is read again.
440#[must_use]
441pub fn order(a: &str, b: &str) -> Option<Ordering> {
442    let (a, b) = (parts(a)?, parts(b)?);
443    (a.facets == b.facets && a.extra == b.extra).then(|| a.updated.cmp(&b.updated))
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use serde_json::json;
450
451    /// An issue as the cache's widened reads answer it: no comments, no inverse relations, and
452    /// `relations` as given.
453    fn issue(updated: &str, label: &str, relations: Value) -> Value {
454        json!({
455            "id": "i1", "identifier": "ENG-1", "url": "https://linear.app/x/issue/ENG-1",
456            "updatedAt": updated, "state": {"name": "Todo", "type": "unstarted"},
457            "project": {"id": "p1"},
458            "labels": {"nodes": [{"id": "l1", "name": label, "color": "#ff0000"}]},
459            "comments": {"nodes": [], "pageInfo": {"hasNextPage": false}},
460            "relations": relations,
461            "inverseRelations": inverse(&[], false),
462        })
463    }
464
465    fn page(rows: &[(&str, &str)], more: bool) -> Value {
466        json!({
467            "nodes": rows.iter().map(|(id, far)| json!({"id": id, "type": "blocks",
468                                                        "relatedIssue": {"id": far}}))
469                .collect::<Vec<_>>(),
470            "pageInfo": {"hasNextPage": more},
471        })
472    }
473
474    fn inverse(rows: &[(&str, &str)], more: bool) -> Value {
475        json!({
476            "nodes": rows.iter().map(|(id, near)| json!({"id": id, "type": "blocks",
477                                                         "issue": {"id": near}}))
478                .collect::<Vec<_>>(),
479            "pageInfo": {"hasNextPage": more},
480        })
481    }
482
483    fn comments(rows: &[(&str, &str)], more: bool) -> Value {
484        json!({
485            "nodes": rows.iter().map(|(id, updated)| json!({"id": id, "updatedAt": updated}))
486                .collect::<Vec<_>>(),
487            "pageInfo": {"hasNextPage": more},
488        })
489    }
490
491    #[test]
492    fn a_later_time_with_the_same_facets_and_parts_orders_after() {
493        let none = page(&[], false);
494        let before = task(
495            &issue("2026-10-01T00:00:00Z", "bug", none.clone()),
496            Spelling::Attached,
497        )
498        .unwrap();
499        let after = task(
500            &issue("2026-10-01T00:00:01Z", "bug", none),
501            Spelling::Attached,
502        )
503        .unwrap();
504        assert_eq!(order(&after, &before), Some(Ordering::Greater));
505        assert_eq!(order(&before, &after), Some(Ordering::Less));
506        assert_eq!(order(&before, &before), Some(Ordering::Equal));
507    }
508
509    #[test]
510    fn another_facet_or_part_is_never_ordered() {
511        let none = page(&[], false);
512        let held = issue("2026-10-01T00:00:00Z", "bug", none.clone());
513        let before = task(&held, Spelling::Attached).unwrap();
514        let renamed = task(
515            &issue("2026-10-01T00:00:05Z", "defect", none.clone()),
516            Spelling::Attached,
517        )
518        .unwrap();
519        assert_eq!(order(&renamed, &before), None, "a label renamed");
520        let related = task(
521            &issue("2026-10-01T00:00:05Z", "bug", page(&[("r1", "i2")], false)),
522            Spelling::Attached,
523        )
524        .unwrap();
525        assert_eq!(order(&related, &before), None, "a relation added");
526        let mut depended = held.clone();
527        depended["inverseRelations"] = inverse(&[("r2", "i3")], false);
528        assert_eq!(
529            order(&task(&depended, Spelling::Attached).unwrap(), &before),
530            None,
531            "an inverse relation added"
532        );
533        let mut commented = held.clone();
534        commented["comments"] = comments(&[("c1", "2026-10-01T00:00:00Z")], false);
535        let commented_token = task(&commented, Spelling::Attached).unwrap();
536        assert_eq!(order(&commented_token, &before), None, "a comment added");
537        let mut edited = commented.clone();
538        edited["comments"] = comments(&[("c1", "2026-10-01T00:00:09Z")], false);
539        assert_eq!(
540            order(
541                &task(&edited, Spelling::Attached).unwrap(),
542                &commented_token
543            ),
544            None,
545            "a comment edited"
546        );
547        assert_eq!(order(&before, &commented_token), None, "a comment deleted");
548        let unattached =
549            task(&issue("2026-10-01T00:00:00Z", "bug", none), Spelling::Plain).unwrap();
550        assert!(!unattached.contains("|r:") && !unattached.contains("|c:"));
551        assert_eq!(
552            order(&unattached, &before),
553            None,
554            "a token with the cache's parts and one without"
555        );
556        assert_eq!(order("gh3|x", &before), None);
557        assert_eq!(order(&format!("{before}|extra"), &before), None);
558    }
559
560    #[test]
561    fn the_earlier_spelling_of_an_attached_token_is_never_ordered_beside_this_one() {
562        let held = issue("2026-10-01T00:00:00Z", "bug", page(&[], false));
563        let now = task(&held, Spelling::Attached).unwrap();
564        // What the release before this one spelled for the same issue: its relations part alone.
565        let relations = relation_digest(&held, "relations", "relatedIssue").unwrap();
566        let earlier = format!("{}|r:{relations}", task(&held, Spelling::Plain).unwrap());
567        assert!(
568            parts(&earlier).is_none(),
569            "a relations part alone is no shape of this one"
570        );
571        assert_eq!(order(&earlier, &now), None);
572        assert_eq!(order(&now, &earlier), None);
573        // A part out of its one order, or twice, is no token of this plugin.
574        let (base, rest) = now.split_once("|c:").unwrap();
575        let comments_part = rest.split('|').next().unwrap();
576        assert_eq!(
577            order(&format!("{base}|r:{relations}|c:{comments_part}"), &now),
578            None
579        );
580        assert!(parts(&format!("{base}|r:{relations}|c:{comments_part}")).is_none());
581        assert!(parts(&format!("{base}|c:{comments_part}|c:{comments_part}")).is_none());
582        // Nor is any combination that is neither a project's two parts nor a task's three.
583        for incomplete in [
584            format!("{base}|c:{comments_part}"),
585            format!("{base}|c:{comments_part}|r:{relations}"),
586            format!("{base}|i:{relations}"),
587        ] {
588            assert!(parts(&incomplete).is_none(), "{incomplete}");
589            assert_eq!(order(&incomplete, &incomplete), None, "{incomplete}");
590        }
591    }
592
593    #[test]
594    fn a_part_read_from_part_of_a_connection_gives_no_token() {
595        let more = page(&[("r1", "i2")], true);
596        assert_eq!(
597            task(
598                &issue("2026-10-01T00:00:00Z", "bug", more),
599                Spelling::Attached
600            ),
601            None
602        );
603        let rows: Vec<(String, String)> = (0..=PAGE)
604            .map(|n| (format!("r{n}"), format!("i{n}")))
605            .collect();
606        let eleven: Vec<(&str, &str)> = rows
607            .iter()
608            .map(|(id, far)| (id.as_str(), far.as_str()))
609            .collect();
610        assert_eq!(
611            task(
612                &issue("2026-10-01T00:00:00Z", "bug", page(&eleven, false)),
613                Spelling::Attached
614            ),
615            None
616        );
617        let whole = issue("2026-10-01T00:00:00Z", "bug", page(&[], false));
618        for (connection, value) in [
619            ("inverseRelations", inverse(&[("r1", "i2")], true)),
620            ("inverseRelations", inverse(&eleven, false)),
621            (
622                "comments",
623                comments(&[("c1", "2026-10-01T00:00:00Z")], true),
624            ),
625            (
626                "comments",
627                json!({"nodes": [], "pageInfo": {"hasPreviousPage": true}}),
628            ),
629        ] {
630            let mut partial = whole.clone();
631            partial[connection] = value.clone();
632            assert_eq!(
633                task(&partial, Spelling::Attached),
634                None,
635                "{connection}: {value}"
636            );
637            assert!(
638                task(&partial, Spelling::Plain).is_some(),
639                "{connection}: a token without the cache's parts reads none of them"
640            );
641        }
642        let stamps: Vec<String> = (0..=PAGE).map(|n| format!("c{n}")).collect();
643        let crowd: Vec<(&str, &str)> = stamps
644            .iter()
645            .map(|id| (id.as_str(), "2026-10-01T00:00:00Z"))
646            .collect();
647        let mut crowded = whole.clone();
648        crowded["comments"] = comments(&crowd, false);
649        assert_eq!(task(&crowded, Spelling::Attached), None, "eleven comments");
650        let mut crowded = whole;
651        crowded["labels"]["nodes"] = Value::Array(
652            (0..DEFAULT_LABEL_PAGE)
653                .map(|n| json!({"id": format!("l{n}"), "name": "x", "color": null}))
654                .collect(),
655        );
656        assert_eq!(task(&crowded, Spelling::Attached), None);
657        assert_eq!(task(&crowded, Spelling::Plain), None);
658    }
659
660    #[test]
661    fn a_comments_page_read_backwards_or_forwards_spells_one_part() {
662        let mut forwards = issue("2026-10-01T00:00:00Z", "bug", page(&[], false));
663        forwards["comments"] = comments(
664            &[
665                ("c1", "2026-10-01T00:00:00Z"),
666                ("c2", "2026-10-01T00:00:01.000Z"),
667            ],
668            false,
669        );
670        let mut backwards = forwards.clone();
671        backwards["comments"] = json!({
672            "nodes": [
673                {"id": "c2", "body": "two", "updatedAt": "2026-10-01T00:00:01Z",
674                 "user": {"displayName": "a person"}},
675                {"id": "c1", "body": "one", "updatedAt": "2026-10-01T00:00:00Z", "user": null},
676            ],
677            "pageInfo": {"hasPreviousPage": false, "startCursor": "c1"},
678        });
679        assert_eq!(
680            task(&forwards, Spelling::Attached),
681            task(&backwards, Spelling::Attached)
682        );
683        assert!(task(&forwards, Spelling::Attached).is_some());
684    }
685
686    #[test]
687    fn a_part_of_another_json_type_than_linears_schema_gives_no_token() {
688        let whole = issue("2026-10-01T00:00:00Z", "bug", page(&[], false));
689        assert!(task(&whole, Spelling::Attached).is_some());
690        for (part, value) in [
691            ("/identifier", json!(7)),
692            ("/url", json!(["a"])),
693            ("/project", json!({"id": 7})),
694            ("/project", json!("p1")),
695            ("/state/type", json!(null)),
696            ("/state/name", json!(1)),
697            ("/labels/nodes/0/name", json!(null)),
698            ("/labels/nodes/0/id", json!(1)),
699            ("/labels/nodes/0/color", json!(0)),
700        ] {
701            let mut broken = whole.clone();
702            *broken.pointer_mut(part).expect("the part") = value.clone();
703            assert_eq!(task(&broken, Spelling::Attached), None, "{part} as {value}");
704        }
705        let mut broken = whole;
706        broken["comments"] = comments(&[("c1", "yesterday")], false);
707        assert_eq!(
708            task(&broken, Spelling::Attached),
709            None,
710            "a comment time that is no instant"
711        );
712        let document = json!({"title": "Design", "url": "https://linear.app/x/document/design",
713                              "project": null, "updatedAt": "2026-10-01T00:00:00Z",
714                              "content": null});
715        assert!(
716            document_token(&document).is_some(),
717            "Linear's content may be null"
718        );
719        for (part, value) in [
720            ("/title", json!(null)),
721            ("/url", json!(null)),
722            ("/content", json!(1)),
723        ] {
724            let mut broken = document.clone();
725            *broken.pointer_mut(part).expect("the part") = value.clone();
726            assert_eq!(document_token(&broken), None, "{part} as {value}");
727        }
728    }
729
730    #[test]
731    fn the_pages_a_token_is_read_from_are_the_ones_the_cache_selects() {
732        for selected in [
733            crate::graphql::RELATIONS_PAGE,
734            crate::graphql::INVERSE_RELATIONS_PAGE,
735            crate::graphql::COMMENTS_PAGE,
736            crate::graphql::PROJECT_RELATIONS_PAGE,
737            crate::graphql::PROJECT_INVERSE_RELATIONS_PAGE,
738        ] {
739            assert!(
740                selected.contains(&format!("(first:{PAGE})")),
741                "{selected} selects a page of {PAGE}"
742            );
743        }
744    }
745
746    #[test]
747    fn a_projects_token_carries_its_relations_and_inverse_relations_only_attached() {
748        let none = json!({"nodes": [], "pageInfo": {"hasNextPage": false}});
749        let with = |relations: Value, inverse: Value| {
750            json!({"updatedAt": "2026-10-01T00:00:00Z", "status": {"name": "Doing", "type": "started"},
751                   "labels": {"nodes": []}, "relations": relations, "inverseRelations": inverse})
752        };
753        let project = |relations: Value| with(relations, none.clone());
754        let related = json!({"nodes": [{"id": "pr1", "type": "dependency",
755                                        "relatedProject": {"id": "p2"}}],
756                             "pageInfo": {"hasNextPage": false}});
757        let none = json!({"nodes": [], "pageInfo": {"hasNextPage": false}});
758        let plain = project_token(&project(none.clone()), Spelling::Plain).unwrap();
759        assert!(!plain.contains("|r:"));
760        let attached = project_token(&project(none.clone()), Spelling::Attached).unwrap();
761        let added = project_token(&project(related), Spelling::Attached).unwrap();
762        assert_eq!(order(&attached, &added), None, "a project relation added");
763        assert_eq!(order(&plain, &attached), None);
764        assert_eq!(without_relations(&added), plain);
765        let depended = with(
766            none.clone(),
767            json!({"nodes": [{"id": "pr2", "type": "dependency", "project": {"id": "p3"}}],
768                   "pageInfo": {"hasNextPage": false}}),
769        );
770        let depended = project_token(&depended, Spelling::Attached).unwrap();
771        assert_eq!(
772            order(&attached, &depended),
773            None,
774            "a project depending on it added"
775        );
776        assert_eq!(without_relations(&depended), plain);
777        let more = json!({"nodes": [], "pageInfo": {"hasNextPage": true}});
778        assert_eq!(
779            project_token(&project(more.clone()), Spelling::Attached),
780            None
781        );
782        assert_eq!(
783            project_token(&with(none.clone(), more), Spelling::Attached),
784            None
785        );
786    }
787
788    #[test]
789    fn a_documents_token_moves_with_its_content_and_the_relations_parts_come_off() {
790        let document = |content: &str| {
791            json!({"title": "Design", "url": "https://linear.app/x/document/design", "project": null,
792                   "updatedAt": "2026-10-01T00:00:00Z", "content": content})
793        };
794        let one = document_token(&document("a")).unwrap();
795        let other = document_token(&document("a, edited")).unwrap();
796        assert_eq!(order(&one, &other), None);
797        let held = issue("2026-10-01T00:00:00Z", "bug", page(&[], false));
798        let token = task(&held, Spelling::Attached).unwrap();
799        assert_eq!(
800            without_relations(&token),
801            task_before_relations(&held).unwrap()
802        );
803        assert!(without_relations(&token).contains("|c:"));
804        assert!(!without_relations(&token).contains("|i:"));
805    }
806}