onetaskgraph-plugin-api 0.2.36

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
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
//! 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>,
    /// The tasks this one delivers: finishing this task finishes them.
    ///
    /// Each entry is a [`TaskRef`] — `<source>:<native>` names a task of any source, and a
    /// bare native id names a task of the source holding this one — with no repeats and
    /// never this task itself. Empty by default, and left out of the wire when empty, so a
    /// reader written before the field existed reads exactly what it read before.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    #[schemars(!skip_serializing_if)]
    pub delivers: Vec<TaskRef>,
    /// Every task that delivers this one, by qualified id: the reverse of [`Self::delivers`].
    ///
    /// **Owned by the store, not by a source record and not by a copy.** The engine keeps it
    /// in step whenever it writes a task's `delivers`, through
    /// [`TaskSource::set_delivered_by`](crate::TaskSource::set_delivered_by); a source holds
    /// and reports it, and a copy keeps the destination's own rather than taking the
    /// source's. Empty by default and left out of the wire when empty, as `delivers` is.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    #[schemars(!skip_serializing_if)]
    pub delivered_by: Vec<TaskRef>,
}

/// 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
    }
}

/// One task named by another task's [`Task::delivers`] or [`Task::delivered_by`].
///
/// A string with one of two spellings, decided the way a [`DependencyEndpoint`] decides it:
/// one holding a colon is `<source>:<native>` and names a task of any source, and one
/// without is a bare native id naming a task of the source that holds the list. So a
/// native id holding a colon cannot be named bare, exactly as it cannot in
/// `onetaskgraph.depends_on`.
#[derive(
    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(try_from = "String", into = "String")]
pub struct TaskRef(String);

impl TaskRef {
    /// The reserved metadata key a source records [`Task::delivers`] under when its backend
    /// has no notion of its own.
    ///
    /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is.
    pub const DELIVERS_KEY: &'static str = "onetaskgraph.delivers";

    /// The reserved metadata key a source records [`Task::delivered_by`] under when its
    /// backend has no notion of its own.
    pub const DELIVERED_BY_KEY: &'static str = "onetaskgraph.delivered_by";

    /// One entry, once it is established it is a task id.
    ///
    /// # Errors
    ///
    /// Returns a message saying why when the id is empty, or when it is qualified with a
    /// source name that breaks the pattern or with no native id after the colon.
    pub fn new(id: impl Into<String>) -> Result<Self, String> {
        let id = id.into();
        if id.is_empty() {
            return Err("an empty string names no task".to_owned());
        }
        if let Some((source, native)) = id.split_once(':') {
            SourceName::new(source).map_err(|error| error.to_string())?;
            if native.is_empty() {
                return Err(format!("{id:?} names a source and no task in it"));
            }
        }
        Ok(Self(id))
    }

    /// The qualified entry naming `native` in `source`.
    #[must_use]
    pub fn qualified(source: &SourceName, native: &NativeId) -> Self {
        Self(format!("{source}:{native}"))
    }

    /// The entry as it is spelled.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Whether the entry names its source in writing.
    #[must_use]
    pub fn is_qualified(&self) -> bool {
        self.0.contains(':')
    }

    /// The source and the native id this entry names, reading a bare entry as naming a task
    /// of `near_source`.
    #[must_use]
    pub fn parts<'a>(&'a self, near_source: &'a str) -> (&'a str, &'a str) {
        self.0
            .split_once(':')
            .unwrap_or((near_source, self.0.as_str()))
    }

    /// This entry qualified, reading a bare one as naming a task of `near_source`.
    #[must_use]
    pub fn in_source(&self, near_source: &SourceName) -> Self {
        if self.is_qualified() {
            return self.clone();
        }
        Self(format!("{near_source}:{}", self.0))
    }

    /// The entries of one task's list, once it is established that none names the task
    /// itself and none repeats.
    ///
    /// `field` is what the list is called where it is stored, for the message. `near` is the
    /// task holding the list and `near_source` the configured name of the source holding it,
    /// which is what tells `T-1` and `work:T-1` apart as the same task. A source that does
    /// not know its own name passes `None`, and then only a bare entry can be recognised as
    /// naming this task or one of the other entries.
    ///
    /// # Errors
    ///
    /// Returns a message naming the task and the entry.
    pub fn listed(
        field: &str,
        near: &NativeId,
        near_source: Option<&SourceName>,
        entries: Vec<Self>,
    ) -> Result<Vec<Self>, String> {
        let normal = |entry: &Self| match near_source {
            Some(source) => entry.in_source(source).0,
            None => entry.0.clone(),
        };
        let this = match near_source {
            Some(source) => Self::qualified(source, near).0,
            None => near.0.clone(),
        };
        let mut seen: Vec<(String, &Self)> = Vec::with_capacity(entries.len());
        for entry in &entries {
            let named = normal(entry);
            if named == this {
                return Err(format!(
                    "{field} on task {near} names {entry}, which is that task itself; a task \
                     cannot be listed in its own {field}"
                ));
            }
            if let Some((_, first)) = seen.iter().find(|(held, _)| *held == named) {
                return Err(format!(
                    "{field} on task {near} names {entry} more than once (as {first} and \
                     {entry}); name each task once"
                ));
            }
            seen.push((named, entry));
        }
        Ok(entries)
    }

    /// The entries one task's list holds, read out of the JSON a source stores it as.
    ///
    /// `value` is `None` when the source holds no list at all, which is the empty one.
    ///
    /// # Errors
    ///
    /// Returns a message naming the task and the entry when the value is not a list, when an
    /// entry is not a task id, or when [`Self::listed`] refuses the list.
    pub fn from_value(
        field: &str,
        near: &NativeId,
        near_source: Option<&SourceName>,
        value: Option<&Value>,
    ) -> Result<Vec<Self>, String> {
        let Some(value) = value else {
            return Ok(Vec::new());
        };
        let Some(held) = value.as_array() else {
            return Err(format!(
                "{field} on task {near} is {value}, which is not a list of task ids"
            ));
        };
        let entries = held
            .iter()
            .map(|entry| {
                entry
                    .as_str()
                    .ok_or_else(|| "it is not a string".to_owned())
                    .and_then(Self::new)
                    .map_err(|why| {
                        format!(
                            "{field} on task {near} holds {entry}, which is not a task id: {why}"
                        )
                    })
            })
            .collect::<Result<Vec<_>, _>>()?;
        Self::listed(field, near, near_source, entries)
    }
}

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

    fn try_from(id: String) -> Result<Self, Self::Error> {
        Self::new(id)
    }
}

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

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

/// 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 accepted as ready to work.
    Backlog,
    /// Accepted and ready to be picked up, and nothing has claimed it.
    Todo,
    /// Claimed by work that will do it, and not yet started.
    Queued,
    /// 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,
}