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