1use std::cmp::Ordering;
21
22use chrono::{DateTime, Utc};
23use onetaskgraph_plugin_api::asset_sha256;
24use serde::Serialize;
25use serde_json::Value;
26
27pub const PREFIX: &str = "lin1|";
29
30pub const PAGE: usize = 10;
33
34pub const DEFAULT_LABEL_PAGE: usize = 50;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Spelling {
42 Plain,
44 Attached,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum Part {
55 Comments,
57 Relations,
59 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
75type LabelFacet<'a> = (&'a str, &'a str, Option<&'a str>);
77
78type NamedType<'a> = (&'a str, &'a str);
80
81#[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#[derive(Serialize)]
93struct ProjectFacets<'a> {
94 labels: Vec<LabelFacet<'a>>,
95 status: NamedType<'a>,
96}
97
98#[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#[derive(Serialize)]
127struct DocumentFacets<'a> {
128 content: Sha256Hex,
129 project: Option<&'a str>,
130 title: &'a str,
131 url: &'a str,
132}
133
134fn digest(facets: &impl Serialize) -> Option<Sha256Hex> {
136 Some(Sha256Hex::of(
137 serde_json::to_string(facets).ok()?.as_bytes(),
138 ))
139}
140
141fn text<'a>(item: &'a Value, key: &str) -> Option<&'a str> {
144 item.get(key)?.as_str()
145}
146
147fn 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
157fn 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
179fn 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
188fn named_type(status: &Value) -> Option<NamedType<'_>> {
190 Some((text(status, "name")?, text(status, "type")?))
191}
192
193fn 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
199fn updated(item: &Value) -> Option<String> {
201 instant(item.get("updatedAt")?)
202}
203
204fn 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
223fn 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
229fn 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
245fn 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
255fn 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
264fn 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
276fn 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#[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#[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#[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#[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#[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#[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#[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
393struct Parts {
395 updated: DateTime<Utc>,
396 facets: Sha256Hex,
397 extra: Extra,
398}
399
400fn 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#[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 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 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 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 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}