Skip to main content

onetaskgraph_plugin_api/
work.rs

1//! The work items every source is normalised into.
2
3use chrono::{DateTime, Utc};
4use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::BTreeMap;
8
9use crate::{NativeId, SourceName};
10
11/// One unit of work as a source reports it.
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
13pub struct Task {
14    /// The source's own opaque identifier.
15    pub id: NativeId,
16    /// The short handle the backend shows people, beside [`id`](Self::id) and never
17    /// instead of it — a Linear issue's `ENG-123`, a GitHub issue's `1043`.
18    ///
19    /// **It is human-facing and it may change.** A Linear issue moved between teams gets a
20    /// new identifier and a GitHub issue transferred between repositories gets a new
21    /// number, so nothing stores this in place of [`id`](Self::id) and nothing matches on
22    /// it: `id` is what everything stores and matches on, and this is what a person says
23    /// out loud.
24    ///
25    /// Absent by default, so a source that predates this field — and every source with no
26    /// separate handle of its own — reads as `None`, which means *this backend has no
27    /// short handle for this task* rather than *the handle is the id*. A source never
28    /// copies [`id`](Self::id) here.
29    ///
30    /// **Read-only.** A source derives it on a read and never stores one it is handed: a
31    /// task arriving on an [`ItemWrite`](crate::ItemWrite) may still hold its source's key,
32    /// and a destination ignores it.
33    // llmlint: ignore[invalid_states_unrepresentable] The answer `Task::url` and `Project::url` below already record: this crate's field types are the contract itself, and the contract approving this field states its shape as `Option<String>`. A backend's handle is also the one value this product never parses, matches on or resolves by — `id` does all three — so the confusion a newtype prevents is confusion no code here can act on.
34    #[serde(default)]
35    pub key: Option<String>,
36    /// The one-line summary a user recognises the task by.
37    pub title: String,
38    /// The long-form body, when the source has one.
39    pub content: Option<String>,
40    /// The source's status, normalised and preserved.
41    pub status: Status,
42    /// The task's priority; `none` means none is set, and a source that cannot hold one
43    /// reports `none`.
44    ///
45    /// Defaulted when a document omits it, so a task written before this field existed —
46    /// and every task of a plugin that predates it — reads as [`Priority::None`]. Always
47    /// written, so a reader never has to tell an absent member from a `none` one. Whether a
48    /// source can hold one at all is [`Capabilities::priority`](crate::Capabilities::priority),
49    /// and the engine never hands a source declaring it cannot a priority other than `none`.
50    #[serde(default)]
51    pub priority: Priority,
52    /// Inline rather than by id: a source returning a task already knows them.
53    pub labels: Vec<Label>,
54    /// `None` is a first-class case — an orphan task — not an edge case.
55    pub project: Option<NativeId>,
56    /// Where a human can open this task.
57    // llmlint: ignore[invalid_states_unrepresentable] SECOND PERMITTED REASON — this restates at a new site the justification already recorded at `Capabilities.max_page_size` (capability.rs) and `PageRequest.limit` (query.rs), and in AGENTS.md's "The plugin contract": this crate's field types ARE the approved contract, six undispatched nodes compile against `Option<String>` here, and only the contract's owner may narrow one. No code change is available that clears this without editing that frozen surface.
58    // llmlint: ignore[boundary_inputs_validated] parsing this into a URL type would narrow the same frozen surface, and would add a URL dependency to the crate AGENTS.md says to keep still ("Keep the api crate still" — every change here re-tests every plugin). A plugin that returns a string this interface cannot represent is what `SourceError::Malformed` is for. Contract owner's call; recorded in AGENTS.md, "The plugin contract".
59    pub url: Option<String>,
60    /// Where this task is, when the source says (see [`Location`]).
61    ///
62    /// Absent by default, so a source that predates this field — and every source that
63    /// simply does not say — reads as `None`, which means *the source did not say where
64    /// this is* rather than *this is nowhere*. It neither replaces nor derives from
65    /// [`url`](Self::url), which goes on meaning exactly what it always did.
66    #[serde(default)]
67    pub location: Option<Location>,
68    /// When the source says the task was created.
69    pub created_at: Option<DateTime<Utc>>,
70    /// When the source says the task last changed.
71    pub updated_at: Option<DateTime<Utc>>,
72    /// Caller-defined attributes, preserving their JSON types.
73    ///
74    /// Keys are free-form, with two reserved prefixes: `onetaskgraph.` belongs to this
75    /// product — [`Repository::METADATA_KEY`] and [`DependencyEdge::RECORDED_KEY`] are
76    /// the two every source honours, and [`ItemKind::METADATA_KEY`] is one plugin's —
77    /// and `onepipeline.` belongs to that consumer. Every other key is the caller's, and
78    /// a source returns it exactly as it holds it.
79    #[serde(default)]
80    pub metadata: BTreeMap<String, Value>,
81    /// Normalized repository origins this task concerns, in source order and without
82    /// repeats.
83    #[serde(default, deserialize_with = "unique_repositories")]
84    pub repositories: Vec<Repository>,
85    /// The tasks this one delivers: finishing this task finishes them.
86    ///
87    /// Each entry is a [`TaskRef`] — `<source>:<native>` names a task of any source, and a
88    /// bare native id names a task of the source holding this one — with no repeats and
89    /// never this task itself. Empty by default, and left out of the wire when empty, so a
90    /// reader written before the field existed reads exactly what it read before.
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    #[schemars(!skip_serializing_if)]
93    pub delivers: Vec<TaskRef>,
94    /// Every task that delivers this one, by qualified id: the reverse of [`Self::delivers`].
95    ///
96    /// **Owned by the store, not by a source record and not by a copy.** The engine keeps it
97    /// in step whenever it writes a task's `delivers`, through
98    /// [`TaskSource::set_delivered_by`](crate::TaskSource::set_delivered_by); a source holds
99    /// and reports it, and a copy keeps the destination's own rather than taking the
100    /// source's. Empty by default and left out of the wire when empty, as `delivers` is.
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    #[schemars(!skip_serializing_if)]
103    pub delivered_by: Vec<TaskRef>,
104    /// The task's change token: an opaque value its source gives so a reader can tell whether
105    /// the task changed without reading it whole.
106    ///
107    /// **Two equal versions of one task from one source mean nothing in it has changed** — its
108    /// title, body, status, priority, labels, project fields, metadata, edges and comments,
109    /// comments added, edited and deleted included. A source that cannot promise all of that
110    /// for a task leaves this absent, which means *always read it*. What each plugin keys its
111    /// token on, and which change kinds it covers, is stated per plugin in
112    /// `docs/plugin-protocol.md` §4.13d.
113    ///
114    /// Opaque to every caller: nothing compares one but for equality, and the one reader that
115    /// looks inside is the source that gave it, through
116    /// [`TaskSource::order_versions`](crate::TaskSource::order_versions). Absent by default and left out of the wire when absent, so a source written
117    /// before it existed is read as giving none.
118    ///
119    /// **Read-only.** A source derives it on a read and never stores one it is handed: a task
120    /// arriving on an [`ItemWrite`](crate::ItemWrite) may still hold its source's token, and a
121    /// destination ignores it.
122    // llmlint: ignore[invalid_states_unrepresentable] The contract this lands states the token as an opaque string so the next node's cache and both SDKs carry it unread; a typed token would make every plugin's format part of this crate, which AGENTS.md asks to keep still. The one reader that parses it, its own source's `TaskSource::order_versions`, answers `None` for any value it cannot parse.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub version: Option<String>,
125    /// Who may read this task, as it was declared (see [`Classification`]).
126    ///
127    /// Defaulted and left out of the wire while `public`, so a task written before this
128    /// field existed reads as public and a reader written before it reads what it always
129    /// read. It is the *declared* classification: the engine tightens it by the task's
130    /// repositories and its project before it decides where the task may be written, and
131    /// nothing written here can loosen either.
132    #[serde(default, skip_serializing_if = "Classification::is_public")]
133    #[schemars(!skip_serializing_if)]
134    pub classification: Classification,
135}
136
137/// A grouping of tasks, shaped like a [`Task`] without a parent of its own.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
139pub struct Project {
140    /// The source's own opaque identifier.
141    pub id: NativeId,
142    /// The one-line summary a user recognises the project by.
143    pub title: String,
144    /// The long-form body, when the source has one.
145    pub content: Option<String>,
146    /// The source's status, normalised and preserved.
147    pub status: Status,
148    /// Inline rather than by id, for the same reason as on [`Task`].
149    pub labels: Vec<Label>,
150    /// Where a human can open this project.
151    // llmlint: ignore[invalid_states_unrepresentable] SECOND PERMITTED REASON — this restates at a new site the justification already recorded at `Capabilities.max_page_size` (capability.rs) and `PageRequest.limit` (query.rs), and in AGENTS.md's "The plugin contract": this crate's field types ARE the approved contract, six undispatched nodes compile against `Option<String>` here, and only the contract's owner may narrow one. No code change is available that clears this without editing that frozen surface.
152    // llmlint: ignore[boundary_inputs_validated] parsing this into a URL type would narrow the same frozen surface, and would add a URL dependency to the crate AGENTS.md says to keep still ("Keep the api crate still" — every change here re-tests every plugin). A plugin that returns a string this interface cannot represent is what `SourceError::Malformed` is for. Contract owner's call; recorded in AGENTS.md, "The plugin contract".
153    pub url: Option<String>,
154    /// Where this project is, on exactly the terms of [`Task::location`].
155    #[serde(default)]
156    pub location: Option<Location>,
157    /// When the source says the project was created.
158    pub created_at: Option<DateTime<Utc>>,
159    /// When the source says the project last changed.
160    pub updated_at: Option<DateTime<Utc>>,
161    /// Caller-defined attributes, preserving their JSON types, on the same terms as
162    /// [`Task::metadata`].
163    #[serde(default)]
164    pub metadata: BTreeMap<String, Value>,
165    /// Normalized repository origins this project concerns, in source order and without
166    /// repeats.
167    #[serde(default, deserialize_with = "unique_repositories")]
168    pub repositories: Vec<Repository>,
169    /// Who may read this project, as it was declared, on the terms of
170    /// [`Task::classification`]. A project holding a private task or document is private
171    /// however this reads, and the engine records it so when it writes the project.
172    #[serde(default, skip_serializing_if = "Classification::is_public")]
173    #[schemars(!skip_serializing_if)]
174    pub classification: Classification,
175}
176
177/// One piece of information that lives in a project and is not work.
178///
179/// A document carries **no status** and **no dependencies**, and both omissions are the
180/// contract rather than an oversight: a document is not work, so it has no place in a
181/// status filter and no place in a dependency graph. [`ItemKind`] therefore gains no
182/// document variant — that enum names what a dependency endpoint points at, and nothing
183/// may point at a document.
184///
185/// A source says whether it has documents at all through
186/// [`Capabilities::documents`](crate::Capabilities::documents), and one that says it has
187/// none is never asked for one.
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
189pub struct Document {
190    /// The source's own opaque identifier.
191    pub id: NativeId,
192    /// The one-line summary a person recognises it by.
193    pub title: String,
194    /// The long-form body, when the source has one.
195    pub content: Option<String>,
196    /// The project it lives in; `None` is an orphan document, exactly as it is on a
197    /// [`Task`].
198    pub project: Option<NativeId>,
199    /// Inline, on the same terms as a [`Task`]'s.
200    pub labels: Vec<Label>,
201    /// Where a person can open it, on the same terms as a [`Task`]'s.
202    // llmlint: ignore[invalid_states_unrepresentable] SECOND PERMITTED REASON — this restates at a new site the justification already recorded at `Task::url` and `Project::url` in this module, at `Capabilities.max_page_size` (capability.rs) and `PageRequest.limit` (query.rs), and in AGENTS.md's "The plugin contract": this crate's field types ARE the approved contract, this field is `Option<String>` because a task's and a project's are, and only the contract's owner may narrow one. Narrowing it here alone would leave the three entities describing the same thing in two different types.
203    // llmlint: ignore[boundary_inputs_validated] parsing this into a URL type would narrow the same frozen surface, and would add a URL dependency to the crate AGENTS.md says to keep still ("Keep the api crate still" — every change here re-tests every plugin). A plugin that returns a string this interface cannot represent is what `SourceError::Malformed` is for. Contract owner's call; recorded in AGENTS.md, "The plugin contract".
204    pub url: Option<String>,
205    /// Where it is, when the source says (see [`Location`]).
206    #[serde(default)]
207    pub location: Option<Location>,
208    /// When the source says it was created.
209    pub created_at: Option<DateTime<Utc>>,
210    /// When the source says it last changed.
211    pub updated_at: Option<DateTime<Utc>>,
212    /// Caller-defined attributes, preserving their JSON types, with the same reserved
213    /// prefixes [`Task::metadata`] carries.
214    #[serde(default)]
215    pub metadata: BTreeMap<String, Value>,
216    /// Normalized repository origins this document concerns, in source order and without
217    /// repeats, as a [`Task`]'s.
218    #[serde(default, deserialize_with = "unique_repositories")]
219    pub repositories: Vec<Repository>,
220    /// Who may read this document, as it was declared, on the terms of
221    /// [`Task::classification`].
222    #[serde(default, skip_serializing_if = "Classification::is_public")]
223    #[schemars(!skip_serializing_if)]
224    pub classification: Classification,
225}
226
227/// Who may read an item: anybody, or only those who may read where it is kept.
228///
229/// Two values, ordered so the stricter is the greater: [`strictest`](Self::strictest) of
230/// any two is the one an item they both describe carries. Nothing combines them any other
231/// way, which is what makes an explicit `public` unable to loosen a private repository or a
232/// private project — a classification only ever tightens.
233///
234/// It is recorded on the item and travels with it. A source whose backend has no notion of
235/// its own records it under [`Self::METADATA_KEY`], exactly as it records
236/// [`Repository::METADATA_KEY`], and only while it is `private`: an item without it is
237/// public, so every item written before this existed reads as it did.
238#[derive(
239    Debug,
240    Clone,
241    Copy,
242    Default,
243    PartialEq,
244    Eq,
245    PartialOrd,
246    Ord,
247    Hash,
248    Serialize,
249    Deserialize,
250    JsonSchema,
251)]
252#[serde(rename_all = "kebab-case")]
253pub enum Classification {
254    /// Anybody who may read the destination may read it.
255    #[default]
256    Public,
257    /// It may be written only to a destination verified private, and nothing of it may
258    /// reach a public one.
259    Private,
260}
261
262impl Classification {
263    /// The reserved metadata key a source records a private classification under when its
264    /// backend has no notion of its own.
265    ///
266    /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is.
267    pub const METADATA_KEY: &'static str = "onetaskgraph.classification";
268
269    /// Whether this is [`Public`](Self::Public) — the value left out of the wire.
270    #[must_use]
271    // serde's `skip_serializing_if` calls its predicate with a reference to the field, so this
272    // takes `&self` although the type is `Copy`.
273    #[allow(clippy::trivially_copy_pass_by_ref)]
274    pub fn is_public(&self) -> bool {
275        *self == Self::Public
276    }
277
278    /// The stricter of the two.
279    #[must_use]
280    pub fn strictest(self, other: Self) -> Self {
281        self.max(other)
282    }
283
284    /// The value as the wire spells it: `public` or `private`.
285    #[must_use]
286    pub const fn as_str(self) -> &'static str {
287        match self {
288            Self::Public => "public",
289            Self::Private => "private",
290        }
291    }
292
293    /// The classification a source records under [`Self::METADATA_KEY`], or `public` when
294    /// it records none.
295    ///
296    /// # Errors
297    ///
298    /// Returns a message when the key holds anything but one of the two spellings.
299    pub fn from_metadata(metadata: &BTreeMap<String, Value>) -> Result<Self, String> {
300        match metadata.get(Self::METADATA_KEY) {
301            None => Ok(Self::Public),
302            Some(value) => serde_json::from_value(value.clone()).map_err(|_| {
303                format!(
304                    "{} is {value}; it accepts only \"public\" or \"private\"",
305                    Self::METADATA_KEY
306                )
307            }),
308        }
309    }
310
311    /// Record this classification in `metadata` under [`Self::METADATA_KEY`]: present while
312    /// private, absent while public.
313    pub fn record(self, metadata: &mut BTreeMap<String, Value>) {
314        if self.is_public() {
315            metadata.remove(Self::METADATA_KEY);
316        } else {
317            metadata.insert(Self::METADATA_KEY.to_owned(), Value::from(self.as_str()));
318        }
319    }
320}
321
322impl std::fmt::Display for Classification {
323    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324        formatter.write_str(self.as_str())
325    }
326}
327
328impl std::str::FromStr for Classification {
329    type Err = String;
330
331    fn from_str(value: &str) -> Result<Self, Self::Err> {
332        match value {
333            "public" => Ok(Self::Public),
334            "private" => Ok(Self::Private),
335            _ => Err(format!(
336                "{value:?} is not a classification; a classification is public or private"
337            )),
338        }
339    }
340}
341
342/// Where an entity is, in the one form a consumer can act on without knowing the backend.
343///
344/// Externally tagged with exactly two variants, so the JSON is `{"url": "https://…"}` or
345/// `{"path": "/home/…"}` and a consumer tells them apart by which key is present. A reader
346/// handed one of these knows what to *do* with it — open a link, or print a path and read
347/// the file out — which is what a bare string could not have said.
348///
349/// It carries no third case on purpose. `None` on the field is the third case, and it
350/// means the source did not say where the entity is, which is not the same as saying it is
351/// nowhere.
352///
353/// This does **not** redefine, replace or derive from the `url` field of [`Task`],
354/// [`Project`] or [`Document`]: a source that reports a web URL there goes on reporting
355/// it, and every existing consumer sees exactly what it saw.
356#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
357#[serde(rename_all = "kebab-case")]
358pub enum Location {
359    /// The entity lives at an external website, and this is a link a reader can open.
360    // llmlint: ignore[invalid_states_unrepresentable] SECOND PERMITTED REASON — this restates at a new site the justification already recorded at `Task::url` and `Project::url` in this module, at `Capabilities.max_page_size` (capability.rs) and `PageRequest.limit` (query.rs): this crate's field types ARE the approved contract, and this variant is a `String` because the `url` field it sits beside is one. Narrowing it here alone would leave two members describing a web address in two different types, which is worse than the state it would remove.
361    // llmlint: ignore[boundary_inputs_validated] parsing this into a URL type would add a URL dependency to the crate AGENTS.md says to keep still ("Keep the api crate still" — every change here rebuilds and re-tests every plugin), and would narrow a frozen surface only the contract's owner may narrow. A plugin returning a string this interface cannot represent is what `SourceError::Malformed` is for, exactly as it is for `Task::url`.
362    Url(String),
363    /// The entity is a file on the machine the source runs on, and this is that file's
364    /// absolute path, so a reader can print the path or read the contents out.
365    // llmlint: ignore[invalid_states_unrepresentable] SECOND PERMITTED REASON — the reason the variant above carries, plus one of this variant's own: a typed path here would be `std::path::PathBuf`, whose parsing is the *reading* platform's while this string is the *source's*. A plugin on Linux reporting an absolute path to an engine on Windows must have that path survive byte for byte, so the type that would make a relative path unrepresentable is the type that would corrupt a correct one.
366    // llmlint: ignore[boundary_inputs_validated] validating absoluteness here would answer the question with the wrong machine's rules, for the reason above — this side cannot know what "absolute" means on the host the plugin runs on. The absoluteness this documents is an obligation on the source, and a source that breaks it is `SourceError::Malformed` to the reader that acts on the path.
367    Path(String),
368}
369
370/// A repository identified by its normalized origin, without a URL scheme or `.git` suffix.
371#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
372#[serde(try_from = "String", into = "String")]
373pub struct Repository(String);
374
375impl Repository {
376    /// The reserved metadata key a source reads these origins from when its backend has
377    /// no notion of its own.
378    ///
379    /// The key is spelled once, here, because every plugin has to agree on it: a source
380    /// that invented its own spelling would hold work nothing else could read.
381    pub const METADATA_KEY: &'static str = "onetaskgraph.repositories";
382
383    /// The normalized `host/owner/name` origin.
384    #[must_use]
385    pub fn as_str(&self) -> &str {
386        &self.0
387    }
388
389    /// The origins a source records under [`Self::METADATA_KEY`], or none.
390    ///
391    /// # Errors
392    ///
393    /// Returns a message when the key holds something other than a duplicate-free list
394    /// of normalized origins.
395    pub fn from_metadata(metadata: &BTreeMap<String, Value>) -> Result<Vec<Self>, String> {
396        let Some(value) = metadata.get(Self::METADATA_KEY) else {
397            return Ok(Vec::new());
398        };
399        let origins: Vec<Self> = serde_json::from_value(value.clone()).map_err(|error| {
400            format!(
401                "{} is not a list of repository origins: {error}",
402                Self::METADATA_KEY
403            )
404        })?;
405        Self::unique(origins)
406    }
407
408    /// The same origins, in the order given, once it is established none repeats.
409    ///
410    /// # Errors
411    ///
412    /// Returns a message naming the first origin that appears twice.
413    pub fn unique(origins: Vec<Self>) -> Result<Vec<Self>, String> {
414        let mut seen = std::collections::BTreeSet::new();
415        for origin in &origins {
416            if !seen.insert(origin.as_str()) {
417                return Err(format!(
418                    "{:?} is listed twice; a repository list names each origin once",
419                    origin.as_str()
420                ));
421            }
422        }
423        Ok(origins)
424    }
425}
426
427fn unique_repositories<'de, D>(deserializer: D) -> Result<Vec<Repository>, D::Error>
428where
429    D: serde::Deserializer<'de>,
430{
431    Repository::unique(Vec::<Repository>::deserialize(deserializer)?)
432        .map_err(serde::de::Error::custom)
433}
434
435impl TryFrom<String> for Repository {
436    type Error = String;
437
438    fn try_from(origin: String) -> Result<Self, Self::Error> {
439        let valid = !origin.is_empty()
440            && !origin.contains("://")
441            && !origin.ends_with(".git")
442            && !origin.chars().any(char::is_whitespace)
443            && origin.split('/').count() >= 3
444            && origin
445                .split('/')
446                .all(|part| !part.is_empty() && part != "." && part != "..");
447        valid.then_some(Self(origin.clone())).ok_or_else(|| format!(
448            "{origin:?} is not a normalized repository origin; use host/owner/name without a scheme or .git suffix"
449        ))
450    }
451}
452
453impl From<Repository> for String {
454    fn from(repository: Repository) -> Self {
455        repository.0
456    }
457}
458
459/// One task named by another task's [`Task::delivers`] or [`Task::delivered_by`].
460///
461/// A string with one of two spellings, decided the way a [`DependencyEndpoint`] decides it:
462/// one holding a colon is `<source>:<native>` and names a task of any source, and one
463/// without is a bare native id naming a task of the source that holds the list. So a
464/// native id holding a colon cannot be named bare, exactly as it cannot in
465/// `onetaskgraph.depends_on`.
466#[derive(
467    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
468)]
469#[serde(try_from = "String", into = "String")]
470pub struct TaskRef(String);
471
472impl TaskRef {
473    /// The reserved metadata key a source records [`Task::delivers`] under when its backend
474    /// has no notion of its own.
475    ///
476    /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is.
477    pub const DELIVERS_KEY: &'static str = "onetaskgraph.delivers";
478
479    /// The reserved metadata key a source records [`Task::delivered_by`] under when its
480    /// backend has no notion of its own.
481    pub const DELIVERED_BY_KEY: &'static str = "onetaskgraph.delivered_by";
482
483    /// One entry, once it is established it is a task id.
484    ///
485    /// # Errors
486    ///
487    /// Returns a message saying why when the id is empty, or when it is qualified with a
488    /// source name that breaks the pattern or with no native id after the colon.
489    pub fn new(id: impl Into<String>) -> Result<Self, String> {
490        let id = id.into();
491        if id.is_empty() {
492            return Err("an empty string names no task".to_owned());
493        }
494        if let Some((source, native)) = id.split_once(':') {
495            SourceName::new(source).map_err(|error| error.to_string())?;
496            if native.is_empty() {
497                return Err(format!("{id:?} names a source and no task in it"));
498            }
499        }
500        Ok(Self(id))
501    }
502
503    /// The qualified entry naming `native` in `source`.
504    #[must_use]
505    pub fn qualified(source: &SourceName, native: &NativeId) -> Self {
506        Self(format!("{source}:{native}"))
507    }
508
509    /// The entry as it is spelled.
510    #[must_use]
511    pub fn as_str(&self) -> &str {
512        &self.0
513    }
514
515    /// Whether the entry names its source in writing.
516    #[must_use]
517    pub fn is_qualified(&self) -> bool {
518        self.0.contains(':')
519    }
520
521    /// The source and the native id this entry names, reading a bare entry as naming a task
522    /// of `near_source`.
523    #[must_use]
524    pub fn parts<'a>(&'a self, near_source: &'a str) -> (&'a str, &'a str) {
525        self.0
526            .split_once(':')
527            .unwrap_or((near_source, self.0.as_str()))
528    }
529
530    /// This entry qualified, reading a bare one as naming a task of `near_source`.
531    #[must_use]
532    pub fn in_source(&self, near_source: &SourceName) -> Self {
533        if self.is_qualified() {
534            return self.clone();
535        }
536        Self(format!("{near_source}:{}", self.0))
537    }
538
539    /// The entries of one task's list, once it is established that none names the task
540    /// itself and none repeats.
541    ///
542    /// `field` is what the list is called where it is stored, for the message. `near` is the
543    /// task holding the list and `near_source` the configured name of the source holding it,
544    /// which is what tells `T-1` and `work:T-1` apart as the same task. A source that does
545    /// not know its own name passes `None`, and then only a bare entry can be recognised as
546    /// naming this task or one of the other entries.
547    ///
548    /// # Errors
549    ///
550    /// Returns a message naming the task and the entry.
551    pub fn listed(
552        field: &str,
553        near: &NativeId,
554        near_source: Option<&SourceName>,
555        entries: Vec<Self>,
556    ) -> Result<Vec<Self>, String> {
557        let normal = |entry: &Self| match near_source {
558            Some(source) => entry.in_source(source).0,
559            None => entry.0.clone(),
560        };
561        let this = match near_source {
562            Some(source) => Self::qualified(source, near).0,
563            None => near.0.clone(),
564        };
565        let mut seen: Vec<(String, &Self)> = Vec::with_capacity(entries.len());
566        for entry in &entries {
567            let named = normal(entry);
568            if named == this {
569                return Err(format!(
570                    "{field} on task {near} names {entry}, which is that task itself; a task \
571                     cannot be listed in its own {field}"
572                ));
573            }
574            if let Some((_, first)) = seen.iter().find(|(held, _)| *held == named) {
575                return Err(format!(
576                    "{field} on task {near} names {entry} more than once (as {first} and \
577                     {entry}); name each task once"
578                ));
579            }
580            seen.push((named, entry));
581        }
582        Ok(entries)
583    }
584
585    /// The entries one task's list holds, read out of the JSON a source stores it as.
586    ///
587    /// `value` is `None` when the source holds no list at all, which is the empty one.
588    ///
589    /// # Errors
590    ///
591    /// Returns a message naming the task and the entry when the value is not a list, when an
592    /// entry is not a task id, or when [`Self::listed`] refuses the list.
593    pub fn from_value(
594        field: &str,
595        near: &NativeId,
596        near_source: Option<&SourceName>,
597        value: Option<&Value>,
598    ) -> Result<Vec<Self>, String> {
599        let Some(value) = value else {
600            return Ok(Vec::new());
601        };
602        let Some(held) = value.as_array() else {
603            return Err(format!(
604                "{field} on task {near} is {value}, which is not a list of task ids"
605            ));
606        };
607        let entries = held
608            .iter()
609            .map(|entry| {
610                entry
611                    .as_str()
612                    .ok_or_else(|| "it is not a string".to_owned())
613                    .and_then(Self::new)
614                    .map_err(|why| {
615                        format!(
616                            "{field} on task {near} holds {entry}, which is not a task id: {why}"
617                        )
618                    })
619            })
620            .collect::<Result<Vec<_>, _>>()?;
621        Self::listed(field, near, near_source, entries)
622    }
623}
624
625impl TryFrom<String> for TaskRef {
626    type Error = String;
627
628    fn try_from(id: String) -> Result<Self, Self::Error> {
629        Self::new(id)
630    }
631}
632
633impl From<TaskRef> for String {
634    fn from(entry: TaskRef) -> Self {
635        entry.0
636    }
637}
638
639impl std::fmt::Display for TaskRef {
640    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
641        self.0.fmt(formatter)
642    }
643}
644
645/// A tag a source attaches to work.
646#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
647pub struct Label {
648    /// The source's own opaque identifier.
649    pub id: NativeId,
650    /// What a user filtering across sources actually types.
651    pub name: String,
652    /// The source's own colour for the label, when it has one.
653    pub color: Option<String>,
654}
655
656/// A source's status, kept in both normalised and original form.
657///
658/// `category` is what every filter compares against; `name` is the source's own
659/// wording, preserved so display never flattens "In Review" into "In Progress".
660#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
661pub struct Status {
662    /// The normalised value filters compare against.
663    pub category: StatusCategory,
664    /// The source's own label for this status.
665    pub name: String,
666}
667
668/// The normalised status vocabulary shared across every source.
669#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
670#[serde(rename_all = "kebab-case")]
671pub enum StatusCategory {
672    /// Written down but not yet committed to as work.
673    Draft,
674    /// Known about, not yet accepted as ready to work.
675    Backlog,
676    /// Accepted and ready to be picked up, and nothing has claimed it.
677    Todo,
678    /// Claimed by work that will do it, and not yet started.
679    Queued,
680    /// Being worked on.
681    InProgress,
682    /// Finished.
683    Done,
684    /// Abandoned.
685    Cancelled,
686    /// The source reported a status this vocabulary cannot place.
687    Unknown,
688}
689
690/// How much a task matters, in the one vocabulary every source is normalised into.
691///
692/// Five values, most pressing first after [`None`](Self::None): Linear's own priority has
693/// exactly these, and a source whose backend has none of its own maps its representation
694/// onto them. `none` is a value rather than an absent field, because "no priority is set" is
695/// something a person sets — writing `none` clears a priority — and a reader tells it apart
696/// from nothing by the value alone.
697#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
698#[serde(rename_all = "kebab-case")]
699pub enum Priority {
700    /// No priority is set.
701    #[default]
702    None,
703    /// Drop everything for it.
704    Urgent,
705    /// Next, before the rest.
706    High,
707    /// In its turn.
708    Medium,
709    /// When there is nothing more pressing.
710    Low,
711}
712
713impl Priority {
714    /// Every value, in the order the vocabulary lists them.
715    pub const ALL: [Self; 5] = [
716        Self::None,
717        Self::Urgent,
718        Self::High,
719        Self::Medium,
720        Self::Low,
721    ];
722
723    /// The value as the wire spells it: `none`, `urgent`, `high`, `medium` or `low`.
724    #[must_use]
725    pub const fn as_str(self) -> &'static str {
726        match self {
727            Self::None => "none",
728            Self::Urgent => "urgent",
729            Self::High => "high",
730            Self::Medium => "medium",
731            Self::Low => "low",
732        }
733    }
734}
735
736impl std::fmt::Display for Priority {
737    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
738        formatter.write_str(self.as_str())
739    }
740}
741
742impl std::str::FromStr for Priority {
743    type Err = String;
744
745    fn from_str(value: &str) -> Result<Self, Self::Err> {
746        Self::ALL
747            .into_iter()
748            .find(|priority| priority.as_str() == value)
749            .ok_or_else(|| {
750                format!(
751                    "{value:?} is not a priority; a priority is one of none, urgent, high, \
752                     medium or low"
753                )
754            })
755    }
756}
757
758/// A dependency between two work items.
759///
760/// An endpoint may name another source. Keeping that far id on the near item is work data
761/// owned by its plugin, not an engine-side index or mirror; the engine reports it without
762/// resolving or fetching the far item.
763///
764/// A source uses its backend's own relationship wherever that relationship can name the
765/// far end, so the backend knows the graph and its own interface draws it. Where it
766/// cannot — a far end in another source, which no backend relates — the source reads
767/// [`Self::recorded`] from the near item instead. Only the forward direction is ever
768/// recorded; the reverse of a recorded edge is derived, exactly as a
769/// [`ForwardOnly`](crate::DependencySupport::ForwardOnly) source's reverse is.
770#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
771pub struct DependencyEdge {
772    /// The item the edge starts at, and the one that **depends on** the other.
773    ///
774    /// This is the orientation every source reports in, whichever way its own backend
775    /// spells the relationship: a GitHub `blockedBy` connection read for `ENG-1` yields
776    /// `from: ENG-1`, because `ENG-1` is what depends.
777    pub from: DependencyEndpoint,
778    /// The item the edge points at, and the one that must finish first.
779    pub to: DependencyEndpoint,
780    /// What the edge means.
781    pub kind: DependencyKind,
782}
783
784impl DependencyEdge {
785    /// The reserved metadata key a near item records a far end under.
786    ///
787    /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is: a plugin that
788    /// invented its own spelling would record a plan nothing else could read.
789    pub const RECORDED_KEY: &'static str = "onetaskgraph.depends_on";
790
791    /// The forward edges `near` records under [`Self::RECORDED_KEY`], or none.
792    ///
793    /// The key holds a list of endpoints — a bare string is a native id naming a task,
794    /// and `{"id": "<source>:<native>", "kind": "project"}` names any item of any source.
795    /// Each becomes one `blocks` edge from `near` to that endpoint.
796    ///
797    /// `natively_names` is the kind of item the near item's **own backend** can relate it
798    /// to — `Some(ItemKind::Task)` for a GitHub issue, whose `blockedBy` connection holds
799    /// issues; `None` for a GitHub draft, which has no such connection at all. An endpoint
800    /// of that kind naming an item of `near_source` is refused, because it names an item
801    /// the backend itself could hold, and the rule this key exists to serve is the
802    /// backend's own relationship first. Naming one's own source is what an unqualified id
803    /// does implicitly and what `<near_source>:<native>` does in writing, so both are
804    /// refused: which of the two spellings a plan happened to use says nothing about where
805    /// the edge belongs.
806    ///
807    /// An endpoint qualified to a *different* source is never refused. That is the whole
808    /// case this key is for: no backend relates an id in a system it knows nothing about.
809    ///
810    /// # Errors
811    ///
812    /// Returns a message when the key holds anything other than a list of endpoints, or
813    /// holds one the near item's own backend was supposed to name.
814    pub fn recorded(
815        metadata: &BTreeMap<String, Value>,
816        near: &NativeId,
817        near_kind: ItemKind,
818        near_source: &SourceName,
819        natively_names: Option<ItemKind>,
820    ) -> Result<Vec<Self>, String> {
821        let Some(value) = metadata.get(Self::RECORDED_KEY) else {
822            return Ok(Vec::new());
823        };
824        let far: Vec<DependencyEndpoint> =
825            serde_json::from_value(value.clone()).map_err(|error| {
826                format!(
827                    "{} is not a list of dependency endpoints: {error}",
828                    Self::RECORDED_KEY
829                )
830            })?;
831        far.into_iter()
832            .map(|to| {
833                let names_this_source = to
834                    .source()
835                    .is_none_or(|source| source == near_source.as_str());
836                if names_this_source && natively_names == Some(to.kind) {
837                    return Err(format!(
838                        "{key} on {near} records {to}, which this source can relate \
839                         natively; record it as this backend's own dependency and keep \
840                         {key} for a far end no relationship here can name",
841                        key = Self::RECORDED_KEY
842                    ));
843                }
844                Ok(Self {
845                    from: DependencyEndpoint::from_native(near.clone(), near_kind),
846                    to,
847                    kind: DependencyKind::Blocks,
848                })
849            })
850            .collect()
851    }
852}
853
854/// One endpoint of a dependency edge.
855#[derive(Debug, Clone, PartialEq, Eq, Hash)]
856pub struct DependencyEndpoint {
857    /// A qualified `<source>:<native>` id, or a legacy native id which the engine
858    /// qualifies to the source reporting the edge.
859    id: EndpointIdentity,
860    /// Whether the endpoint names a task or a project.
861    pub kind: ItemKind,
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, Hash)]
865enum EndpointIdentity {
866    Native(String),
867    Qualified(String),
868}
869
870impl EndpointIdentity {
871    fn as_str(&self) -> &str {
872        match self {
873            Self::Native(id) | Self::Qualified(id) => id,
874        }
875    }
876
877    fn into_string(self) -> String {
878        match self {
879            Self::Native(id) | Self::Qualified(id) => id,
880        }
881    }
882}
883
884impl Serialize for DependencyEndpoint {
885    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
886    where
887        S: serde::Serializer,
888    {
889        #[derive(Serialize)]
890        struct Wire<'a> {
891            id: &'a str,
892            kind: ItemKind,
893        }
894        Wire {
895            id: self.id(),
896            kind: self.kind,
897        }
898        .serialize(serializer)
899    }
900}
901
902impl JsonSchema for DependencyEndpoint {
903    fn schema_name() -> std::borrow::Cow<'static, str> {
904        "DependencyEndpoint".into()
905    }
906
907    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
908        json_schema!({
909            "description": "A dependency endpoint. A bare string is a native id of the source reporting it, and this decoding reads one as a task; a reader that knows the level it was written at — a source's own configuration, say — may read it at that level instead.",
910            "oneOf": [
911                {"type": "string", "minLength": 1},
912                {
913                    "type": "object",
914                    "additionalProperties": false,
915                    "required": ["id", "kind"],
916                    "properties": {
917                        "id": {"type": "string", "minLength": 1},
918                        "kind": {"type": "string", "enum": ["task", "project"]}
919                    }
920                }
921            ]
922        })
923    }
924}
925
926impl DependencyEndpoint {
927    /// Builds an endpoint from a serialized id, validating a qualified id when present.
928    ///
929    /// # Errors
930    ///
931    /// Returns an error for an empty id or a malformed `<source>:<native>` id.
932    pub fn new(id: String, kind: ItemKind) -> Result<Self, String> {
933        let is_qualified = id.contains(':');
934        let id = valid_endpoint_id(id)?;
935        Ok(Self {
936            id: if is_qualified {
937                EndpointIdentity::Qualified(id)
938            } else {
939                EndpointIdentity::Native(id)
940            },
941            kind,
942        })
943    }
944
945    /// Builds an endpoint from a source-native id, whose contents are deliberately opaque.
946    #[must_use]
947    pub fn from_native(id: NativeId, kind: ItemKind) -> Self {
948        Self {
949            id: EndpointIdentity::Native(id.0),
950            kind,
951        }
952    }
953
954    /// The serialized native or qualified id.
955    #[must_use]
956    pub fn id(&self) -> &str {
957        self.id.as_str()
958    }
959
960    /// Consumes the endpoint and returns its serialized id.
961    #[must_use]
962    pub fn into_id(self) -> String {
963        self.id.into_string()
964    }
965
966    /// Whether the id was explicitly supplied as a qualified endpoint.
967    #[must_use]
968    pub fn is_qualified(&self) -> bool {
969        matches!(self.id, EndpointIdentity::Qualified(_))
970    }
971
972    /// The source segment of a qualified id, or `None` for a native one.
973    ///
974    /// A native id belongs to whichever source reports it, so `None` reads as "this
975    /// source" rather than "no source".
976    #[must_use]
977    pub fn source(&self) -> Option<&str> {
978        match &self.id {
979            EndpointIdentity::Qualified(id) => id.split_once(':').map(|(source, _)| source),
980            EndpointIdentity::Native(_) => None,
981        }
982    }
983}
984
985impl<'de> Deserialize<'de> for DependencyEndpoint {
986    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
987    where
988        D: serde::Deserializer<'de>,
989    {
990        #[derive(Deserialize)]
991        #[serde(untagged)]
992        enum Wire {
993            Legacy(String),
994            Endpoint { id: String, kind: ItemKind },
995        }
996        match Wire::deserialize(deserializer)? {
997            Wire::Legacy(id) => {
998                if id.is_empty() {
999                    return Err(serde::de::Error::custom(
1000                        "a dependency endpoint id cannot be empty",
1001                    ));
1002                }
1003                Ok(Self::from_native(NativeId(id), ItemKind::Task))
1004            }
1005            Wire::Endpoint { id, kind } => Self::new(id, kind).map_err(serde::de::Error::custom),
1006        }
1007    }
1008}
1009
1010fn valid_endpoint_id(id: String) -> Result<String, String> {
1011    if id.is_empty() {
1012        return Err("a dependency endpoint id cannot be empty".into());
1013    }
1014    if let Some((source, native)) = id.split_once(':') {
1015        crate::SourceName::new(source).map_err(|error| error.to_string())?;
1016        if native.is_empty() {
1017            return Err("a qualified dependency endpoint must name a native id".into());
1018        }
1019    }
1020    Ok(id)
1021}
1022
1023impl std::fmt::Display for DependencyEndpoint {
1024    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1025        self.id().fmt(formatter)
1026    }
1027}
1028
1029impl PartialEq<NativeId> for DependencyEndpoint {
1030    fn eq(&self, other: &NativeId) -> bool {
1031        self.id() == other.0
1032    }
1033}
1034
1035/// The kind of work item named by a dependency endpoint.
1036#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
1037#[serde(rename_all = "kebab-case")]
1038pub enum ItemKind {
1039    /// A task.
1040    Task,
1041    /// A project.
1042    Project,
1043}
1044
1045impl ItemKind {
1046    /// The reserved metadata key an item is marked with when its backend cannot say
1047    /// which kind it is.
1048    ///
1049    /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is: a key under
1050    /// this product's prefix belongs to the product, and a plugin inventing its own
1051    /// spelling would collide with the next one to want it.
1052    ///
1053    /// Unlike the other two reserved keys, this one obliges **no** source. A backend that
1054    /// knows its own kinds — folders, native projects — never reads or writes it, and
1055    /// passes it through as ordinary caller metadata with its JSON type intact, exactly
1056    /// as it passes through every other key it does not own. `github-projects` is the one
1057    /// source that needs it, because a GitHub Projects board holds only issues and an
1058    /// empty project is indistinguishable from a task without it.
1059    pub const METADATA_KEY: &'static str = "onetaskgraph.item_kind";
1060
1061    /// The value this kind is marked with under [`Self::METADATA_KEY`].
1062    #[must_use]
1063    pub const fn marker(self) -> &'static str {
1064        match self {
1065            Self::Task => "task",
1066            Self::Project => "project",
1067        }
1068    }
1069
1070    /// The kind `metadata` marks, or `None` when it carries no marker at all.
1071    ///
1072    /// # Errors
1073    ///
1074    /// Returns a message when [`Self::METADATA_KEY`] holds anything other than the two
1075    /// markers [`Self::marker`] spells.
1076    pub fn from_metadata(metadata: &BTreeMap<String, Value>) -> Result<Option<Self>, String> {
1077        let Some(value) = metadata.get(Self::METADATA_KEY) else {
1078            return Ok(None);
1079        };
1080        match value.as_str() {
1081            Some(marker) if marker == Self::Task.marker() => Ok(Some(Self::Task)),
1082            Some(marker) if marker == Self::Project.marker() => Ok(Some(Self::Project)),
1083            _ => Err(format!(
1084                "{} is {value}; it accepts only {:?} or {:?}",
1085                Self::METADATA_KEY,
1086                Self::Project.marker(),
1087                Self::Task.marker()
1088            )),
1089        }
1090    }
1091}
1092
1093/// What a [`DependencyEdge`] means.
1094///
1095/// Both variants are read in the one direction [`DependencyEdge::from`] fixes: `from`
1096/// depends on `to`. This enum said the opposite of that until the orientation was settled,
1097/// which is why it is spelled out twice rather than once.
1098#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
1099#[serde(rename_all = "kebab-case")]
1100pub enum DependencyKind {
1101    /// `from` depends on `to`, and `to` must finish before `from` can.
1102    // llmlint: ignore[names_match_behavior] `"blocks"` is the approved serialized value, spelled in docs/plugin-protocol.md §4.8 and both generated SDKs; the variant names the kind of dependency, and `from`/`to` carry the direction. Renaming it is a wire change and the contract owner's call.
1103    Blocks,
1104    /// `from` and `to` are linked without an ordering.
1105    Related,
1106}
1107
1108/// Which way a dependency query walks the graph.
1109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
1110#[serde(rename_all = "kebab-case")]
1111pub enum Direction {
1112    /// What this item depends on — the forward edges every source can report.
1113    DependsOn,
1114    /// What depends on this item — emulated by the engine for a
1115    /// [`ForwardOnly`](crate::DependencySupport::ForwardOnly) source.
1116    DependedOnBy,
1117}