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