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