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