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 /// Who may read this task, as it was declared (see [`Classification`]).
105 ///
106 /// Defaulted and left out of the wire while `public`, so a task written before this
107 /// field existed reads as public and a reader written before it reads what it always
108 /// read. It is the *declared* classification: the engine tightens it by the task's
109 /// repositories and its project before it decides where the task may be written, and
110 /// nothing written here can loosen either.
111 #[serde(default, skip_serializing_if = "Classification::is_public")]
112 #[schemars(!skip_serializing_if)]
113 pub classification: Classification,
114}
115
116/// A grouping of tasks, shaped like a [`Task`] without a parent of its own.
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
118pub struct Project {
119 /// The source's own opaque identifier.
120 pub id: NativeId,
121 /// The one-line summary a user recognises the project by.
122 pub title: String,
123 /// The long-form body, when the source has one.
124 pub content: Option<String>,
125 /// The source's status, normalised and preserved.
126 pub status: Status,
127 /// Inline rather than by id, for the same reason as on [`Task`].
128 pub labels: Vec<Label>,
129 /// Where a human can open this project.
130 // 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.
131 // 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".
132 pub url: Option<String>,
133 /// Where this project is, on exactly the terms of [`Task::location`].
134 #[serde(default)]
135 pub location: Option<Location>,
136 /// When the source says the project was created.
137 pub created_at: Option<DateTime<Utc>>,
138 /// When the source says the project last changed.
139 pub updated_at: Option<DateTime<Utc>>,
140 /// Caller-defined attributes, preserving their JSON types, on the same terms as
141 /// [`Task::metadata`].
142 #[serde(default)]
143 pub metadata: BTreeMap<String, Value>,
144 /// Normalized repository origins this project concerns, in source order and without
145 /// repeats.
146 #[serde(default, deserialize_with = "unique_repositories")]
147 pub repositories: Vec<Repository>,
148 /// Who may read this project, as it was declared, on the terms of
149 /// [`Task::classification`]. A project holding a private task or document is private
150 /// however this reads, and the engine records it so when it writes the project.
151 #[serde(default, skip_serializing_if = "Classification::is_public")]
152 #[schemars(!skip_serializing_if)]
153 pub classification: Classification,
154}
155
156/// One piece of information that lives in a project and is not work.
157///
158/// A document carries **no status** and **no dependencies**, and both omissions are the
159/// contract rather than an oversight: a document is not work, so it has no place in a
160/// status filter and no place in a dependency graph. [`ItemKind`] therefore gains no
161/// document variant — that enum names what a dependency endpoint points at, and nothing
162/// may point at a document.
163///
164/// A source says whether it has documents at all through
165/// [`Capabilities::documents`](crate::Capabilities::documents), and one that says it has
166/// none is never asked for one.
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
168pub struct Document {
169 /// The source's own opaque identifier.
170 pub id: NativeId,
171 /// The one-line summary a person recognises it by.
172 pub title: String,
173 /// The long-form body, when the source has one.
174 pub content: Option<String>,
175 /// The project it lives in; `None` is an orphan document, exactly as it is on a
176 /// [`Task`].
177 pub project: Option<NativeId>,
178 /// Inline, on the same terms as a [`Task`]'s.
179 pub labels: Vec<Label>,
180 /// Where a person can open it, on the same terms as a [`Task`]'s.
181 // 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.
182 // 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".
183 pub url: Option<String>,
184 /// Where it is, when the source says (see [`Location`]).
185 #[serde(default)]
186 pub location: Option<Location>,
187 /// When the source says it was created.
188 pub created_at: Option<DateTime<Utc>>,
189 /// When the source says it last changed.
190 pub updated_at: Option<DateTime<Utc>>,
191 /// Caller-defined attributes, preserving their JSON types, with the same reserved
192 /// prefixes [`Task::metadata`] carries.
193 #[serde(default)]
194 pub metadata: BTreeMap<String, Value>,
195 /// Normalized repository origins this document concerns, in source order and without
196 /// repeats, as a [`Task`]'s.
197 #[serde(default, deserialize_with = "unique_repositories")]
198 pub repositories: Vec<Repository>,
199 /// Who may read this document, as it was declared, on the terms of
200 /// [`Task::classification`].
201 #[serde(default, skip_serializing_if = "Classification::is_public")]
202 #[schemars(!skip_serializing_if)]
203 pub classification: Classification,
204}
205
206/// Who may read an item: anybody, or only those who may read where it is kept.
207///
208/// Two values, ordered so the stricter is the greater: [`strictest`](Self::strictest) of
209/// any two is the one an item they both describe carries. Nothing combines them any other
210/// way, which is what makes an explicit `public` unable to loosen a private repository or a
211/// private project — a classification only ever tightens.
212///
213/// It is recorded on the item and travels with it. A source whose backend has no notion of
214/// its own records it under [`Self::METADATA_KEY`], exactly as it records
215/// [`Repository::METADATA_KEY`], and only while it is `private`: an item without it is
216/// public, so every item written before this existed reads as it did.
217#[derive(
218 Debug,
219 Clone,
220 Copy,
221 Default,
222 PartialEq,
223 Eq,
224 PartialOrd,
225 Ord,
226 Hash,
227 Serialize,
228 Deserialize,
229 JsonSchema,
230)]
231#[serde(rename_all = "kebab-case")]
232pub enum Classification {
233 /// Anybody who may read the destination may read it.
234 #[default]
235 Public,
236 /// It may be written only to a destination verified private, and nothing of it may
237 /// reach a public one.
238 Private,
239}
240
241impl Classification {
242 /// The reserved metadata key a source records a private classification under when its
243 /// backend has no notion of its own.
244 ///
245 /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is.
246 pub const METADATA_KEY: &'static str = "onetaskgraph.classification";
247
248 /// Whether this is [`Public`](Self::Public) — the value left out of the wire.
249 #[must_use]
250 // serde's `skip_serializing_if` calls its predicate with a reference to the field, so this
251 // takes `&self` although the type is `Copy`.
252 #[allow(clippy::trivially_copy_pass_by_ref)]
253 pub fn is_public(&self) -> bool {
254 *self == Self::Public
255 }
256
257 /// The stricter of the two.
258 #[must_use]
259 pub fn strictest(self, other: Self) -> Self {
260 self.max(other)
261 }
262
263 /// The value as the wire spells it: `public` or `private`.
264 #[must_use]
265 pub const fn as_str(self) -> &'static str {
266 match self {
267 Self::Public => "public",
268 Self::Private => "private",
269 }
270 }
271
272 /// The classification a source records under [`Self::METADATA_KEY`], or `public` when
273 /// it records none.
274 ///
275 /// # Errors
276 ///
277 /// Returns a message when the key holds anything but one of the two spellings.
278 pub fn from_metadata(metadata: &BTreeMap<String, Value>) -> Result<Self, String> {
279 match metadata.get(Self::METADATA_KEY) {
280 None => Ok(Self::Public),
281 Some(value) => serde_json::from_value(value.clone()).map_err(|_| {
282 format!(
283 "{} is {value}; it accepts only \"public\" or \"private\"",
284 Self::METADATA_KEY
285 )
286 }),
287 }
288 }
289
290 /// Record this classification in `metadata` under [`Self::METADATA_KEY`]: present while
291 /// private, absent while public.
292 pub fn record(self, metadata: &mut BTreeMap<String, Value>) {
293 if self.is_public() {
294 metadata.remove(Self::METADATA_KEY);
295 } else {
296 metadata.insert(Self::METADATA_KEY.to_owned(), Value::from(self.as_str()));
297 }
298 }
299}
300
301impl std::fmt::Display for Classification {
302 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303 formatter.write_str(self.as_str())
304 }
305}
306
307impl std::str::FromStr for Classification {
308 type Err = String;
309
310 fn from_str(value: &str) -> Result<Self, Self::Err> {
311 match value {
312 "public" => Ok(Self::Public),
313 "private" => Ok(Self::Private),
314 _ => Err(format!(
315 "{value:?} is not a classification; a classification is public or private"
316 )),
317 }
318 }
319}
320
321/// Where an entity is, in the one form a consumer can act on without knowing the backend.
322///
323/// Externally tagged with exactly two variants, so the JSON is `{"url": "https://…"}` or
324/// `{"path": "/home/…"}` and a consumer tells them apart by which key is present. A reader
325/// handed one of these knows what to *do* with it — open a link, or print a path and read
326/// the file out — which is what a bare string could not have said.
327///
328/// It carries no third case on purpose. `None` on the field is the third case, and it
329/// means the source did not say where the entity is, which is not the same as saying it is
330/// nowhere.
331///
332/// This does **not** redefine, replace or derive from the `url` field of [`Task`],
333/// [`Project`] or [`Document`]: a source that reports a web URL there goes on reporting
334/// it, and every existing consumer sees exactly what it saw.
335#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
336#[serde(rename_all = "kebab-case")]
337pub enum Location {
338 /// The entity lives at an external website, and this is a link a reader can open.
339 // 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.
340 // 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`.
341 Url(String),
342 /// The entity is a file on the machine the source runs on, and this is that file's
343 /// absolute path, so a reader can print the path or read the contents out.
344 // 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.
345 // 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.
346 Path(String),
347}
348
349/// A repository identified by its normalized origin, without a URL scheme or `.git` suffix.
350#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
351#[serde(try_from = "String", into = "String")]
352pub struct Repository(String);
353
354impl Repository {
355 /// The reserved metadata key a source reads these origins from when its backend has
356 /// no notion of its own.
357 ///
358 /// The key is spelled once, here, because every plugin has to agree on it: a source
359 /// that invented its own spelling would hold work nothing else could read.
360 pub const METADATA_KEY: &'static str = "onetaskgraph.repositories";
361
362 /// The normalized `host/owner/name` origin.
363 #[must_use]
364 pub fn as_str(&self) -> &str {
365 &self.0
366 }
367
368 /// The origins a source records under [`Self::METADATA_KEY`], or none.
369 ///
370 /// # Errors
371 ///
372 /// Returns a message when the key holds something other than a duplicate-free list
373 /// of normalized origins.
374 pub fn from_metadata(metadata: &BTreeMap<String, Value>) -> Result<Vec<Self>, String> {
375 let Some(value) = metadata.get(Self::METADATA_KEY) else {
376 return Ok(Vec::new());
377 };
378 let origins: Vec<Self> = serde_json::from_value(value.clone()).map_err(|error| {
379 format!(
380 "{} is not a list of repository origins: {error}",
381 Self::METADATA_KEY
382 )
383 })?;
384 Self::unique(origins)
385 }
386
387 /// The same origins, in the order given, once it is established none repeats.
388 ///
389 /// # Errors
390 ///
391 /// Returns a message naming the first origin that appears twice.
392 pub fn unique(origins: Vec<Self>) -> Result<Vec<Self>, String> {
393 let mut seen = std::collections::BTreeSet::new();
394 for origin in &origins {
395 if !seen.insert(origin.as_str()) {
396 return Err(format!(
397 "{:?} is listed twice; a repository list names each origin once",
398 origin.as_str()
399 ));
400 }
401 }
402 Ok(origins)
403 }
404}
405
406fn unique_repositories<'de, D>(deserializer: D) -> Result<Vec<Repository>, D::Error>
407where
408 D: serde::Deserializer<'de>,
409{
410 Repository::unique(Vec::<Repository>::deserialize(deserializer)?)
411 .map_err(serde::de::Error::custom)
412}
413
414impl TryFrom<String> for Repository {
415 type Error = String;
416
417 fn try_from(origin: String) -> Result<Self, Self::Error> {
418 let valid = !origin.is_empty()
419 && !origin.contains("://")
420 && !origin.ends_with(".git")
421 && !origin.chars().any(char::is_whitespace)
422 && origin.split('/').count() >= 3
423 && origin
424 .split('/')
425 .all(|part| !part.is_empty() && part != "." && part != "..");
426 valid.then_some(Self(origin.clone())).ok_or_else(|| format!(
427 "{origin:?} is not a normalized repository origin; use host/owner/name without a scheme or .git suffix"
428 ))
429 }
430}
431
432impl From<Repository> for String {
433 fn from(repository: Repository) -> Self {
434 repository.0
435 }
436}
437
438/// One task named by another task's [`Task::delivers`] or [`Task::delivered_by`].
439///
440/// A string with one of two spellings, decided the way a [`DependencyEndpoint`] decides it:
441/// one holding a colon is `<source>:<native>` and names a task of any source, and one
442/// without is a bare native id naming a task of the source that holds the list. So a
443/// native id holding a colon cannot be named bare, exactly as it cannot in
444/// `onetaskgraph.depends_on`.
445#[derive(
446 Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
447)]
448#[serde(try_from = "String", into = "String")]
449pub struct TaskRef(String);
450
451impl TaskRef {
452 /// The reserved metadata key a source records [`Task::delivers`] under when its backend
453 /// has no notion of its own.
454 ///
455 /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is.
456 pub const DELIVERS_KEY: &'static str = "onetaskgraph.delivers";
457
458 /// The reserved metadata key a source records [`Task::delivered_by`] under when its
459 /// backend has no notion of its own.
460 pub const DELIVERED_BY_KEY: &'static str = "onetaskgraph.delivered_by";
461
462 /// One entry, once it is established it is a task id.
463 ///
464 /// # Errors
465 ///
466 /// Returns a message saying why when the id is empty, or when it is qualified with a
467 /// source name that breaks the pattern or with no native id after the colon.
468 pub fn new(id: impl Into<String>) -> Result<Self, String> {
469 let id = id.into();
470 if id.is_empty() {
471 return Err("an empty string names no task".to_owned());
472 }
473 if let Some((source, native)) = id.split_once(':') {
474 SourceName::new(source).map_err(|error| error.to_string())?;
475 if native.is_empty() {
476 return Err(format!("{id:?} names a source and no task in it"));
477 }
478 }
479 Ok(Self(id))
480 }
481
482 /// The qualified entry naming `native` in `source`.
483 #[must_use]
484 pub fn qualified(source: &SourceName, native: &NativeId) -> Self {
485 Self(format!("{source}:{native}"))
486 }
487
488 /// The entry as it is spelled.
489 #[must_use]
490 pub fn as_str(&self) -> &str {
491 &self.0
492 }
493
494 /// Whether the entry names its source in writing.
495 #[must_use]
496 pub fn is_qualified(&self) -> bool {
497 self.0.contains(':')
498 }
499
500 /// The source and the native id this entry names, reading a bare entry as naming a task
501 /// of `near_source`.
502 #[must_use]
503 pub fn parts<'a>(&'a self, near_source: &'a str) -> (&'a str, &'a str) {
504 self.0
505 .split_once(':')
506 .unwrap_or((near_source, self.0.as_str()))
507 }
508
509 /// This entry qualified, reading a bare one as naming a task of `near_source`.
510 #[must_use]
511 pub fn in_source(&self, near_source: &SourceName) -> Self {
512 if self.is_qualified() {
513 return self.clone();
514 }
515 Self(format!("{near_source}:{}", self.0))
516 }
517
518 /// The entries of one task's list, once it is established that none names the task
519 /// itself and none repeats.
520 ///
521 /// `field` is what the list is called where it is stored, for the message. `near` is the
522 /// task holding the list and `near_source` the configured name of the source holding it,
523 /// which is what tells `T-1` and `work:T-1` apart as the same task. A source that does
524 /// not know its own name passes `None`, and then only a bare entry can be recognised as
525 /// naming this task or one of the other entries.
526 ///
527 /// # Errors
528 ///
529 /// Returns a message naming the task and the entry.
530 pub fn listed(
531 field: &str,
532 near: &NativeId,
533 near_source: Option<&SourceName>,
534 entries: Vec<Self>,
535 ) -> Result<Vec<Self>, String> {
536 let normal = |entry: &Self| match near_source {
537 Some(source) => entry.in_source(source).0,
538 None => entry.0.clone(),
539 };
540 let this = match near_source {
541 Some(source) => Self::qualified(source, near).0,
542 None => near.0.clone(),
543 };
544 let mut seen: Vec<(String, &Self)> = Vec::with_capacity(entries.len());
545 for entry in &entries {
546 let named = normal(entry);
547 if named == this {
548 return Err(format!(
549 "{field} on task {near} names {entry}, which is that task itself; a task \
550 cannot be listed in its own {field}"
551 ));
552 }
553 if let Some((_, first)) = seen.iter().find(|(held, _)| *held == named) {
554 return Err(format!(
555 "{field} on task {near} names {entry} more than once (as {first} and \
556 {entry}); name each task once"
557 ));
558 }
559 seen.push((named, entry));
560 }
561 Ok(entries)
562 }
563
564 /// The entries one task's list holds, read out of the JSON a source stores it as.
565 ///
566 /// `value` is `None` when the source holds no list at all, which is the empty one.
567 ///
568 /// # Errors
569 ///
570 /// Returns a message naming the task and the entry when the value is not a list, when an
571 /// entry is not a task id, or when [`Self::listed`] refuses the list.
572 pub fn from_value(
573 field: &str,
574 near: &NativeId,
575 near_source: Option<&SourceName>,
576 value: Option<&Value>,
577 ) -> Result<Vec<Self>, String> {
578 let Some(value) = value else {
579 return Ok(Vec::new());
580 };
581 let Some(held) = value.as_array() else {
582 return Err(format!(
583 "{field} on task {near} is {value}, which is not a list of task ids"
584 ));
585 };
586 let entries = held
587 .iter()
588 .map(|entry| {
589 entry
590 .as_str()
591 .ok_or_else(|| "it is not a string".to_owned())
592 .and_then(Self::new)
593 .map_err(|why| {
594 format!(
595 "{field} on task {near} holds {entry}, which is not a task id: {why}"
596 )
597 })
598 })
599 .collect::<Result<Vec<_>, _>>()?;
600 Self::listed(field, near, near_source, entries)
601 }
602}
603
604impl TryFrom<String> for TaskRef {
605 type Error = String;
606
607 fn try_from(id: String) -> Result<Self, Self::Error> {
608 Self::new(id)
609 }
610}
611
612impl From<TaskRef> for String {
613 fn from(entry: TaskRef) -> Self {
614 entry.0
615 }
616}
617
618impl std::fmt::Display for TaskRef {
619 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
620 self.0.fmt(formatter)
621 }
622}
623
624/// A tag a source attaches to work.
625#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
626pub struct Label {
627 /// The source's own opaque identifier.
628 pub id: NativeId,
629 /// What a user filtering across sources actually types.
630 pub name: String,
631 /// The source's own colour for the label, when it has one.
632 pub color: Option<String>,
633}
634
635/// A source's status, kept in both normalised and original form.
636///
637/// `category` is what every filter compares against; `name` is the source's own
638/// wording, preserved so display never flattens "In Review" into "In Progress".
639#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
640pub struct Status {
641 /// The normalised value filters compare against.
642 pub category: StatusCategory,
643 /// The source's own label for this status.
644 pub name: String,
645}
646
647/// The normalised status vocabulary shared across every source.
648#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
649#[serde(rename_all = "kebab-case")]
650pub enum StatusCategory {
651 /// Written down but not yet committed to as work.
652 Draft,
653 /// Known about, not yet accepted as ready to work.
654 Backlog,
655 /// Accepted and ready to be picked up, and nothing has claimed it.
656 Todo,
657 /// Claimed by work that will do it, and not yet started.
658 Queued,
659 /// Being worked on.
660 InProgress,
661 /// Finished.
662 Done,
663 /// Abandoned.
664 Cancelled,
665 /// The source reported a status this vocabulary cannot place.
666 Unknown,
667}
668
669/// How much a task matters, in the one vocabulary every source is normalised into.
670///
671/// Five values, most pressing first after [`None`](Self::None): Linear's own priority has
672/// exactly these, and a source whose backend has none of its own maps its representation
673/// onto them. `none` is a value rather than an absent field, because "no priority is set" is
674/// something a person sets — writing `none` clears a priority — and a reader tells it apart
675/// from nothing by the value alone.
676#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
677#[serde(rename_all = "kebab-case")]
678pub enum Priority {
679 /// No priority is set.
680 #[default]
681 None,
682 /// Drop everything for it.
683 Urgent,
684 /// Next, before the rest.
685 High,
686 /// In its turn.
687 Medium,
688 /// When there is nothing more pressing.
689 Low,
690}
691
692impl Priority {
693 /// Every value, in the order the vocabulary lists them.
694 pub const ALL: [Self; 5] = [
695 Self::None,
696 Self::Urgent,
697 Self::High,
698 Self::Medium,
699 Self::Low,
700 ];
701
702 /// The value as the wire spells it: `none`, `urgent`, `high`, `medium` or `low`.
703 #[must_use]
704 pub const fn as_str(self) -> &'static str {
705 match self {
706 Self::None => "none",
707 Self::Urgent => "urgent",
708 Self::High => "high",
709 Self::Medium => "medium",
710 Self::Low => "low",
711 }
712 }
713}
714
715impl std::fmt::Display for Priority {
716 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
717 formatter.write_str(self.as_str())
718 }
719}
720
721impl std::str::FromStr for Priority {
722 type Err = String;
723
724 fn from_str(value: &str) -> Result<Self, Self::Err> {
725 Self::ALL
726 .into_iter()
727 .find(|priority| priority.as_str() == value)
728 .ok_or_else(|| {
729 format!(
730 "{value:?} is not a priority; a priority is one of none, urgent, high, \
731 medium or low"
732 )
733 })
734 }
735}
736
737/// A dependency between two work items.
738///
739/// An endpoint may name another source. Keeping that far id on the near item is work data
740/// owned by its plugin, not an engine-side index or mirror; the engine reports it without
741/// resolving or fetching the far item.
742///
743/// A source uses its backend's own relationship wherever that relationship can name the
744/// far end, so the backend knows the graph and its own interface draws it. Where it
745/// cannot — a far end in another source, which no backend relates — the source reads
746/// [`Self::recorded`] from the near item instead. Only the forward direction is ever
747/// recorded; the reverse of a recorded edge is derived, exactly as a
748/// [`ForwardOnly`](crate::DependencySupport::ForwardOnly) source's reverse is.
749#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
750pub struct DependencyEdge {
751 /// The item the edge starts at, and the one that **depends on** the other.
752 ///
753 /// This is the orientation every source reports in, whichever way its own backend
754 /// spells the relationship: a GitHub `blockedBy` connection read for `ENG-1` yields
755 /// `from: ENG-1`, because `ENG-1` is what depends.
756 pub from: DependencyEndpoint,
757 /// The item the edge points at, and the one that must finish first.
758 pub to: DependencyEndpoint,
759 /// What the edge means.
760 pub kind: DependencyKind,
761}
762
763impl DependencyEdge {
764 /// The reserved metadata key a near item records a far end under.
765 ///
766 /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is: a plugin that
767 /// invented its own spelling would record a plan nothing else could read.
768 pub const RECORDED_KEY: &'static str = "onetaskgraph.depends_on";
769
770 /// The forward edges `near` records under [`Self::RECORDED_KEY`], or none.
771 ///
772 /// The key holds a list of endpoints — a bare string is a native id naming a task,
773 /// and `{"id": "<source>:<native>", "kind": "project"}` names any item of any source.
774 /// Each becomes one `blocks` edge from `near` to that endpoint.
775 ///
776 /// `natively_names` is the kind of item the near item's **own backend** can relate it
777 /// to — `Some(ItemKind::Task)` for a GitHub issue, whose `blockedBy` connection holds
778 /// issues; `None` for a GitHub draft, which has no such connection at all. An endpoint
779 /// of that kind naming an item of `near_source` is refused, because it names an item
780 /// the backend itself could hold, and the rule this key exists to serve is the
781 /// backend's own relationship first. Naming one's own source is what an unqualified id
782 /// does implicitly and what `<near_source>:<native>` does in writing, so both are
783 /// refused: which of the two spellings a plan happened to use says nothing about where
784 /// the edge belongs.
785 ///
786 /// An endpoint qualified to a *different* source is never refused. That is the whole
787 /// case this key is for: no backend relates an id in a system it knows nothing about.
788 ///
789 /// # Errors
790 ///
791 /// Returns a message when the key holds anything other than a list of endpoints, or
792 /// holds one the near item's own backend was supposed to name.
793 pub fn recorded(
794 metadata: &BTreeMap<String, Value>,
795 near: &NativeId,
796 near_kind: ItemKind,
797 near_source: &SourceName,
798 natively_names: Option<ItemKind>,
799 ) -> Result<Vec<Self>, String> {
800 let Some(value) = metadata.get(Self::RECORDED_KEY) else {
801 return Ok(Vec::new());
802 };
803 let far: Vec<DependencyEndpoint> =
804 serde_json::from_value(value.clone()).map_err(|error| {
805 format!(
806 "{} is not a list of dependency endpoints: {error}",
807 Self::RECORDED_KEY
808 )
809 })?;
810 far.into_iter()
811 .map(|to| {
812 let names_this_source = to
813 .source()
814 .is_none_or(|source| source == near_source.as_str());
815 if names_this_source && natively_names == Some(to.kind) {
816 return Err(format!(
817 "{key} on {near} records {to}, which this source can relate \
818 natively; record it as this backend's own dependency and keep \
819 {key} for a far end no relationship here can name",
820 key = Self::RECORDED_KEY
821 ));
822 }
823 Ok(Self {
824 from: DependencyEndpoint::from_native(near.clone(), near_kind),
825 to,
826 kind: DependencyKind::Blocks,
827 })
828 })
829 .collect()
830 }
831}
832
833/// One endpoint of a dependency edge.
834#[derive(Debug, Clone, PartialEq, Eq, Hash)]
835pub struct DependencyEndpoint {
836 /// A qualified `<source>:<native>` id, or a legacy native id which the engine
837 /// qualifies to the source reporting the edge.
838 id: EndpointIdentity,
839 /// Whether the endpoint names a task or a project.
840 pub kind: ItemKind,
841}
842
843#[derive(Debug, Clone, PartialEq, Eq, Hash)]
844enum EndpointIdentity {
845 Native(String),
846 Qualified(String),
847}
848
849impl EndpointIdentity {
850 fn as_str(&self) -> &str {
851 match self {
852 Self::Native(id) | Self::Qualified(id) => id,
853 }
854 }
855
856 fn into_string(self) -> String {
857 match self {
858 Self::Native(id) | Self::Qualified(id) => id,
859 }
860 }
861}
862
863impl Serialize for DependencyEndpoint {
864 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
865 where
866 S: serde::Serializer,
867 {
868 #[derive(Serialize)]
869 struct Wire<'a> {
870 id: &'a str,
871 kind: ItemKind,
872 }
873 Wire {
874 id: self.id(),
875 kind: self.kind,
876 }
877 .serialize(serializer)
878 }
879}
880
881impl JsonSchema for DependencyEndpoint {
882 fn schema_name() -> std::borrow::Cow<'static, str> {
883 "DependencyEndpoint".into()
884 }
885
886 fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
887 json_schema!({
888 "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.",
889 "oneOf": [
890 {"type": "string", "minLength": 1},
891 {
892 "type": "object",
893 "additionalProperties": false,
894 "required": ["id", "kind"],
895 "properties": {
896 "id": {"type": "string", "minLength": 1},
897 "kind": {"type": "string", "enum": ["task", "project"]}
898 }
899 }
900 ]
901 })
902 }
903}
904
905impl DependencyEndpoint {
906 /// Builds an endpoint from a serialized id, validating a qualified id when present.
907 ///
908 /// # Errors
909 ///
910 /// Returns an error for an empty id or a malformed `<source>:<native>` id.
911 pub fn new(id: String, kind: ItemKind) -> Result<Self, String> {
912 let is_qualified = id.contains(':');
913 let id = valid_endpoint_id(id)?;
914 Ok(Self {
915 id: if is_qualified {
916 EndpointIdentity::Qualified(id)
917 } else {
918 EndpointIdentity::Native(id)
919 },
920 kind,
921 })
922 }
923
924 /// Builds an endpoint from a source-native id, whose contents are deliberately opaque.
925 #[must_use]
926 pub fn from_native(id: NativeId, kind: ItemKind) -> Self {
927 Self {
928 id: EndpointIdentity::Native(id.0),
929 kind,
930 }
931 }
932
933 /// The serialized native or qualified id.
934 #[must_use]
935 pub fn id(&self) -> &str {
936 self.id.as_str()
937 }
938
939 /// Consumes the endpoint and returns its serialized id.
940 #[must_use]
941 pub fn into_id(self) -> String {
942 self.id.into_string()
943 }
944
945 /// Whether the id was explicitly supplied as a qualified endpoint.
946 #[must_use]
947 pub fn is_qualified(&self) -> bool {
948 matches!(self.id, EndpointIdentity::Qualified(_))
949 }
950
951 /// The source segment of a qualified id, or `None` for a native one.
952 ///
953 /// A native id belongs to whichever source reports it, so `None` reads as "this
954 /// source" rather than "no source".
955 #[must_use]
956 pub fn source(&self) -> Option<&str> {
957 match &self.id {
958 EndpointIdentity::Qualified(id) => id.split_once(':').map(|(source, _)| source),
959 EndpointIdentity::Native(_) => None,
960 }
961 }
962}
963
964impl<'de> Deserialize<'de> for DependencyEndpoint {
965 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
966 where
967 D: serde::Deserializer<'de>,
968 {
969 #[derive(Deserialize)]
970 #[serde(untagged)]
971 enum Wire {
972 Legacy(String),
973 Endpoint { id: String, kind: ItemKind },
974 }
975 match Wire::deserialize(deserializer)? {
976 Wire::Legacy(id) => {
977 if id.is_empty() {
978 return Err(serde::de::Error::custom(
979 "a dependency endpoint id cannot be empty",
980 ));
981 }
982 Ok(Self::from_native(NativeId(id), ItemKind::Task))
983 }
984 Wire::Endpoint { id, kind } => Self::new(id, kind).map_err(serde::de::Error::custom),
985 }
986 }
987}
988
989fn valid_endpoint_id(id: String) -> Result<String, String> {
990 if id.is_empty() {
991 return Err("a dependency endpoint id cannot be empty".into());
992 }
993 if let Some((source, native)) = id.split_once(':') {
994 crate::SourceName::new(source).map_err(|error| error.to_string())?;
995 if native.is_empty() {
996 return Err("a qualified dependency endpoint must name a native id".into());
997 }
998 }
999 Ok(id)
1000}
1001
1002impl std::fmt::Display for DependencyEndpoint {
1003 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1004 self.id().fmt(formatter)
1005 }
1006}
1007
1008impl PartialEq<NativeId> for DependencyEndpoint {
1009 fn eq(&self, other: &NativeId) -> bool {
1010 self.id() == other.0
1011 }
1012}
1013
1014/// The kind of work item named by a dependency endpoint.
1015#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
1016#[serde(rename_all = "kebab-case")]
1017pub enum ItemKind {
1018 /// A task.
1019 Task,
1020 /// A project.
1021 Project,
1022}
1023
1024impl ItemKind {
1025 /// The reserved metadata key an item is marked with when its backend cannot say
1026 /// which kind it is.
1027 ///
1028 /// Spelled once, here, for the reason [`Repository::METADATA_KEY`] is: a key under
1029 /// this product's prefix belongs to the product, and a plugin inventing its own
1030 /// spelling would collide with the next one to want it.
1031 ///
1032 /// Unlike the other two reserved keys, this one obliges **no** source. A backend that
1033 /// knows its own kinds — folders, native projects — never reads or writes it, and
1034 /// passes it through as ordinary caller metadata with its JSON type intact, exactly
1035 /// as it passes through every other key it does not own. `github-projects` is the one
1036 /// source that needs it, because a GitHub Projects board holds only issues and an
1037 /// empty project is indistinguishable from a task without it.
1038 pub const METADATA_KEY: &'static str = "onetaskgraph.item_kind";
1039
1040 /// The value this kind is marked with under [`Self::METADATA_KEY`].
1041 #[must_use]
1042 pub const fn marker(self) -> &'static str {
1043 match self {
1044 Self::Task => "task",
1045 Self::Project => "project",
1046 }
1047 }
1048
1049 /// The kind `metadata` marks, or `None` when it carries no marker at all.
1050 ///
1051 /// # Errors
1052 ///
1053 /// Returns a message when [`Self::METADATA_KEY`] holds anything other than the two
1054 /// markers [`Self::marker`] spells.
1055 pub fn from_metadata(metadata: &BTreeMap<String, Value>) -> Result<Option<Self>, String> {
1056 let Some(value) = metadata.get(Self::METADATA_KEY) else {
1057 return Ok(None);
1058 };
1059 match value.as_str() {
1060 Some(marker) if marker == Self::Task.marker() => Ok(Some(Self::Task)),
1061 Some(marker) if marker == Self::Project.marker() => Ok(Some(Self::Project)),
1062 _ => Err(format!(
1063 "{} is {value}; it accepts only {:?} or {:?}",
1064 Self::METADATA_KEY,
1065 Self::Project.marker(),
1066 Self::Task.marker()
1067 )),
1068 }
1069 }
1070}
1071
1072/// What a [`DependencyEdge`] means.
1073///
1074/// Both variants are read in the one direction [`DependencyEdge::from`] fixes: `from`
1075/// depends on `to`. This enum said the opposite of that until the orientation was settled,
1076/// which is why it is spelled out twice rather than once.
1077#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
1078#[serde(rename_all = "kebab-case")]
1079pub enum DependencyKind {
1080 /// `from` depends on `to`, and `to` must finish before `from` can.
1081 // 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.
1082 Blocks,
1083 /// `from` and `to` are linked without an ordering.
1084 Related,
1085}
1086
1087/// Which way a dependency query walks the graph.
1088#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
1089#[serde(rename_all = "kebab-case")]
1090pub enum Direction {
1091 /// What this item depends on — the forward edges every source can report.
1092 DependsOn,
1093 /// What depends on this item — emulated by the engine for a
1094 /// [`ForwardOnly`](crate::DependencySupport::ForwardOnly) source.
1095 DependedOnBy,
1096}