Skip to main content

onetaskgraph_plugin_api/
work.rs

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