onetaskgraph-plugin-api 0.2.19

The plugin contract onetaskgraph sources implement: the traits, the work types, and the capability declaration.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
//! The work items every source is normalised into.

use chrono::{DateTime, Utc};
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;

use crate::{NativeId, SourceName};

/// One unit of work as a source reports it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Task {
    /// The source's own opaque identifier.
    pub id: NativeId,
    /// The one-line summary a user recognises the task by.
    pub title: String,
    /// The long-form body, when the source has one.
    pub content: Option<String>,
    /// The source's status, normalised and preserved.
    pub status: Status,
    /// Inline rather than by id: a source returning a task already knows them.
    pub labels: Vec<Label>,
    /// `None` is a first-class case — an orphan task — not an edge case.
    pub project: Option<NativeId>,
    /// Where a human can open this task.
    // 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.
    // 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".
    pub url: Option<String>,
    /// Where this task is, when the source says (see [`Location`]).
    ///
    /// Absent by default, so a source that predates this field — and every source that
    /// simply does not say — reads as `None`, which means *the source did not say where
    /// this is* rather than *this is nowhere*. It neither replaces nor derives from
    /// [`url`](Self::url), which goes on meaning exactly what it always did.
    #[serde(default)]
    pub location: Option<Location>,
    /// When the source says the task was created.
    pub created_at: Option<DateTime<Utc>>,
    /// When the source says the task last changed.
    pub updated_at: Option<DateTime<Utc>>,
    /// Caller-defined attributes, preserving their JSON types.
    ///
    /// Keys are free-form, with two reserved prefixes: `onetaskgraph.` belongs to this
    /// product — [`Repository::METADATA_KEY`] and [`DependencyEdge::RECORDED_KEY`] are
    /// the two every source honours, and [`ItemKind::METADATA_KEY`] is one plugin's —
    /// and `onepipeline.` belongs to that consumer. Every other key is the caller's, and
    /// a source returns it exactly as it holds it.
    #[serde(default)]
    pub metadata: BTreeMap<String, Value>,
    /// Normalized repository origins this task concerns, in source order and without
    /// repeats.
    #[serde(default, deserialize_with = "unique_repositories")]
    pub repositories: Vec<Repository>,
}

/// A grouping of tasks, shaped like a [`Task`] without a parent of its own.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Project {
    /// The source's own opaque identifier.
    pub id: NativeId,
    /// The one-line summary a user recognises the project by.
    pub title: String,
    /// The long-form body, when the source has one.
    pub content: Option<String>,
    /// The source's status, normalised and preserved.
    pub status: Status,
    /// Inline rather than by id, for the same reason as on [`Task`].
    pub labels: Vec<Label>,
    /// Where a human can open this project.
    // 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.
    // 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".
    pub url: Option<String>,
    /// Where this project is, on exactly the terms of [`Task::location`].
    #[serde(default)]
    pub location: Option<Location>,
    /// When the source says the project was created.
    pub created_at: Option<DateTime<Utc>>,
    /// When the source says the project last changed.
    pub updated_at: Option<DateTime<Utc>>,
    /// Caller-defined attributes, preserving their JSON types, on the same terms as
    /// [`Task::metadata`].
    #[serde(default)]
    pub metadata: BTreeMap<String, Value>,
    /// Normalized repository origins this project concerns, in source order and without
    /// repeats.
    #[serde(default, deserialize_with = "unique_repositories")]
    pub repositories: Vec<Repository>,
}

/// One piece of information that lives in a project and is not work.
///
/// A document carries **no status** and **no dependencies**, and both omissions are the
/// contract rather than an oversight: a document is not work, so it has no place in a
/// status filter and no place in a dependency graph. [`ItemKind`] therefore gains no
/// document variant — that enum names what a dependency endpoint points at, and nothing
/// may point at a document.
///
/// A source says whether it has documents at all through
/// [`Capabilities::documents`](crate::Capabilities::documents), and one that says it has
/// none is never asked for one.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Document {
    /// The source's own opaque identifier.
    pub id: NativeId,
    /// The one-line summary a person recognises it by.
    pub title: String,
    /// The long-form body, when the source has one.
    pub content: Option<String>,
    /// The project it lives in; `None` is an orphan document, exactly as it is on a
    /// [`Task`].
    pub project: Option<NativeId>,
    /// Inline, on the same terms as a [`Task`]'s.
    pub labels: Vec<Label>,
    /// Where a person can open it, on the same terms as a [`Task`]'s.
    // 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.
    // 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".
    pub url: Option<String>,
    /// Where it is, when the source says (see [`Location`]).
    #[serde(default)]
    pub location: Option<Location>,
    /// When the source says it was created.
    pub created_at: Option<DateTime<Utc>>,
    /// When the source says it last changed.
    pub updated_at: Option<DateTime<Utc>>,
    /// Caller-defined attributes, preserving their JSON types, with the same reserved
    /// prefixes [`Task::metadata`] carries.
    #[serde(default)]
    pub metadata: BTreeMap<String, Value>,
    /// Normalized repository origins this document concerns, in source order and without
    /// repeats, as a [`Task`]'s.
    #[serde(default, deserialize_with = "unique_repositories")]
    pub repositories: Vec<Repository>,
}

/// Where an entity is, in the one form a consumer can act on without knowing the backend.
///
/// Externally tagged with exactly two variants, so the JSON is `{"url": "https://…"}` or
/// `{"path": "/home/…"}` and a consumer tells them apart by which key is present. A reader
/// handed one of these knows what to *do* with it — open a link, or print a path and read
/// the file out — which is what a bare string could not have said.
///
/// It carries no third case on purpose. `None` on the field is the third case, and it
/// means the source did not say where the entity is, which is not the same as saying it is
/// nowhere.
///
/// This does **not** redefine, replace or derive from the `url` field of [`Task`],
/// [`Project`] or [`Document`]: a source that reports a web URL there goes on reporting
/// it, and every existing consumer sees exactly what it saw.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Location {
    /// The entity lives at an external website, and this is a link a reader can open.
    // 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.
    // 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`.
    Url(String),
    /// The entity is a file on the machine the source runs on, and this is that file's
    /// absolute path, so a reader can print the path or read the contents out.
    // 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.
    // 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.
    Path(String),
}

/// A repository identified by its normalized origin, without a URL scheme or `.git` suffix.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(try_from = "String", into = "String")]
pub struct Repository(String);

impl Repository {
    /// The reserved metadata key a source reads these origins from when its backend has
    /// no notion of its own.
    ///
    /// The key is spelled once, here, because every plugin has to agree on it: a source
    /// that invented its own spelling would hold work nothing else could read.
    pub const METADATA_KEY: &'static str = "onetaskgraph.repositories";

    /// The normalized `host/owner/name` origin.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// The origins a source records under [`Self::METADATA_KEY`], or none.
    ///
    /// # Errors
    ///
    /// Returns a message when the key holds something other than a duplicate-free list
    /// of normalized origins.
    pub fn from_metadata(metadata: &BTreeMap<String, Value>) -> Result<Vec<Self>, String> {
        let Some(value) = metadata.get(Self::METADATA_KEY) else {
            return Ok(Vec::new());
        };
        let origins: Vec<Self> = serde_json::from_value(value.clone()).map_err(|error| {
            format!(
                "{} is not a list of repository origins: {error}",
                Self::METADATA_KEY
            )
        })?;
        Self::unique(origins)
    }

    /// The same origins, in the order given, once it is established none repeats.
    ///
    /// # Errors
    ///
    /// Returns a message naming the first origin that appears twice.
    pub fn unique(origins: Vec<Self>) -> Result<Vec<Self>, String> {
        let mut seen = std::collections::BTreeSet::new();
        for origin in &origins {
            if !seen.insert(origin.as_str()) {
                return Err(format!(
                    "{:?} is listed twice; a repository list names each origin once",
                    origin.as_str()
                ));
            }
        }
        Ok(origins)
    }
}

fn unique_repositories<'de, D>(deserializer: D) -> Result<Vec<Repository>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    Repository::unique(Vec::<Repository>::deserialize(deserializer)?)
        .map_err(serde::de::Error::custom)
}

impl TryFrom<String> for Repository {
    type Error = String;

    fn try_from(origin: String) -> Result<Self, Self::Error> {
        let valid = !origin.is_empty()
            && !origin.contains("://")
            && !origin.ends_with(".git")
            && !origin.chars().any(char::is_whitespace)
            && origin.split('/').count() >= 3
            && origin
                .split('/')
                .all(|part| !part.is_empty() && part != "." && part != "..");
        valid.then_some(Self(origin.clone())).ok_or_else(|| format!(
            "{origin:?} is not a normalized repository origin; use host/owner/name without a scheme or .git suffix"
        ))
    }
}

impl From<Repository> for String {
    fn from(repository: Repository) -> Self {
        repository.0
    }
}

/// A tag a source attaches to work.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Label {
    /// The source's own opaque identifier.
    pub id: NativeId,
    /// What a user filtering across sources actually types.
    pub name: String,
    /// The source's own colour for the label, when it has one.
    pub color: Option<String>,
}

/// A source's status, kept in both normalised and original form.
///
/// `category` is what every filter compares against; `name` is the source's own
/// wording, preserved so display never flattens "In Review" into "In Progress".
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Status {
    /// The normalised value filters compare against.
    pub category: StatusCategory,
    /// The source's own label for this status.
    pub name: String,
}

/// The normalised status vocabulary shared across every source.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum StatusCategory {
    /// Written down but not yet committed to as work.
    Draft,
    /// Known about, not yet queued.
    Backlog,
    /// Queued, not yet started.
    Todo,
    /// Being worked on.
    InProgress,
    /// Finished.
    Done,
    /// Abandoned.
    Cancelled,
    /// The source reported a status this vocabulary cannot place.
    Unknown,
}

/// A dependency between two work items.
///
/// An endpoint may name another source. Keeping that far id on the near item is work data
/// owned by its plugin, not an engine-side index or mirror; the engine reports it without
/// resolving or fetching the far item.
///
/// A source uses its backend's own relationship wherever that relationship can name the
/// far end, so the backend knows the graph and its own interface draws it. Where it
/// cannot — a far end in another source, which no backend relates — the source reads
/// [`Self::recorded`] from the near item instead. Only the forward direction is ever
/// recorded; the reverse of a recorded edge is derived, exactly as a
/// [`ForwardOnly`](crate::DependencySupport::ForwardOnly) source's reverse is.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct DependencyEdge {
    /// The item the edge starts at, and the one that **depends on** the other.
    ///
    /// This is the orientation every source reports in, whichever way its own backend
    /// spells the relationship: a GitHub `blockedBy` connection read for `ENG-1` yields
    /// `from: ENG-1`, because `ENG-1` is what depends.
    pub from: DependencyEndpoint,
    /// The item the edge points at, and the one that must finish first.
    pub to: DependencyEndpoint,
    /// What the edge means.
    pub kind: DependencyKind,
}

impl DependencyEdge {
    /// The reserved metadata key a near item records a far end under.
    ///
    /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is: a plugin that
    /// invented its own spelling would record a plan nothing else could read.
    pub const RECORDED_KEY: &'static str = "onetaskgraph.depends_on";

    /// The forward edges `near` records under [`Self::RECORDED_KEY`], or none.
    ///
    /// The key holds a list of endpoints — a bare string is a native id naming a task,
    /// and `{"id": "<source>:<native>", "kind": "project"}` names any item of any source.
    /// Each becomes one `blocks` edge from `near` to that endpoint.
    ///
    /// `natively_names` is the kind of item the near item's **own backend** can relate it
    /// to — `Some(ItemKind::Task)` for a GitHub issue, whose `blockedBy` connection holds
    /// issues; `None` for a GitHub draft, which has no such connection at all. An endpoint
    /// of that kind naming an item of `near_source` is refused, because it names an item
    /// the backend itself could hold, and the rule this key exists to serve is the
    /// backend's own relationship first. Naming one's own source is what an unqualified id
    /// does implicitly and what `<near_source>:<native>` does in writing, so both are
    /// refused: which of the two spellings a plan happened to use says nothing about where
    /// the edge belongs.
    ///
    /// An endpoint qualified to a *different* source is never refused. That is the whole
    /// case this key is for: no backend relates an id in a system it knows nothing about.
    ///
    /// # Errors
    ///
    /// Returns a message when the key holds anything other than a list of endpoints, or
    /// holds one the near item's own backend was supposed to name.
    pub fn recorded(
        metadata: &BTreeMap<String, Value>,
        near: &NativeId,
        near_kind: ItemKind,
        near_source: &SourceName,
        natively_names: Option<ItemKind>,
    ) -> Result<Vec<Self>, String> {
        let Some(value) = metadata.get(Self::RECORDED_KEY) else {
            return Ok(Vec::new());
        };
        let far: Vec<DependencyEndpoint> =
            serde_json::from_value(value.clone()).map_err(|error| {
                format!(
                    "{} is not a list of dependency endpoints: {error}",
                    Self::RECORDED_KEY
                )
            })?;
        far.into_iter()
            .map(|to| {
                let names_this_source = to
                    .source()
                    .is_none_or(|source| source == near_source.as_str());
                if names_this_source && natively_names == Some(to.kind) {
                    return Err(format!(
                        "{key} on {near} records {to}, which this source can relate \
                         natively; record it as this backend's own dependency and keep \
                         {key} for a far end no relationship here can name",
                        key = Self::RECORDED_KEY
                    ));
                }
                Ok(Self {
                    from: DependencyEndpoint::from_native(near.clone(), near_kind),
                    to,
                    kind: DependencyKind::Blocks,
                })
            })
            .collect()
    }
}

/// One endpoint of a dependency edge.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DependencyEndpoint {
    /// A qualified `<source>:<native>` id, or a legacy native id which the engine
    /// qualifies to the source reporting the edge.
    id: EndpointIdentity,
    /// Whether the endpoint names a task or a project.
    pub kind: ItemKind,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum EndpointIdentity {
    Native(String),
    Qualified(String),
}

impl EndpointIdentity {
    fn as_str(&self) -> &str {
        match self {
            Self::Native(id) | Self::Qualified(id) => id,
        }
    }

    fn into_string(self) -> String {
        match self {
            Self::Native(id) | Self::Qualified(id) => id,
        }
    }
}

impl Serialize for DependencyEndpoint {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        #[derive(Serialize)]
        struct Wire<'a> {
            id: &'a str,
            kind: ItemKind,
        }
        Wire {
            id: self.id(),
            kind: self.kind,
        }
        .serialize(serializer)
    }
}

impl JsonSchema for DependencyEndpoint {
    fn schema_name() -> std::borrow::Cow<'static, str> {
        "DependencyEndpoint".into()
    }

    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
        json_schema!({
            "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.",
            "oneOf": [
                {"type": "string", "minLength": 1},
                {
                    "type": "object",
                    "additionalProperties": false,
                    "required": ["id", "kind"],
                    "properties": {
                        "id": {"type": "string", "minLength": 1},
                        "kind": {"type": "string", "enum": ["task", "project"]}
                    }
                }
            ]
        })
    }
}

impl DependencyEndpoint {
    /// Builds an endpoint from a serialized id, validating a qualified id when present.
    ///
    /// # Errors
    ///
    /// Returns an error for an empty id or a malformed `<source>:<native>` id.
    pub fn new(id: String, kind: ItemKind) -> Result<Self, String> {
        let is_qualified = id.contains(':');
        let id = valid_endpoint_id(id)?;
        Ok(Self {
            id: if is_qualified {
                EndpointIdentity::Qualified(id)
            } else {
                EndpointIdentity::Native(id)
            },
            kind,
        })
    }

    /// Builds an endpoint from a source-native id, whose contents are deliberately opaque.
    #[must_use]
    pub fn from_native(id: NativeId, kind: ItemKind) -> Self {
        Self {
            id: EndpointIdentity::Native(id.0),
            kind,
        }
    }

    /// The serialized native or qualified id.
    #[must_use]
    pub fn id(&self) -> &str {
        self.id.as_str()
    }

    /// Consumes the endpoint and returns its serialized id.
    #[must_use]
    pub fn into_id(self) -> String {
        self.id.into_string()
    }

    /// Whether the id was explicitly supplied as a qualified endpoint.
    #[must_use]
    pub fn is_qualified(&self) -> bool {
        matches!(self.id, EndpointIdentity::Qualified(_))
    }

    /// The source segment of a qualified id, or `None` for a native one.
    ///
    /// A native id belongs to whichever source reports it, so `None` reads as "this
    /// source" rather than "no source".
    #[must_use]
    pub fn source(&self) -> Option<&str> {
        match &self.id {
            EndpointIdentity::Qualified(id) => id.split_once(':').map(|(source, _)| source),
            EndpointIdentity::Native(_) => None,
        }
    }
}

impl<'de> Deserialize<'de> for DependencyEndpoint {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        #[derive(Deserialize)]
        #[serde(untagged)]
        enum Wire {
            Legacy(String),
            Endpoint { id: String, kind: ItemKind },
        }
        match Wire::deserialize(deserializer)? {
            Wire::Legacy(id) => {
                if id.is_empty() {
                    return Err(serde::de::Error::custom(
                        "a dependency endpoint id cannot be empty",
                    ));
                }
                Ok(Self::from_native(NativeId(id), ItemKind::Task))
            }
            Wire::Endpoint { id, kind } => Self::new(id, kind).map_err(serde::de::Error::custom),
        }
    }
}

fn valid_endpoint_id(id: String) -> Result<String, String> {
    if id.is_empty() {
        return Err("a dependency endpoint id cannot be empty".into());
    }
    if let Some((source, native)) = id.split_once(':') {
        crate::SourceName::new(source).map_err(|error| error.to_string())?;
        if native.is_empty() {
            return Err("a qualified dependency endpoint must name a native id".into());
        }
    }
    Ok(id)
}

impl std::fmt::Display for DependencyEndpoint {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.id().fmt(formatter)
    }
}

impl PartialEq<NativeId> for DependencyEndpoint {
    fn eq(&self, other: &NativeId) -> bool {
        self.id() == other.0
    }
}

/// The kind of work item named by a dependency endpoint.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ItemKind {
    /// A task.
    Task,
    /// A project.
    Project,
}

impl ItemKind {
    /// The reserved metadata key an item is marked with when its backend cannot say
    /// which kind it is.
    ///
    /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is: a key under
    /// this product's prefix belongs to the product, and a plugin inventing its own
    /// spelling would collide with the next one to want it.
    ///
    /// Unlike the other two reserved keys, this one obliges **no** source. A backend that
    /// knows its own kinds — folders, native projects — never reads or writes it, and
    /// passes it through as ordinary caller metadata with its JSON type intact, exactly
    /// as it passes through every other key it does not own. `github-projects` is the one
    /// source that needs it, because a GitHub Projects board holds only issues and an
    /// empty project is indistinguishable from a task without it.
    pub const METADATA_KEY: &'static str = "onetaskgraph.item_kind";

    /// The value this kind is marked with under [`Self::METADATA_KEY`].
    #[must_use]
    pub const fn marker(self) -> &'static str {
        match self {
            Self::Task => "task",
            Self::Project => "project",
        }
    }

    /// The kind `metadata` marks, or `None` when it carries no marker at all.
    ///
    /// # Errors
    ///
    /// Returns a message when [`Self::METADATA_KEY`] holds anything other than the two
    /// markers [`Self::marker`] spells.
    pub fn from_metadata(metadata: &BTreeMap<String, Value>) -> Result<Option<Self>, String> {
        let Some(value) = metadata.get(Self::METADATA_KEY) else {
            return Ok(None);
        };
        match value.as_str() {
            Some(marker) if marker == Self::Task.marker() => Ok(Some(Self::Task)),
            Some(marker) if marker == Self::Project.marker() => Ok(Some(Self::Project)),
            _ => Err(format!(
                "{} is {value}; it accepts only {:?} or {:?}",
                Self::METADATA_KEY,
                Self::Project.marker(),
                Self::Task.marker()
            )),
        }
    }
}

/// What a [`DependencyEdge`] means.
///
/// Both variants are read in the one direction [`DependencyEdge::from`] fixes: `from`
/// depends on `to`. This enum said the opposite of that until the orientation was settled,
/// which is why it is spelled out twice rather than once.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum DependencyKind {
    /// `from` depends on `to`, and `to` must finish before `from` can.
    // 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.
    Blocks,
    /// `from` and `to` are linked without an ordering.
    Related,
}

/// Which way a dependency query walks the graph.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Direction {
    /// What this item depends on — the forward edges every source can report.
    DependsOn,
    /// What depends on this item — emulated by the engine for a
    /// [`ForwardOnly`](crate::DependencySupport::ForwardOnly) source.
    DependedOnBy,
}