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}
105
106/// A grouping of tasks, shaped like a [`Task`] without a parent of its own.
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
108pub struct Project {
109    /// The source's own opaque identifier.
110    pub id: NativeId,
111    /// The one-line summary a user recognises the project by.
112    pub title: String,
113    /// The long-form body, when the source has one.
114    pub content: Option<String>,
115    /// The source's status, normalised and preserved.
116    pub status: Status,
117    /// Inline rather than by id, for the same reason as on [`Task`].
118    pub labels: Vec<Label>,
119    /// Where a human can open this project.
120    // 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.
121    // 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".
122    pub url: Option<String>,
123    /// Where this project is, on exactly the terms of [`Task::location`].
124    #[serde(default)]
125    pub location: Option<Location>,
126    /// When the source says the project was created.
127    pub created_at: Option<DateTime<Utc>>,
128    /// When the source says the project last changed.
129    pub updated_at: Option<DateTime<Utc>>,
130    /// Caller-defined attributes, preserving their JSON types, on the same terms as
131    /// [`Task::metadata`].
132    #[serde(default)]
133    pub metadata: BTreeMap<String, Value>,
134    /// Normalized repository origins this project concerns, in source order and without
135    /// repeats.
136    #[serde(default, deserialize_with = "unique_repositories")]
137    pub repositories: Vec<Repository>,
138}
139
140/// One piece of information that lives in a project and is not work.
141///
142/// A document carries **no status** and **no dependencies**, and both omissions are the
143/// contract rather than an oversight: a document is not work, so it has no place in a
144/// status filter and no place in a dependency graph. [`ItemKind`] therefore gains no
145/// document variant — that enum names what a dependency endpoint points at, and nothing
146/// may point at a document.
147///
148/// A source says whether it has documents at all through
149/// [`Capabilities::documents`](crate::Capabilities::documents), and one that says it has
150/// none is never asked for one.
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
152pub struct Document {
153    /// The source's own opaque identifier.
154    pub id: NativeId,
155    /// The one-line summary a person recognises it by.
156    pub title: String,
157    /// The long-form body, when the source has one.
158    pub content: Option<String>,
159    /// The project it lives in; `None` is an orphan document, exactly as it is on a
160    /// [`Task`].
161    pub project: Option<NativeId>,
162    /// Inline, on the same terms as a [`Task`]'s.
163    pub labels: Vec<Label>,
164    /// Where a person can open it, on the same terms as a [`Task`]'s.
165    // 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.
166    // 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".
167    pub url: Option<String>,
168    /// Where it is, when the source says (see [`Location`]).
169    #[serde(default)]
170    pub location: Option<Location>,
171    /// When the source says it was created.
172    pub created_at: Option<DateTime<Utc>>,
173    /// When the source says it last changed.
174    pub updated_at: Option<DateTime<Utc>>,
175    /// Caller-defined attributes, preserving their JSON types, with the same reserved
176    /// prefixes [`Task::metadata`] carries.
177    #[serde(default)]
178    pub metadata: BTreeMap<String, Value>,
179    /// Normalized repository origins this document concerns, in source order and without
180    /// repeats, as a [`Task`]'s.
181    #[serde(default, deserialize_with = "unique_repositories")]
182    pub repositories: Vec<Repository>,
183}
184
185/// Where an entity is, in the one form a consumer can act on without knowing the backend.
186///
187/// Externally tagged with exactly two variants, so the JSON is `{"url": "https://…"}` or
188/// `{"path": "/home/…"}` and a consumer tells them apart by which key is present. A reader
189/// handed one of these knows what to *do* with it — open a link, or print a path and read
190/// the file out — which is what a bare string could not have said.
191///
192/// It carries no third case on purpose. `None` on the field is the third case, and it
193/// means the source did not say where the entity is, which is not the same as saying it is
194/// nowhere.
195///
196/// This does **not** redefine, replace or derive from the `url` field of [`Task`],
197/// [`Project`] or [`Document`]: a source that reports a web URL there goes on reporting
198/// it, and every existing consumer sees exactly what it saw.
199#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
200#[serde(rename_all = "kebab-case")]
201pub enum Location {
202    /// The entity lives at an external website, and this is a link a reader can open.
203    // 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.
204    // 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`.
205    Url(String),
206    /// The entity is a file on the machine the source runs on, and this is that file's
207    /// absolute path, so a reader can print the path or read the contents out.
208    // 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.
209    // 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.
210    Path(String),
211}
212
213/// A repository identified by its normalized origin, without a URL scheme or `.git` suffix.
214#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
215#[serde(try_from = "String", into = "String")]
216pub struct Repository(String);
217
218impl Repository {
219    /// The reserved metadata key a source reads these origins from when its backend has
220    /// no notion of its own.
221    ///
222    /// The key is spelled once, here, because every plugin has to agree on it: a source
223    /// that invented its own spelling would hold work nothing else could read.
224    pub const METADATA_KEY: &'static str = "onetaskgraph.repositories";
225
226    /// The normalized `host/owner/name` origin.
227    #[must_use]
228    pub fn as_str(&self) -> &str {
229        &self.0
230    }
231
232    /// The origins a source records under [`Self::METADATA_KEY`], or none.
233    ///
234    /// # Errors
235    ///
236    /// Returns a message when the key holds something other than a duplicate-free list
237    /// of normalized origins.
238    pub fn from_metadata(metadata: &BTreeMap<String, Value>) -> Result<Vec<Self>, String> {
239        let Some(value) = metadata.get(Self::METADATA_KEY) else {
240            return Ok(Vec::new());
241        };
242        let origins: Vec<Self> = serde_json::from_value(value.clone()).map_err(|error| {
243            format!(
244                "{} is not a list of repository origins: {error}",
245                Self::METADATA_KEY
246            )
247        })?;
248        Self::unique(origins)
249    }
250
251    /// The same origins, in the order given, once it is established none repeats.
252    ///
253    /// # Errors
254    ///
255    /// Returns a message naming the first origin that appears twice.
256    pub fn unique(origins: Vec<Self>) -> Result<Vec<Self>, String> {
257        let mut seen = std::collections::BTreeSet::new();
258        for origin in &origins {
259            if !seen.insert(origin.as_str()) {
260                return Err(format!(
261                    "{:?} is listed twice; a repository list names each origin once",
262                    origin.as_str()
263                ));
264            }
265        }
266        Ok(origins)
267    }
268}
269
270fn unique_repositories<'de, D>(deserializer: D) -> Result<Vec<Repository>, D::Error>
271where
272    D: serde::Deserializer<'de>,
273{
274    Repository::unique(Vec::<Repository>::deserialize(deserializer)?)
275        .map_err(serde::de::Error::custom)
276}
277
278impl TryFrom<String> for Repository {
279    type Error = String;
280
281    fn try_from(origin: String) -> Result<Self, Self::Error> {
282        let valid = !origin.is_empty()
283            && !origin.contains("://")
284            && !origin.ends_with(".git")
285            && !origin.chars().any(char::is_whitespace)
286            && origin.split('/').count() >= 3
287            && origin
288                .split('/')
289                .all(|part| !part.is_empty() && part != "." && part != "..");
290        valid.then_some(Self(origin.clone())).ok_or_else(|| format!(
291            "{origin:?} is not a normalized repository origin; use host/owner/name without a scheme or .git suffix"
292        ))
293    }
294}
295
296impl From<Repository> for String {
297    fn from(repository: Repository) -> Self {
298        repository.0
299    }
300}
301
302/// One task named by another task's [`Task::delivers`] or [`Task::delivered_by`].
303///
304/// A string with one of two spellings, decided the way a [`DependencyEndpoint`] decides it:
305/// one holding a colon is `<source>:<native>` and names a task of any source, and one
306/// without is a bare native id naming a task of the source that holds the list. So a
307/// native id holding a colon cannot be named bare, exactly as it cannot in
308/// `onetaskgraph.depends_on`.
309#[derive(
310    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
311)]
312#[serde(try_from = "String", into = "String")]
313pub struct TaskRef(String);
314
315impl TaskRef {
316    /// The reserved metadata key a source records [`Task::delivers`] under when its backend
317    /// has no notion of its own.
318    ///
319    /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is.
320    pub const DELIVERS_KEY: &'static str = "onetaskgraph.delivers";
321
322    /// The reserved metadata key a source records [`Task::delivered_by`] under when its
323    /// backend has no notion of its own.
324    pub const DELIVERED_BY_KEY: &'static str = "onetaskgraph.delivered_by";
325
326    /// One entry, once it is established it is a task id.
327    ///
328    /// # Errors
329    ///
330    /// Returns a message saying why when the id is empty, or when it is qualified with a
331    /// source name that breaks the pattern or with no native id after the colon.
332    pub fn new(id: impl Into<String>) -> Result<Self, String> {
333        let id = id.into();
334        if id.is_empty() {
335            return Err("an empty string names no task".to_owned());
336        }
337        if let Some((source, native)) = id.split_once(':') {
338            SourceName::new(source).map_err(|error| error.to_string())?;
339            if native.is_empty() {
340                return Err(format!("{id:?} names a source and no task in it"));
341            }
342        }
343        Ok(Self(id))
344    }
345
346    /// The qualified entry naming `native` in `source`.
347    #[must_use]
348    pub fn qualified(source: &SourceName, native: &NativeId) -> Self {
349        Self(format!("{source}:{native}"))
350    }
351
352    /// The entry as it is spelled.
353    #[must_use]
354    pub fn as_str(&self) -> &str {
355        &self.0
356    }
357
358    /// Whether the entry names its source in writing.
359    #[must_use]
360    pub fn is_qualified(&self) -> bool {
361        self.0.contains(':')
362    }
363
364    /// The source and the native id this entry names, reading a bare entry as naming a task
365    /// of `near_source`.
366    #[must_use]
367    pub fn parts<'a>(&'a self, near_source: &'a str) -> (&'a str, &'a str) {
368        self.0
369            .split_once(':')
370            .unwrap_or((near_source, self.0.as_str()))
371    }
372
373    /// This entry qualified, reading a bare one as naming a task of `near_source`.
374    #[must_use]
375    pub fn in_source(&self, near_source: &SourceName) -> Self {
376        if self.is_qualified() {
377            return self.clone();
378        }
379        Self(format!("{near_source}:{}", self.0))
380    }
381
382    /// The entries of one task's list, once it is established that none names the task
383    /// itself and none repeats.
384    ///
385    /// `field` is what the list is called where it is stored, for the message. `near` is the
386    /// task holding the list and `near_source` the configured name of the source holding it,
387    /// which is what tells `T-1` and `work:T-1` apart as the same task. A source that does
388    /// not know its own name passes `None`, and then only a bare entry can be recognised as
389    /// naming this task or one of the other entries.
390    ///
391    /// # Errors
392    ///
393    /// Returns a message naming the task and the entry.
394    pub fn listed(
395        field: &str,
396        near: &NativeId,
397        near_source: Option<&SourceName>,
398        entries: Vec<Self>,
399    ) -> Result<Vec<Self>, String> {
400        let normal = |entry: &Self| match near_source {
401            Some(source) => entry.in_source(source).0,
402            None => entry.0.clone(),
403        };
404        let this = match near_source {
405            Some(source) => Self::qualified(source, near).0,
406            None => near.0.clone(),
407        };
408        let mut seen: Vec<(String, &Self)> = Vec::with_capacity(entries.len());
409        for entry in &entries {
410            let named = normal(entry);
411            if named == this {
412                return Err(format!(
413                    "{field} on task {near} names {entry}, which is that task itself; a task \
414                     cannot be listed in its own {field}"
415                ));
416            }
417            if let Some((_, first)) = seen.iter().find(|(held, _)| *held == named) {
418                return Err(format!(
419                    "{field} on task {near} names {entry} more than once (as {first} and \
420                     {entry}); name each task once"
421                ));
422            }
423            seen.push((named, entry));
424        }
425        Ok(entries)
426    }
427
428    /// The entries one task's list holds, read out of the JSON a source stores it as.
429    ///
430    /// `value` is `None` when the source holds no list at all, which is the empty one.
431    ///
432    /// # Errors
433    ///
434    /// Returns a message naming the task and the entry when the value is not a list, when an
435    /// entry is not a task id, or when [`Self::listed`] refuses the list.
436    pub fn from_value(
437        field: &str,
438        near: &NativeId,
439        near_source: Option<&SourceName>,
440        value: Option<&Value>,
441    ) -> Result<Vec<Self>, String> {
442        let Some(value) = value else {
443            return Ok(Vec::new());
444        };
445        let Some(held) = value.as_array() else {
446            return Err(format!(
447                "{field} on task {near} is {value}, which is not a list of task ids"
448            ));
449        };
450        let entries = held
451            .iter()
452            .map(|entry| {
453                entry
454                    .as_str()
455                    .ok_or_else(|| "it is not a string".to_owned())
456                    .and_then(Self::new)
457                    .map_err(|why| {
458                        format!(
459                            "{field} on task {near} holds {entry}, which is not a task id: {why}"
460                        )
461                    })
462            })
463            .collect::<Result<Vec<_>, _>>()?;
464        Self::listed(field, near, near_source, entries)
465    }
466}
467
468impl TryFrom<String> for TaskRef {
469    type Error = String;
470
471    fn try_from(id: String) -> Result<Self, Self::Error> {
472        Self::new(id)
473    }
474}
475
476impl From<TaskRef> for String {
477    fn from(entry: TaskRef) -> Self {
478        entry.0
479    }
480}
481
482impl std::fmt::Display for TaskRef {
483    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
484        self.0.fmt(formatter)
485    }
486}
487
488/// A tag a source attaches to work.
489#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
490pub struct Label {
491    /// The source's own opaque identifier.
492    pub id: NativeId,
493    /// What a user filtering across sources actually types.
494    pub name: String,
495    /// The source's own colour for the label, when it has one.
496    pub color: Option<String>,
497}
498
499/// A source's status, kept in both normalised and original form.
500///
501/// `category` is what every filter compares against; `name` is the source's own
502/// wording, preserved so display never flattens "In Review" into "In Progress".
503#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
504pub struct Status {
505    /// The normalised value filters compare against.
506    pub category: StatusCategory,
507    /// The source's own label for this status.
508    pub name: String,
509}
510
511/// The normalised status vocabulary shared across every source.
512#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
513#[serde(rename_all = "kebab-case")]
514pub enum StatusCategory {
515    /// Written down but not yet committed to as work.
516    Draft,
517    /// Known about, not yet accepted as ready to work.
518    Backlog,
519    /// Accepted and ready to be picked up, and nothing has claimed it.
520    Todo,
521    /// Claimed by work that will do it, and not yet started.
522    Queued,
523    /// Being worked on.
524    InProgress,
525    /// Finished.
526    Done,
527    /// Abandoned.
528    Cancelled,
529    /// The source reported a status this vocabulary cannot place.
530    Unknown,
531}
532
533/// How much a task matters, in the one vocabulary every source is normalised into.
534///
535/// Five values, most pressing first after [`None`](Self::None): Linear's own priority has
536/// exactly these, and a source whose backend has none of its own maps its representation
537/// onto them. `none` is a value rather than an absent field, because "no priority is set" is
538/// something a person sets — writing `none` clears a priority — and a reader tells it apart
539/// from nothing by the value alone.
540#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
541#[serde(rename_all = "kebab-case")]
542pub enum Priority {
543    /// No priority is set.
544    #[default]
545    None,
546    /// Drop everything for it.
547    Urgent,
548    /// Next, before the rest.
549    High,
550    /// In its turn.
551    Medium,
552    /// When there is nothing more pressing.
553    Low,
554}
555
556impl Priority {
557    /// Every value, in the order the vocabulary lists them.
558    pub const ALL: [Self; 5] = [
559        Self::None,
560        Self::Urgent,
561        Self::High,
562        Self::Medium,
563        Self::Low,
564    ];
565
566    /// The value as the wire spells it: `none`, `urgent`, `high`, `medium` or `low`.
567    #[must_use]
568    pub const fn as_str(self) -> &'static str {
569        match self {
570            Self::None => "none",
571            Self::Urgent => "urgent",
572            Self::High => "high",
573            Self::Medium => "medium",
574            Self::Low => "low",
575        }
576    }
577}
578
579impl std::fmt::Display for Priority {
580    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
581        formatter.write_str(self.as_str())
582    }
583}
584
585impl std::str::FromStr for Priority {
586    type Err = String;
587
588    fn from_str(value: &str) -> Result<Self, Self::Err> {
589        Self::ALL
590            .into_iter()
591            .find(|priority| priority.as_str() == value)
592            .ok_or_else(|| {
593                format!(
594                    "{value:?} is not a priority; a priority is one of none, urgent, high, \
595                     medium or low"
596                )
597            })
598    }
599}
600
601/// A dependency between two work items.
602///
603/// An endpoint may name another source. Keeping that far id on the near item is work data
604/// owned by its plugin, not an engine-side index or mirror; the engine reports it without
605/// resolving or fetching the far item.
606///
607/// A source uses its backend's own relationship wherever that relationship can name the
608/// far end, so the backend knows the graph and its own interface draws it. Where it
609/// cannot — a far end in another source, which no backend relates — the source reads
610/// [`Self::recorded`] from the near item instead. Only the forward direction is ever
611/// recorded; the reverse of a recorded edge is derived, exactly as a
612/// [`ForwardOnly`](crate::DependencySupport::ForwardOnly) source's reverse is.
613#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
614pub struct DependencyEdge {
615    /// The item the edge starts at, and the one that **depends on** the other.
616    ///
617    /// This is the orientation every source reports in, whichever way its own backend
618    /// spells the relationship: a GitHub `blockedBy` connection read for `ENG-1` yields
619    /// `from: ENG-1`, because `ENG-1` is what depends.
620    pub from: DependencyEndpoint,
621    /// The item the edge points at, and the one that must finish first.
622    pub to: DependencyEndpoint,
623    /// What the edge means.
624    pub kind: DependencyKind,
625}
626
627impl DependencyEdge {
628    /// The reserved metadata key a near item records a far end under.
629    ///
630    /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is: a plugin that
631    /// invented its own spelling would record a plan nothing else could read.
632    pub const RECORDED_KEY: &'static str = "onetaskgraph.depends_on";
633
634    /// The forward edges `near` records under [`Self::RECORDED_KEY`], or none.
635    ///
636    /// The key holds a list of endpoints — a bare string is a native id naming a task,
637    /// and `{"id": "<source>:<native>", "kind": "project"}` names any item of any source.
638    /// Each becomes one `blocks` edge from `near` to that endpoint.
639    ///
640    /// `natively_names` is the kind of item the near item's **own backend** can relate it
641    /// to — `Some(ItemKind::Task)` for a GitHub issue, whose `blockedBy` connection holds
642    /// issues; `None` for a GitHub draft, which has no such connection at all. An endpoint
643    /// of that kind naming an item of `near_source` is refused, because it names an item
644    /// the backend itself could hold, and the rule this key exists to serve is the
645    /// backend's own relationship first. Naming one's own source is what an unqualified id
646    /// does implicitly and what `<near_source>:<native>` does in writing, so both are
647    /// refused: which of the two spellings a plan happened to use says nothing about where
648    /// the edge belongs.
649    ///
650    /// An endpoint qualified to a *different* source is never refused. That is the whole
651    /// case this key is for: no backend relates an id in a system it knows nothing about.
652    ///
653    /// # Errors
654    ///
655    /// Returns a message when the key holds anything other than a list of endpoints, or
656    /// holds one the near item's own backend was supposed to name.
657    pub fn recorded(
658        metadata: &BTreeMap<String, Value>,
659        near: &NativeId,
660        near_kind: ItemKind,
661        near_source: &SourceName,
662        natively_names: Option<ItemKind>,
663    ) -> Result<Vec<Self>, String> {
664        let Some(value) = metadata.get(Self::RECORDED_KEY) else {
665            return Ok(Vec::new());
666        };
667        let far: Vec<DependencyEndpoint> =
668            serde_json::from_value(value.clone()).map_err(|error| {
669                format!(
670                    "{} is not a list of dependency endpoints: {error}",
671                    Self::RECORDED_KEY
672                )
673            })?;
674        far.into_iter()
675            .map(|to| {
676                let names_this_source = to
677                    .source()
678                    .is_none_or(|source| source == near_source.as_str());
679                if names_this_source && natively_names == Some(to.kind) {
680                    return Err(format!(
681                        "{key} on {near} records {to}, which this source can relate \
682                         natively; record it as this backend's own dependency and keep \
683                         {key} for a far end no relationship here can name",
684                        key = Self::RECORDED_KEY
685                    ));
686                }
687                Ok(Self {
688                    from: DependencyEndpoint::from_native(near.clone(), near_kind),
689                    to,
690                    kind: DependencyKind::Blocks,
691                })
692            })
693            .collect()
694    }
695}
696
697/// One endpoint of a dependency edge.
698#[derive(Debug, Clone, PartialEq, Eq, Hash)]
699pub struct DependencyEndpoint {
700    /// A qualified `<source>:<native>` id, or a legacy native id which the engine
701    /// qualifies to the source reporting the edge.
702    id: EndpointIdentity,
703    /// Whether the endpoint names a task or a project.
704    pub kind: ItemKind,
705}
706
707#[derive(Debug, Clone, PartialEq, Eq, Hash)]
708enum EndpointIdentity {
709    Native(String),
710    Qualified(String),
711}
712
713impl EndpointIdentity {
714    fn as_str(&self) -> &str {
715        match self {
716            Self::Native(id) | Self::Qualified(id) => id,
717        }
718    }
719
720    fn into_string(self) -> String {
721        match self {
722            Self::Native(id) | Self::Qualified(id) => id,
723        }
724    }
725}
726
727impl Serialize for DependencyEndpoint {
728    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
729    where
730        S: serde::Serializer,
731    {
732        #[derive(Serialize)]
733        struct Wire<'a> {
734            id: &'a str,
735            kind: ItemKind,
736        }
737        Wire {
738            id: self.id(),
739            kind: self.kind,
740        }
741        .serialize(serializer)
742    }
743}
744
745impl JsonSchema for DependencyEndpoint {
746    fn schema_name() -> std::borrow::Cow<'static, str> {
747        "DependencyEndpoint".into()
748    }
749
750    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
751        json_schema!({
752            "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.",
753            "oneOf": [
754                {"type": "string", "minLength": 1},
755                {
756                    "type": "object",
757                    "additionalProperties": false,
758                    "required": ["id", "kind"],
759                    "properties": {
760                        "id": {"type": "string", "minLength": 1},
761                        "kind": {"type": "string", "enum": ["task", "project"]}
762                    }
763                }
764            ]
765        })
766    }
767}
768
769impl DependencyEndpoint {
770    /// Builds an endpoint from a serialized id, validating a qualified id when present.
771    ///
772    /// # Errors
773    ///
774    /// Returns an error for an empty id or a malformed `<source>:<native>` id.
775    pub fn new(id: String, kind: ItemKind) -> Result<Self, String> {
776        let is_qualified = id.contains(':');
777        let id = valid_endpoint_id(id)?;
778        Ok(Self {
779            id: if is_qualified {
780                EndpointIdentity::Qualified(id)
781            } else {
782                EndpointIdentity::Native(id)
783            },
784            kind,
785        })
786    }
787
788    /// Builds an endpoint from a source-native id, whose contents are deliberately opaque.
789    #[must_use]
790    pub fn from_native(id: NativeId, kind: ItemKind) -> Self {
791        Self {
792            id: EndpointIdentity::Native(id.0),
793            kind,
794        }
795    }
796
797    /// The serialized native or qualified id.
798    #[must_use]
799    pub fn id(&self) -> &str {
800        self.id.as_str()
801    }
802
803    /// Consumes the endpoint and returns its serialized id.
804    #[must_use]
805    pub fn into_id(self) -> String {
806        self.id.into_string()
807    }
808
809    /// Whether the id was explicitly supplied as a qualified endpoint.
810    #[must_use]
811    pub fn is_qualified(&self) -> bool {
812        matches!(self.id, EndpointIdentity::Qualified(_))
813    }
814
815    /// The source segment of a qualified id, or `None` for a native one.
816    ///
817    /// A native id belongs to whichever source reports it, so `None` reads as "this
818    /// source" rather than "no source".
819    #[must_use]
820    pub fn source(&self) -> Option<&str> {
821        match &self.id {
822            EndpointIdentity::Qualified(id) => id.split_once(':').map(|(source, _)| source),
823            EndpointIdentity::Native(_) => None,
824        }
825    }
826}
827
828impl<'de> Deserialize<'de> for DependencyEndpoint {
829    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
830    where
831        D: serde::Deserializer<'de>,
832    {
833        #[derive(Deserialize)]
834        #[serde(untagged)]
835        enum Wire {
836            Legacy(String),
837            Endpoint { id: String, kind: ItemKind },
838        }
839        match Wire::deserialize(deserializer)? {
840            Wire::Legacy(id) => {
841                if id.is_empty() {
842                    return Err(serde::de::Error::custom(
843                        "a dependency endpoint id cannot be empty",
844                    ));
845                }
846                Ok(Self::from_native(NativeId(id), ItemKind::Task))
847            }
848            Wire::Endpoint { id, kind } => Self::new(id, kind).map_err(serde::de::Error::custom),
849        }
850    }
851}
852
853fn valid_endpoint_id(id: String) -> Result<String, String> {
854    if id.is_empty() {
855        return Err("a dependency endpoint id cannot be empty".into());
856    }
857    if let Some((source, native)) = id.split_once(':') {
858        crate::SourceName::new(source).map_err(|error| error.to_string())?;
859        if native.is_empty() {
860            return Err("a qualified dependency endpoint must name a native id".into());
861        }
862    }
863    Ok(id)
864}
865
866impl std::fmt::Display for DependencyEndpoint {
867    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
868        self.id().fmt(formatter)
869    }
870}
871
872impl PartialEq<NativeId> for DependencyEndpoint {
873    fn eq(&self, other: &NativeId) -> bool {
874        self.id() == other.0
875    }
876}
877
878/// The kind of work item named by a dependency endpoint.
879#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
880#[serde(rename_all = "kebab-case")]
881pub enum ItemKind {
882    /// A task.
883    Task,
884    /// A project.
885    Project,
886}
887
888impl ItemKind {
889    /// The reserved metadata key an item is marked with when its backend cannot say
890    /// which kind it is.
891    ///
892    /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is: a key under
893    /// this product's prefix belongs to the product, and a plugin inventing its own
894    /// spelling would collide with the next one to want it.
895    ///
896    /// Unlike the other two reserved keys, this one obliges **no** source. A backend that
897    /// knows its own kinds — folders, native projects — never reads or writes it, and
898    /// passes it through as ordinary caller metadata with its JSON type intact, exactly
899    /// as it passes through every other key it does not own. `github-projects` is the one
900    /// source that needs it, because a GitHub Projects board holds only issues and an
901    /// empty project is indistinguishable from a task without it.
902    pub const METADATA_KEY: &'static str = "onetaskgraph.item_kind";
903
904    /// The value this kind is marked with under [`Self::METADATA_KEY`].
905    #[must_use]
906    pub const fn marker(self) -> &'static str {
907        match self {
908            Self::Task => "task",
909            Self::Project => "project",
910        }
911    }
912
913    /// The kind `metadata` marks, or `None` when it carries no marker at all.
914    ///
915    /// # Errors
916    ///
917    /// Returns a message when [`Self::METADATA_KEY`] holds anything other than the two
918    /// markers [`Self::marker`] spells.
919    pub fn from_metadata(metadata: &BTreeMap<String, Value>) -> Result<Option<Self>, String> {
920        let Some(value) = metadata.get(Self::METADATA_KEY) else {
921            return Ok(None);
922        };
923        match value.as_str() {
924            Some(marker) if marker == Self::Task.marker() => Ok(Some(Self::Task)),
925            Some(marker) if marker == Self::Project.marker() => Ok(Some(Self::Project)),
926            _ => Err(format!(
927                "{} is {value}; it accepts only {:?} or {:?}",
928                Self::METADATA_KEY,
929                Self::Project.marker(),
930                Self::Task.marker()
931            )),
932        }
933    }
934}
935
936/// What a [`DependencyEdge`] means.
937///
938/// Both variants are read in the one direction [`DependencyEdge::from`] fixes: `from`
939/// depends on `to`. This enum said the opposite of that until the orientation was settled,
940/// which is why it is spelled out twice rather than once.
941#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
942#[serde(rename_all = "kebab-case")]
943pub enum DependencyKind {
944    /// `from` depends on `to`, and `to` must finish before `from` can.
945    // 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.
946    Blocks,
947    /// `from` and `to` are linked without an ordering.
948    Related,
949}
950
951/// Which way a dependency query walks the graph.
952#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
953#[serde(rename_all = "kebab-case")]
954pub enum Direction {
955    /// What this item depends on — the forward edges every source can report.
956    DependsOn,
957    /// What depends on this item — emulated by the engine for a
958    /// [`ForwardOnly`](crate::DependencySupport::ForwardOnly) source.
959    DependedOnBy,
960}