Skip to main content

onetaskgraph_plugin_api/
relation.rs

1//! Relations between tasks: a task's parent, and typed links between two items.
2//!
3//! Both are opt-in by what a caller writes, and neither touches an item that holds none: a
4//! source answers [`TaskSource::read_relations`](crate::TaskSource::read_relations) and
5//! [`TaskSource::set_parents`](crate::TaskSource::set_parents) only where it can hold a parent,
6//! and a link lives in the store's own metadata on both of its endpoints, under
7//! [`TaskLink::METADATA_KEY`], beside a `## Links` section the engine renders into the item's
8//! content and every source strips on read with [`strip_links_section`].
9//!
10//! A link may also be mirrored as the destination's own relation, so it shows in that
11//! tracker's relationship panel — opt-in per type, through a source's `link_types`
12//! configuration, which [`LinkTypes::checked`] reads against the words a plugin can mirror as.
13
14use std::collections::BTreeMap;
15
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20use crate::{NativeId, SourceError, Task};
21
22/// One typed link, as one endpoint holds it under [`TaskLink::METADATA_KEY`].
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
24#[serde(deny_unknown_fields)]
25pub struct TaskLink {
26    /// The link's type name — the caller's word, which no destination stores natively.
27    // llmlint: ignore[invalid_states_unrepresentable] This is the stored shape the task fixes literally, `{"type": "<name>", …}`, read back from an endpoint a person can edit: a read answers what an endpoint holds rather than failing every link of it on one bad name, and the engine refuses an empty name both where it writes one (`LinkTypeName` in `engine/relations.rs`) and where it reads one (`stored_links`). A newtype here would change the contract every plugin builds against, which this crate keeps still.
28    #[serde(rename = "type")]
29    pub link_type: String,
30    /// Which end of the directed link this item is.
31    pub direction: LinkDirection,
32    /// The other item's qualified id, `<source>:<native-id>`.
33    // llmlint: ignore[invalid_states_unrepresentable] A qualified id is the engine's `GlobalId`, which this crate may not name (AGENTS.md: a plugin never sees a qualified id); a plugin only stores and returns this string, and the engine parses it on every read.
34    pub other: String,
35}
36
37/// Which end of a directed link an item is.
38#[derive(
39    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
40)]
41#[serde(rename_all = "kebab-case")]
42pub enum LinkDirection {
43    /// This item is the link's `from`.
44    Out,
45    /// This item is the link's `to`.
46    In,
47}
48
49impl TaskLink {
50    /// The reserved metadata key every endpoint of a link holds its links under: a JSON list
51    /// of `{"type", "direction", "other"}`.
52    ///
53    /// Written only by the engine's link verbs, through a targeted update, and never carried by
54    /// a copy.
55    pub const METADATA_KEY: &'static str = "onetaskgraph.links";
56
57    /// The links `metadata` holds — none when it holds no [`Self::METADATA_KEY`].
58    ///
59    /// # Errors
60    ///
61    /// When the key holds something other than a list of links, naming what it holds.
62    pub fn held(metadata: &BTreeMap<String, Value>) -> Result<Vec<Self>, String> {
63        match metadata.get(Self::METADATA_KEY) {
64            None => Ok(Vec::new()),
65            Some(value) => serde_json::from_value(value.clone()).map_err(|error| {
66                format!(
67                    "its `{}` is not a list of {{\"type\", \"direction\", \"other\"}}: {error}",
68                    Self::METADATA_KEY
69                )
70            }),
71        }
72    }
73}
74
75/// What a source reads of one task's relations.
76#[derive(Debug, Clone, PartialEq)]
77pub struct Relations {
78    /// The task as the source reads it — its links under [`TaskLink::METADATA_KEY`], its
79    /// version, and its content with the `## Links` section already stripped.
80    pub task: Task,
81    /// Its parent, a task of the same source, or `None`.
82    pub parent: Option<NativeId>,
83    /// Its children, each a task of the same source.
84    pub children: Vec<NativeId>,
85    /// Whether `children` is every child: `false` when the source read fewer than it holds.
86    pub children_complete: bool,
87}
88
89/// One parent write: `child`'s parent set to `parent`, or removed for `None`.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ParentWrite {
92    /// The task whose parent is written.
93    pub child: NativeId,
94    /// Its new parent, a task of the same source, or `None` to remove the one it has.
95    pub parent: Option<NativeId>,
96    /// The version the child must be at for anything to be written.
97    // llmlint: ignore[invalid_states_unrepresentable] As `BatchUpdate::if_version`: a source's own opaque token, `None` the ordinary unheld write.
98    pub if_version: Option<String>,
99}
100
101/// What became of one [`ParentWrite`].
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum ParentAnswer {
104    /// The parent was written; `version` is the child's after it, when the write's answer says.
105    Written {
106        /// The child's version after the write.
107        version: Option<String>,
108    },
109    /// The child already had that parent, so nothing was written.
110    Unchanged {
111        /// The child's version, as read.
112        version: Option<String>,
113    },
114    /// The child was at another version than the one it was held to; nothing was written.
115    VersionMoved {
116        /// The version the source found it at.
117        found: Option<String>,
118    },
119    /// The source, or its destination, refused this edge alone — a cycle, or a limit on
120    /// children or depth — and says why.
121    Refused {
122        /// The refusal, in the destination's own words where it gave some.
123        reason: String,
124    },
125    /// The source holds no such child, or no such parent.
126    Missing {
127        /// Which of the two is missing.
128        what: NativeId,
129    },
130}
131
132/// One native relation to add or remove between two tasks of one source, mirroring a link.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct NativeLink {
135    /// The link's `from`.
136    pub from: NativeId,
137    /// The link's `to`.
138    pub to: NativeId,
139    /// The destination's own word for the relation, as [`LinkTypes`] names it.
140    pub native: NativeWord,
141}
142
143/// A native relation word a source's `link_types` was checked to mirror as: only
144/// [`LinkTypes::checked`] makes one, so no [`NativeLink`] carries a word its plugin refused.
145#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
146pub struct NativeWord(String);
147
148impl NativeWord {
149    /// The word as the configuration spelled it.
150    #[must_use]
151    pub fn as_str(&self) -> &str {
152        &self.0
153    }
154}
155
156impl std::fmt::Display for NativeWord {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        f.write_str(&self.0)
159    }
160}
161
162/// A source's `link_types` configuration: each link type a source mirrors natively, with the
163/// destination's word for it.
164#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub struct LinkTypes(BTreeMap<String, NativeWord>);
166
167/// One entry of a source's `link_types` configuration, as written.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
169#[serde(deny_unknown_fields)]
170pub struct LinkTypeConfig {
171    /// The destination's own relation this type is mirrored as.
172    #[serde(default)]
173    pub native: Option<String>,
174}
175
176impl LinkTypes {
177    /// `configured` checked against `words`, the native relations the `kind` plugin can mirror
178    /// a link as — refusing any other word, naming the type and the word, and, for a word
179    /// `reasons` names, the plugin's own reason it is never one.
180    ///
181    /// # Errors
182    ///
183    /// [`SourceError::Config`] naming the first type whose `native` word the plugin cannot
184    /// mirror as.
185    pub fn checked(
186        kind: &str,
187        configured: &BTreeMap<String, LinkTypeConfig>,
188        words: &[&str],
189        reasons: &[(&str, &str)],
190    ) -> Result<Self, SourceError> {
191        let mut mirrored = BTreeMap::new();
192        for (name, entry) in configured {
193            // The one rule a link's type is held to, at a call as here, so a configured name is
194            // the name a call spells it by.
195            match Self::type_name(name) {
196                Ok(checked) if checked == name => {}
197                Ok(checked) => {
198                    return Err(SourceError::Config {
199                        message: format!(
200                            "link_types names the type {name:?} with surrounding whitespace, which \
201                             a link's type never carries; name it {checked:?}"
202                        ),
203                    });
204                }
205                Err(why) => {
206                    return Err(SourceError::Config {
207                        message: format!("link_types names the type {name:?}, which {why}"),
208                    });
209                }
210            }
211            let Some(word) = &entry.native else {
212                continue;
213            };
214            if !words.contains(&word.as_str()) {
215                let why = reasons
216                    .iter()
217                    .find(|(refused, _)| refused == word)
218                    .map_or_else(String::new, |(_, why)| format!(" — {why}"));
219                let allowed = if words.is_empty() {
220                    format!("the {kind} plugin mirrors no link natively; remove `native`")
221                } else {
222                    format!(
223                        "the {kind} plugin mirrors a link only as {}",
224                        words
225                            .iter()
226                            .map(|word| format!("`{word}`"))
227                            .collect::<Vec<_>>()
228                            .join(" or ")
229                    )
230                };
231                return Err(SourceError::Config {
232                    message: format!(
233                        "link_types.{name}.native is `{word}`, which this source cannot mirror a \
234                         link as{why}; {allowed}"
235                    ),
236                });
237            }
238            mirrored.insert(name.clone(), NativeWord(word.clone()));
239        }
240        Ok(Self(mirrored))
241    }
242
243    /// `name` as a link's type: without surrounding whitespace — or why it names no type a link
244    /// can carry, because it is empty or holds a line break or another control character, which
245    /// its `## Links` section could not list on one line. The one rule a link call, a stored
246    /// link and a source's `link_types` are each held to.
247    ///
248    /// # Errors
249    ///
250    /// Why `name` names no type, as words that follow "which".
251    pub fn type_name(name: &str) -> Result<&str, &'static str> {
252        // Checked before trimming, so a name ending in a line break is refused rather than read
253        // as the name without it.
254        if name.chars().any(char::is_control) {
255            return Err(
256                "holds a line break or another control character, which its `## Links` section \
257                 cannot list on one line",
258            );
259        }
260        let name = name.trim();
261        if name.is_empty() {
262            return Err("names no type");
263        }
264        Ok(name)
265    }
266
267    /// The native word `link_type` is mirrored as, or `None` for a type kept in metadata alone.
268    #[must_use]
269    pub fn native(&self, link_type: &str) -> Option<&NativeWord> {
270        self.0.get(link_type)
271    }
272
273    /// Every mirrored type and its word.
274    #[must_use]
275    pub fn mirrored(&self) -> &BTreeMap<String, NativeWord> {
276        &self.0
277    }
278}
279
280/// The refusal a source holding no relations answers a relation read or write with.
281#[must_use]
282pub fn relationless(kind: &str) -> SourceError {
283    SourceError::Refused {
284        message: format!("the {kind} plugin holds no relations between tasks"),
285    }
286}
287
288/// The heading of the section the engine renders an item's links under, last in its content.
289pub const LINKS_HEADING: &str = "## Links";
290
291/// `content` with the trailing `## Links` section the engine rendered removed — exactly the
292/// content it was rendered onto, on a source that stores what it is sent.
293///
294/// The section is recognised only as the last thing in `content`: a line holding exactly
295/// [`LINKS_HEADING`], followed by nothing but blank lines and bullets — `- ` or `* `, because
296/// Linear stores a `- ` bullet as `* ` and changes blank lines. Anything else after the heading
297/// means a person wrote it, and `content` is answered unchanged.
298#[must_use]
299pub fn strip_links_section(content: &str) -> &str {
300    let mut at = None;
301    let mut offset = 0;
302    for line in content.split_inclusive('\n') {
303        if line.trim_end() == LINKS_HEADING {
304            at = Some(offset);
305        }
306        offset += line.len();
307    }
308    let Some(start) = at else {
309        return content;
310    };
311    let after = &content[start..];
312    let only_bullets = after
313        .split_inclusive('\n')
314        .skip(1)
315        .map(str::trim)
316        .all(|line| line.is_empty() || line.starts_with("- ") || line.starts_with("* "));
317    if !only_bullets {
318        return content;
319    }
320    let before = &content[..start];
321    before
322        .strip_suffix("\n\n")
323        .unwrap_or_else(|| before.trim_end_matches('\n'))
324}
325
326/// `content` with `bullets` rendered as a `## Links` section after it — `content` itself when
327/// there are none. [`strip_links_section`] gives back exactly `content`.
328#[must_use]
329pub fn with_links_section(content: &str, bullets: &[String]) -> String {
330    if bullets.is_empty() {
331        return content.to_owned();
332    }
333    let mut out = String::with_capacity(content.len() + 64);
334    if !content.is_empty() {
335        out.push_str(content);
336        out.push_str("\n\n");
337    }
338    out.push_str(LINKS_HEADING);
339    out.push_str("\n\n");
340    for bullet in bullets {
341        out.push_str("- ");
342        out.push_str(bullet);
343        out.push('\n');
344    }
345    out
346}
347
348/// `content` as a read answers it for an item whose `metadata` holds links: the `## Links`
349/// section stripped, and `None` where nothing but the section was there. Content of an item
350/// holding no links is answered unchanged, so a section a person wrote is never touched.
351#[must_use]
352pub fn read_content(content: Option<String>, metadata: &BTreeMap<String, Value>) -> Option<String> {
353    if !metadata.contains_key(TaskLink::METADATA_KEY) {
354        return content;
355    }
356    let content = content?;
357    let stripped = strip_links_section(&content);
358    if stripped.len() == content.len() {
359        return Some(content);
360    }
361    if stripped.is_empty() {
362        None
363    } else {
364        Some(stripped.to_owned())
365    }
366}
367
368/// The `## Links` line of `link` when nothing better is known of its other end: its type, its
369/// direction, and the other end's qualified id.
370#[must_use]
371pub fn link_line(link: &TaskLink) -> String {
372    format!(
373        "**{}** {} `{}`",
374        link.link_type,
375        arrow(link.direction),
376        link.other
377    )
378}
379
380/// The arrow a `## Links` line shows a direction with.
381#[must_use]
382pub const fn arrow(direction: LinkDirection) -> &'static str {
383    match direction {
384        LinkDirection::Out => "→",
385        LinkDirection::In => "←",
386    }
387}
388
389/// `content` as a write of an item holding `metadata` stores it: with its `## Links` section
390/// re-rendered from the links `metadata` holds, so a content write never drops what a person
391/// sees of them.
392///
393/// Each link keeps the line `stored` — the content as the source stores it now, its section
394/// included — already shows it with, so its other end's title and address survive; a link the
395/// stored section does not show gets [`link_line`]. `content` already ending in a section — a
396/// link write's own — is answered unchanged, and so is any content of an item holding no
397/// links, so a write of an item that never had one is exactly what it was.
398#[must_use]
399pub fn relinked(content: &str, stored: Option<&str>, metadata: &BTreeMap<String, Value>) -> String {
400    let Ok(links) = TaskLink::held(metadata) else {
401        return content.to_owned();
402    };
403    if links.is_empty() || strip_links_section(content).len() != content.len() {
404        return content.to_owned();
405    }
406    let shown: Vec<&str> = stored
407        .map(|stored| {
408            let kept = strip_links_section(stored).len();
409            stored[kept..]
410                .lines()
411                .map(str::trim)
412                .filter_map(|line| line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")))
413                .collect()
414        })
415        .unwrap_or_default();
416    let bullets: Vec<String> = links
417        .iter()
418        .map(|link| {
419            let typed = format!("**{}** {} ", link.link_type, arrow(link.direction));
420            let named = format!("`{}`", link.other);
421            shown
422                .iter()
423                .find(|line| line.starts_with(&typed) && line.ends_with(&named))
424                .map_or_else(|| link_line(link), |line| (*line).to_owned())
425        })
426        .collect();
427    with_links_section(content, &bullets)
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    fn bullets() -> Vec<String> {
435        vec!["**caused-by** → [B](https://example.test/b) `s:b`".to_owned()]
436    }
437
438    #[test]
439    fn a_rendered_section_strips_back_to_exactly_the_content_it_was_rendered_onto() {
440        for content in ["", "Body", "Body\n", "Body\n\n", "# Title\n\ntext\n"] {
441            let rendered = with_links_section(content, &bullets());
442            assert_ne!(rendered, content);
443            assert_eq!(strip_links_section(&rendered), content, "{content:?}");
444        }
445    }
446
447    #[test]
448    fn linears_stars_and_extra_blank_lines_are_still_the_section() {
449        let stored = "Body\n\n## Links\n\n\n* **caused-by** → [B](u)\n\n\n* **x** ← [C](v)\n";
450        assert_eq!(strip_links_section(stored), "Body");
451    }
452
453    #[test]
454    fn a_links_heading_a_person_wrote_prose_under_is_left_alone() {
455        let written = "Body\n\n## Links\n\nSee the wiki.\n";
456        assert_eq!(strip_links_section(written), written);
457        assert_eq!(strip_links_section("no section"), "no section");
458    }
459
460    #[test]
461    fn content_of_an_item_holding_no_links_is_never_stripped() {
462        let rendered = with_links_section("Body", &bullets());
463        assert_eq!(
464            read_content(Some(rendered.clone()), &BTreeMap::new()),
465            Some(rendered.clone())
466        );
467        let mut metadata = BTreeMap::new();
468        metadata.insert(TaskLink::METADATA_KEY.to_owned(), Value::Array(Vec::new()));
469        assert_eq!(
470            read_content(Some(rendered), &metadata),
471            Some("Body".to_owned())
472        );
473        assert_eq!(
474            read_content(Some(with_links_section("", &bullets())), &metadata),
475            None
476        );
477    }
478
479    #[test]
480    fn a_native_word_the_plugin_cannot_mirror_as_is_refused_naming_the_type_and_the_word() {
481        let configured = |word: &str| {
482            BTreeMap::from([(
483                "caused-by".to_owned(),
484                LinkTypeConfig {
485                    native: Some(word.to_owned()),
486                },
487            )])
488        };
489        assert!(
490            LinkTypes::checked(
491                "github-projects",
492                &configured("relates-to"),
493                &["relates-to"],
494                &[("blocks", "blocking is `depends_on`")]
495            )
496            .is_ok()
497        );
498        for word in ["blocks", "blocked-by", "duplicate", "similar", "frobs"] {
499            let Err(SourceError::Config { message }) =
500                LinkTypes::checked("github-projects", &configured(word), &["relates-to"], &[])
501            else {
502                panic!("{word} was accepted");
503            };
504            assert!(message.contains("caused-by"), "{message}");
505            assert!(message.contains(&format!("`{word}`")), "{message}");
506        }
507        let Err(SourceError::Config { message }) =
508            LinkTypes::checked("local-md", &configured("related"), &[], &[])
509        else {
510            panic!("a native word was accepted on a plugin with none");
511        };
512        assert!(message.contains("mirrors no link natively"), "{message}");
513    }
514
515    #[test]
516    fn links_are_read_from_their_key_and_a_malformed_value_is_refused() {
517        let mut metadata = BTreeMap::new();
518        assert!(TaskLink::held(&metadata).unwrap().is_empty());
519        metadata.insert(
520            TaskLink::METADATA_KEY.to_owned(),
521            serde_json::json!([{"type": "t", "direction": "out", "other": "s:b"}]),
522        );
523        assert_eq!(
524            TaskLink::held(&metadata).unwrap(),
525            vec![TaskLink {
526                link_type: "t".to_owned(),
527                direction: LinkDirection::Out,
528                other: "s:b".to_owned()
529            }]
530        );
531        metadata.insert(TaskLink::METADATA_KEY.to_owned(), serde_json::json!("x"));
532        assert!(TaskLink::held(&metadata).is_err());
533    }
534
535    fn holding(links: serde_json::Value) -> BTreeMap<String, Value> {
536        BTreeMap::from([(TaskLink::METADATA_KEY.to_owned(), links)])
537    }
538
539    #[test]
540    fn a_content_write_re_renders_the_section_keeping_each_stored_line() {
541        let metadata = holding(serde_json::json!([
542            {"type": "caused-by", "direction": "out", "other": "s:b"},
543            {"type": "relates", "direction": "in", "other": "t:c"},
544        ]));
545        let stored = with_links_section("Old", &bullets());
546        let written = relinked("New", Some(&stored), &metadata);
547        assert_eq!(
548            written,
549            with_links_section(
550                "New",
551                &[
552                    "**caused-by** → [B](https://example.test/b) `s:b`".to_owned(),
553                    "**relates** ← `t:c`".to_owned(),
554                ]
555            )
556        );
557        assert_eq!(strip_links_section(&written), "New");
558        // Linear's `*` bullets are the same lines.
559        let starred = stored.replace("\n- ", "\n* ");
560        assert_eq!(relinked("New", Some(&starred), &metadata), written);
561    }
562
563    #[test]
564    fn content_carrying_a_section_or_of_an_item_with_no_links_is_written_as_given() {
565        let metadata = holding(serde_json::json!([
566            {"type": "caused-by", "direction": "out", "other": "s:b"}
567        ]));
568        let composed = with_links_section("Body", &bullets());
569        assert_eq!(relinked(&composed, None, &metadata), composed);
570        assert_eq!(relinked("Body", Some(&composed), &BTreeMap::new()), "Body");
571        assert_eq!(
572            relinked("Body", None, &metadata),
573            with_links_section("Body", &["**caused-by** → `s:b`".to_owned()])
574        );
575    }
576}