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}
56
57/// A grouping of tasks, shaped like a [`Task`] without a parent of its own.
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
59pub struct Project {
60    /// The source's own opaque identifier.
61    pub id: NativeId,
62    /// The one-line summary a user recognises the project by.
63    pub title: String,
64    /// The long-form body, when the source has one.
65    pub content: Option<String>,
66    /// The source's status, normalised and preserved.
67    pub status: Status,
68    /// Inline rather than by id, for the same reason as on [`Task`].
69    pub labels: Vec<Label>,
70    /// Where a human can open this project.
71    // 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.
72    // 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".
73    pub url: Option<String>,
74    /// Where this project is, on exactly the terms of [`Task::location`].
75    #[serde(default)]
76    pub location: Option<Location>,
77    /// When the source says the project was created.
78    pub created_at: Option<DateTime<Utc>>,
79    /// When the source says the project last changed.
80    pub updated_at: Option<DateTime<Utc>>,
81    /// Caller-defined attributes, preserving their JSON types, on the same terms as
82    /// [`Task::metadata`].
83    #[serde(default)]
84    pub metadata: BTreeMap<String, Value>,
85    /// Normalized repository origins this project concerns, in source order and without
86    /// repeats.
87    #[serde(default, deserialize_with = "unique_repositories")]
88    pub repositories: Vec<Repository>,
89}
90
91/// One piece of information that lives in a project and is not work.
92///
93/// A document carries **no status** and **no dependencies**, and both omissions are the
94/// contract rather than an oversight: a document is not work, so it has no place in a
95/// status filter and no place in a dependency graph. [`ItemKind`] therefore gains no
96/// document variant — that enum names what a dependency endpoint points at, and nothing
97/// may point at a document.
98///
99/// A source says whether it has documents at all through
100/// [`Capabilities::documents`](crate::Capabilities::documents), and one that says it has
101/// none is never asked for one.
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
103pub struct Document {
104    /// The source's own opaque identifier.
105    pub id: NativeId,
106    /// The one-line summary a person recognises it by.
107    pub title: String,
108    /// The long-form body, when the source has one.
109    pub content: Option<String>,
110    /// The project it lives in; `None` is an orphan document, exactly as it is on a
111    /// [`Task`].
112    pub project: Option<NativeId>,
113    /// Inline, on the same terms as a [`Task`]'s.
114    pub labels: Vec<Label>,
115    /// Where a person can open it, on the same terms as a [`Task`]'s.
116    // 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.
117    // 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".
118    pub url: Option<String>,
119    /// Where it is, when the source says (see [`Location`]).
120    #[serde(default)]
121    pub location: Option<Location>,
122    /// When the source says it was created.
123    pub created_at: Option<DateTime<Utc>>,
124    /// When the source says it last changed.
125    pub updated_at: Option<DateTime<Utc>>,
126    /// Caller-defined attributes, preserving their JSON types, with the same reserved
127    /// prefixes [`Task::metadata`] carries.
128    #[serde(default)]
129    pub metadata: BTreeMap<String, Value>,
130    /// Normalized repository origins this document concerns, in source order and without
131    /// repeats, as a [`Task`]'s.
132    #[serde(default, deserialize_with = "unique_repositories")]
133    pub repositories: Vec<Repository>,
134}
135
136/// Where an entity is, in the one form a consumer can act on without knowing the backend.
137///
138/// Externally tagged with exactly two variants, so the JSON is `{"url": "https://…"}` or
139/// `{"path": "/home/…"}` and a consumer tells them apart by which key is present. A reader
140/// handed one of these knows what to *do* with it — open a link, or print a path and read
141/// the file out — which is what a bare string could not have said.
142///
143/// It carries no third case on purpose. `None` on the field is the third case, and it
144/// means the source did not say where the entity is, which is not the same as saying it is
145/// nowhere.
146///
147/// This does **not** redefine, replace or derive from the `url` field of [`Task`],
148/// [`Project`] or [`Document`]: a source that reports a web URL there goes on reporting
149/// it, and every existing consumer sees exactly what it saw.
150#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
151#[serde(rename_all = "kebab-case")]
152pub enum Location {
153    /// The entity lives at an external website, and this is a link a reader can open.
154    // 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.
155    // 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`.
156    Url(String),
157    /// The entity is a file on the machine the source runs on, and this is that file's
158    /// absolute path, so a reader can print the path or read the contents out.
159    // 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.
160    // 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.
161    Path(String),
162}
163
164/// A repository identified by its normalized origin, without a URL scheme or `.git` suffix.
165#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
166#[serde(try_from = "String", into = "String")]
167pub struct Repository(String);
168
169impl Repository {
170    /// The reserved metadata key a source reads these origins from when its backend has
171    /// no notion of its own.
172    ///
173    /// The key is spelled once, here, because every plugin has to agree on it: a source
174    /// that invented its own spelling would hold work nothing else could read.
175    pub const METADATA_KEY: &'static str = "onetaskgraph.repositories";
176
177    /// The normalized `host/owner/name` origin.
178    #[must_use]
179    pub fn as_str(&self) -> &str {
180        &self.0
181    }
182
183    /// The origins a source records under [`Self::METADATA_KEY`], or none.
184    ///
185    /// # Errors
186    ///
187    /// Returns a message when the key holds something other than a duplicate-free list
188    /// of normalized origins.
189    pub fn from_metadata(metadata: &BTreeMap<String, Value>) -> Result<Vec<Self>, String> {
190        let Some(value) = metadata.get(Self::METADATA_KEY) else {
191            return Ok(Vec::new());
192        };
193        let origins: Vec<Self> = serde_json::from_value(value.clone()).map_err(|error| {
194            format!(
195                "{} is not a list of repository origins: {error}",
196                Self::METADATA_KEY
197            )
198        })?;
199        Self::unique(origins)
200    }
201
202    /// The same origins, in the order given, once it is established none repeats.
203    ///
204    /// # Errors
205    ///
206    /// Returns a message naming the first origin that appears twice.
207    pub fn unique(origins: Vec<Self>) -> Result<Vec<Self>, String> {
208        let mut seen = std::collections::BTreeSet::new();
209        for origin in &origins {
210            if !seen.insert(origin.as_str()) {
211                return Err(format!(
212                    "{:?} is listed twice; a repository list names each origin once",
213                    origin.as_str()
214                ));
215            }
216        }
217        Ok(origins)
218    }
219}
220
221fn unique_repositories<'de, D>(deserializer: D) -> Result<Vec<Repository>, D::Error>
222where
223    D: serde::Deserializer<'de>,
224{
225    Repository::unique(Vec::<Repository>::deserialize(deserializer)?)
226        .map_err(serde::de::Error::custom)
227}
228
229impl TryFrom<String> for Repository {
230    type Error = String;
231
232    fn try_from(origin: String) -> Result<Self, Self::Error> {
233        let valid = !origin.is_empty()
234            && !origin.contains("://")
235            && !origin.ends_with(".git")
236            && !origin.chars().any(char::is_whitespace)
237            && origin.split('/').count() >= 3
238            && origin
239                .split('/')
240                .all(|part| !part.is_empty() && part != "." && part != "..");
241        valid.then_some(Self(origin.clone())).ok_or_else(|| format!(
242            "{origin:?} is not a normalized repository origin; use host/owner/name without a scheme or .git suffix"
243        ))
244    }
245}
246
247impl From<Repository> for String {
248    fn from(repository: Repository) -> Self {
249        repository.0
250    }
251}
252
253/// A tag a source attaches to work.
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
255pub struct Label {
256    /// The source's own opaque identifier.
257    pub id: NativeId,
258    /// What a user filtering across sources actually types.
259    pub name: String,
260    /// The source's own colour for the label, when it has one.
261    pub color: Option<String>,
262}
263
264/// A source's status, kept in both normalised and original form.
265///
266/// `category` is what every filter compares against; `name` is the source's own
267/// wording, preserved so display never flattens "In Review" into "In Progress".
268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
269pub struct Status {
270    /// The normalised value filters compare against.
271    pub category: StatusCategory,
272    /// The source's own label for this status.
273    pub name: String,
274}
275
276/// The normalised status vocabulary shared across every source.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
278#[serde(rename_all = "kebab-case")]
279pub enum StatusCategory {
280    /// Written down but not yet committed to as work.
281    Draft,
282    /// Known about, not yet queued.
283    Backlog,
284    /// Queued, not yet started.
285    Todo,
286    /// Being worked on.
287    InProgress,
288    /// Finished.
289    Done,
290    /// Abandoned.
291    Cancelled,
292    /// The source reported a status this vocabulary cannot place.
293    Unknown,
294}
295
296/// A dependency between two work items.
297///
298/// An endpoint may name another source. Keeping that far id on the near item is work data
299/// owned by its plugin, not an engine-side index or mirror; the engine reports it without
300/// resolving or fetching the far item.
301///
302/// A source uses its backend's own relationship wherever that relationship can name the
303/// far end, so the backend knows the graph and its own interface draws it. Where it
304/// cannot — a far end in another source, which no backend relates — the source reads
305/// [`Self::recorded`] from the near item instead. Only the forward direction is ever
306/// recorded; the reverse of a recorded edge is derived, exactly as a
307/// [`ForwardOnly`](crate::DependencySupport::ForwardOnly) source's reverse is.
308#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
309pub struct DependencyEdge {
310    /// The item the edge starts at, and the one that **depends on** the other.
311    ///
312    /// This is the orientation every source reports in, whichever way its own backend
313    /// spells the relationship: a GitHub `blockedBy` connection read for `ENG-1` yields
314    /// `from: ENG-1`, because `ENG-1` is what depends.
315    pub from: DependencyEndpoint,
316    /// The item the edge points at, and the one that must finish first.
317    pub to: DependencyEndpoint,
318    /// What the edge means.
319    pub kind: DependencyKind,
320}
321
322impl DependencyEdge {
323    /// The reserved metadata key a near item records a far end under.
324    ///
325    /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is: a plugin that
326    /// invented its own spelling would record a plan nothing else could read.
327    pub const RECORDED_KEY: &'static str = "onetaskgraph.depends_on";
328
329    /// The forward edges `near` records under [`Self::RECORDED_KEY`], or none.
330    ///
331    /// The key holds a list of endpoints — a bare string is a native id naming a task,
332    /// and `{"id": "<source>:<native>", "kind": "project"}` names any item of any source.
333    /// Each becomes one `blocks` edge from `near` to that endpoint.
334    ///
335    /// `natively_names` is the kind of item the near item's **own backend** can relate it
336    /// to — `Some(ItemKind::Task)` for a GitHub issue, whose `blockedBy` connection holds
337    /// issues; `None` for a GitHub draft, which has no such connection at all. An endpoint
338    /// of that kind naming an item of `near_source` is refused, because it names an item
339    /// the backend itself could hold, and the rule this key exists to serve is the
340    /// backend's own relationship first. Naming one's own source is what an unqualified id
341    /// does implicitly and what `<near_source>:<native>` does in writing, so both are
342    /// refused: which of the two spellings a plan happened to use says nothing about where
343    /// the edge belongs.
344    ///
345    /// An endpoint qualified to a *different* source is never refused. That is the whole
346    /// case this key is for: no backend relates an id in a system it knows nothing about.
347    ///
348    /// # Errors
349    ///
350    /// Returns a message when the key holds anything other than a list of endpoints, or
351    /// holds one the near item's own backend was supposed to name.
352    pub fn recorded(
353        metadata: &BTreeMap<String, Value>,
354        near: &NativeId,
355        near_kind: ItemKind,
356        near_source: &SourceName,
357        natively_names: Option<ItemKind>,
358    ) -> Result<Vec<Self>, String> {
359        let Some(value) = metadata.get(Self::RECORDED_KEY) else {
360            return Ok(Vec::new());
361        };
362        let far: Vec<DependencyEndpoint> =
363            serde_json::from_value(value.clone()).map_err(|error| {
364                format!(
365                    "{} is not a list of dependency endpoints: {error}",
366                    Self::RECORDED_KEY
367                )
368            })?;
369        far.into_iter()
370            .map(|to| {
371                let names_this_source = to
372                    .source()
373                    .is_none_or(|source| source == near_source.as_str());
374                if names_this_source && natively_names == Some(to.kind) {
375                    return Err(format!(
376                        "{key} on {near} records {to}, which this source can relate \
377                         natively; record it as this backend's own dependency and keep \
378                         {key} for a far end no relationship here can name",
379                        key = Self::RECORDED_KEY
380                    ));
381                }
382                Ok(Self {
383                    from: DependencyEndpoint::from_native(near.clone(), near_kind),
384                    to,
385                    kind: DependencyKind::Blocks,
386                })
387            })
388            .collect()
389    }
390}
391
392/// One endpoint of a dependency edge.
393#[derive(Debug, Clone, PartialEq, Eq, Hash)]
394pub struct DependencyEndpoint {
395    /// A qualified `<source>:<native>` id, or a legacy native id which the engine
396    /// qualifies to the source reporting the edge.
397    id: EndpointIdentity,
398    /// Whether the endpoint names a task or a project.
399    pub kind: ItemKind,
400}
401
402#[derive(Debug, Clone, PartialEq, Eq, Hash)]
403enum EndpointIdentity {
404    Native(String),
405    Qualified(String),
406}
407
408impl EndpointIdentity {
409    fn as_str(&self) -> &str {
410        match self {
411            Self::Native(id) | Self::Qualified(id) => id,
412        }
413    }
414
415    fn into_string(self) -> String {
416        match self {
417            Self::Native(id) | Self::Qualified(id) => id,
418        }
419    }
420}
421
422impl Serialize for DependencyEndpoint {
423    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
424    where
425        S: serde::Serializer,
426    {
427        #[derive(Serialize)]
428        struct Wire<'a> {
429            id: &'a str,
430            kind: ItemKind,
431        }
432        Wire {
433            id: self.id(),
434            kind: self.kind,
435        }
436        .serialize(serializer)
437    }
438}
439
440impl JsonSchema for DependencyEndpoint {
441    fn schema_name() -> std::borrow::Cow<'static, str> {
442        "DependencyEndpoint".into()
443    }
444
445    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
446        json_schema!({
447            "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.",
448            "oneOf": [
449                {"type": "string", "minLength": 1},
450                {
451                    "type": "object",
452                    "additionalProperties": false,
453                    "required": ["id", "kind"],
454                    "properties": {
455                        "id": {"type": "string", "minLength": 1},
456                        "kind": {"type": "string", "enum": ["task", "project"]}
457                    }
458                }
459            ]
460        })
461    }
462}
463
464impl DependencyEndpoint {
465    /// Builds an endpoint from a serialized id, validating a qualified id when present.
466    ///
467    /// # Errors
468    ///
469    /// Returns an error for an empty id or a malformed `<source>:<native>` id.
470    pub fn new(id: String, kind: ItemKind) -> Result<Self, String> {
471        let is_qualified = id.contains(':');
472        let id = valid_endpoint_id(id)?;
473        Ok(Self {
474            id: if is_qualified {
475                EndpointIdentity::Qualified(id)
476            } else {
477                EndpointIdentity::Native(id)
478            },
479            kind,
480        })
481    }
482
483    /// Builds an endpoint from a source-native id, whose contents are deliberately opaque.
484    #[must_use]
485    pub fn from_native(id: NativeId, kind: ItemKind) -> Self {
486        Self {
487            id: EndpointIdentity::Native(id.0),
488            kind,
489        }
490    }
491
492    /// The serialized native or qualified id.
493    #[must_use]
494    pub fn id(&self) -> &str {
495        self.id.as_str()
496    }
497
498    /// Consumes the endpoint and returns its serialized id.
499    #[must_use]
500    pub fn into_id(self) -> String {
501        self.id.into_string()
502    }
503
504    /// Whether the id was explicitly supplied as a qualified endpoint.
505    #[must_use]
506    pub fn is_qualified(&self) -> bool {
507        matches!(self.id, EndpointIdentity::Qualified(_))
508    }
509
510    /// The source segment of a qualified id, or `None` for a native one.
511    ///
512    /// A native id belongs to whichever source reports it, so `None` reads as "this
513    /// source" rather than "no source".
514    #[must_use]
515    pub fn source(&self) -> Option<&str> {
516        match &self.id {
517            EndpointIdentity::Qualified(id) => id.split_once(':').map(|(source, _)| source),
518            EndpointIdentity::Native(_) => None,
519        }
520    }
521}
522
523impl<'de> Deserialize<'de> for DependencyEndpoint {
524    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
525    where
526        D: serde::Deserializer<'de>,
527    {
528        #[derive(Deserialize)]
529        #[serde(untagged)]
530        enum Wire {
531            Legacy(String),
532            Endpoint { id: String, kind: ItemKind },
533        }
534        match Wire::deserialize(deserializer)? {
535            Wire::Legacy(id) => {
536                if id.is_empty() {
537                    return Err(serde::de::Error::custom(
538                        "a dependency endpoint id cannot be empty",
539                    ));
540                }
541                Ok(Self::from_native(NativeId(id), ItemKind::Task))
542            }
543            Wire::Endpoint { id, kind } => Self::new(id, kind).map_err(serde::de::Error::custom),
544        }
545    }
546}
547
548fn valid_endpoint_id(id: String) -> Result<String, String> {
549    if id.is_empty() {
550        return Err("a dependency endpoint id cannot be empty".into());
551    }
552    if let Some((source, native)) = id.split_once(':') {
553        crate::SourceName::new(source).map_err(|error| error.to_string())?;
554        if native.is_empty() {
555            return Err("a qualified dependency endpoint must name a native id".into());
556        }
557    }
558    Ok(id)
559}
560
561impl std::fmt::Display for DependencyEndpoint {
562    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
563        self.id().fmt(formatter)
564    }
565}
566
567impl PartialEq<NativeId> for DependencyEndpoint {
568    fn eq(&self, other: &NativeId) -> bool {
569        self.id() == other.0
570    }
571}
572
573/// The kind of work item named by a dependency endpoint.
574#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
575#[serde(rename_all = "kebab-case")]
576pub enum ItemKind {
577    /// A task.
578    Task,
579    /// A project.
580    Project,
581}
582
583impl ItemKind {
584    /// The reserved metadata key an item is marked with when its backend cannot say
585    /// which kind it is.
586    ///
587    /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is: a key under
588    /// this product's prefix belongs to the product, and a plugin inventing its own
589    /// spelling would collide with the next one to want it.
590    ///
591    /// Unlike the other two reserved keys, this one obliges **no** source. A backend that
592    /// knows its own kinds — folders, native projects — never reads or writes it, and
593    /// passes it through as ordinary caller metadata with its JSON type intact, exactly
594    /// as it passes through every other key it does not own. `github-projects` is the one
595    /// source that needs it, because a GitHub Projects board holds only issues and an
596    /// empty project is indistinguishable from a task without it.
597    pub const METADATA_KEY: &'static str = "onetaskgraph.item_kind";
598
599    /// The value this kind is marked with under [`Self::METADATA_KEY`].
600    #[must_use]
601    pub const fn marker(self) -> &'static str {
602        match self {
603            Self::Task => "task",
604            Self::Project => "project",
605        }
606    }
607
608    /// The kind `metadata` marks, or `None` when it carries no marker at all.
609    ///
610    /// # Errors
611    ///
612    /// Returns a message when [`Self::METADATA_KEY`] holds anything other than the two
613    /// markers [`Self::marker`] spells.
614    pub fn from_metadata(metadata: &BTreeMap<String, Value>) -> Result<Option<Self>, String> {
615        let Some(value) = metadata.get(Self::METADATA_KEY) else {
616            return Ok(None);
617        };
618        match value.as_str() {
619            Some(marker) if marker == Self::Task.marker() => Ok(Some(Self::Task)),
620            Some(marker) if marker == Self::Project.marker() => Ok(Some(Self::Project)),
621            _ => Err(format!(
622                "{} is {value}; it accepts only {:?} or {:?}",
623                Self::METADATA_KEY,
624                Self::Project.marker(),
625                Self::Task.marker()
626            )),
627        }
628    }
629}
630
631/// What a [`DependencyEdge`] means.
632///
633/// Both variants are read in the one direction [`DependencyEdge::from`] fixes: `from`
634/// depends on `to`. This enum said the opposite of that until the orientation was settled,
635/// which is why it is spelled out twice rather than once.
636#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
637#[serde(rename_all = "kebab-case")]
638pub enum DependencyKind {
639    /// `from` depends on `to`, and `to` must finish before `from` can.
640    // 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.
641    Blocks,
642    /// `from` and `to` are linked without an ordering.
643    Related,
644}
645
646/// Which way a dependency query walks the graph.
647#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
648#[serde(rename_all = "kebab-case")]
649pub enum Direction {
650    /// What this item depends on — the forward edges every source can report.
651    DependsOn,
652    /// What depends on this item — emulated by the engine for a
653    /// [`ForwardOnly`](crate::DependencySupport::ForwardOnly) source.
654    DependedOnBy,
655}