Skip to main content

onetaskgraph_plugin_api/
write.rs

1//! The write seam a destination is reached through, and the two refusals a source with
2//! nothing on one side of the contract answers with.
3//!
4//! [`TaskSource`](crate::TaskSource) is a read interface, and it stays one for every
5//! source that has nothing to write into: both write methods are defaulted, so a source
6//! that cannot be written needs no edit and keeps refusing by saying so. What a source
7//! opts into is [`WriteSupport::Supported`], and what that opt-in owes is real
8//! implementations of [`TaskSource::write_task`](crate::TaskSource::write_task) and
9//! [`TaskSource::write_project`](crate::TaskSource::write_project).
10
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14use crate::{DependencyEdge, NativeId, SourceError};
15
16/// Whether a source can be written through at all.
17///
18/// Deliberately its own enum rather than a reuse of [`Support`](crate::Support): that one
19/// says whether a source applies a *predicate* itself, and the engine compensates for an
20/// `Unsupported` there by narrowing a wider result. There is no compensating for a
21/// destination that cannot be written — the copy is refused, naming the source and the
22/// plugin behind it — so conflating the two would invite an engine-side workaround for a
23/// case that has none.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
25#[serde(rename_all = "kebab-case")]
26pub enum WriteSupport {
27    /// The source creates and updates items through its own write interface.
28    Supported,
29    /// The source has no write side; a copy naming it as a destination is refused.
30    Unsupported,
31}
32
33impl WriteSupport {
34    /// Whether this source can be written through.
35    #[must_use]
36    pub fn is_supported(self) -> bool {
37        matches!(self, Self::Supported)
38    }
39}
40
41/// One create-or-update of a work item at a destination.
42///
43/// The two cases are one type because a destination decides between them by exactly one
44/// question — is there an item here to update — and the engine has already answered it.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
46pub struct ItemWrite<T> {
47    /// The destination item to update, or `None` to create one.
48    ///
49    /// A source handed a `target` it does not hold returns [`SourceError::Refused`]
50    /// rather than creating: the engine established that id before asking, so an absent
51    /// one is a race the destination must not paper over.
52    pub target: Option<NativeId>,
53    /// The item as the destination should hold it once the write lands.
54    ///
55    /// Its `id` is the id the item was read under at the **source**. A destination
56    /// updating an item addresses [`target`](Self::target) and ignores it; a destination
57    /// creating one may derive a name from it and is free not to. Its `url`, `location`,
58    /// `created_at` and `updated_at` are the destination's own and are never written —
59    /// where the *source* holds an item says nothing about where the destination does. A
60    /// [`Task::key`](crate::Task::key) that arrives here is read exactly as an absent one:
61    /// a handle is derived by the backend that issues it, so the source's is not the
62    /// destination's to wear and no destination stores one it was handed.
63    pub item: T,
64    /// The forward dependency edges the copy read, with their far ends already resolved.
65    ///
66    /// Each edge's `from` is the item being written as the *source* named it, so a
67    /// destination reads the `to` and the `kind` of each and supplies its own near end.
68    /// A `to` naming another source is qualified and stays that way; a `to` inside the
69    /// copied set arrives as the destination's own native id.
70    #[serde(default)]
71    pub depends_on: Vec<DependencyEdge>,
72}
73
74/// How large the body one write of an item would store is, as its destination counts it, and
75/// the most that destination holds in one.
76///
77/// The body is the long-form field the destination stores an item's content in — an issue's
78/// body, a Linear item's description or content — composed exactly as the write would compose
79/// it: the content, the metadata slot the source keeps beside it, and every asset reference
80/// rewritten to where the destination serves it. A copy asks for it of every item before it
81/// writes any, so an item too large for its destination refuses the copy while nothing of it
82/// has been written.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
84pub struct BodySize {
85    /// The composed body's length, in the unit [`limit`](Self::limit) is stated in — the
86    /// characters a hosted tracker counts, never bytes unless the backend counts bytes.
87    pub size: u64,
88    /// The most one body may hold at this destination, or `None` when it states no limit.
89    pub limit: Option<u64>,
90}
91
92impl BodySize {
93    /// Whether the body is larger than the destination holds: never, where it states no limit.
94    #[must_use]
95    pub fn exceeds(&self) -> bool {
96        self.limit.is_some_and(|limit| self.size > limit)
97    }
98}
99
100/// The refusal a source with no write side answers a write with.
101///
102/// Spelled once so every unwritten source refuses in the same words, and so a plugin's
103/// refusal and the engine's own message about it cannot describe different things.
104#[must_use]
105pub fn unwritable(kind: &str) -> SourceError {
106    SourceError::Refused {
107        message: format!("the {kind} plugin cannot be written"),
108    }
109}
110
111/// The refusal a source answers a narrow write of one field of a task with, when it cannot
112/// write that field on its own.
113///
114/// Spelled once beside [`unwritable`] for that function's reason. `field` is the field's own
115/// name — `status`, `delivered_by` — so the refusal names what could not be written.
116#[must_use]
117pub fn unwritable_field(kind: &str, field: &str) -> SourceError {
118    SourceError::Refused {
119        message: format!("the {kind} plugin cannot write a task's {field} on its own"),
120    }
121}
122
123/// The refusal a source with no documents answers a document read with.
124///
125/// Spelled once beside [`unwritable`], and here rather than beside the reads it answers,
126/// because the two are the same kind of thing: a source saying it does not have that side
127/// of the contract at all. Every document-free source therefore refuses in the same words,
128/// and a plugin's refusal cannot describe something different from the engine's own
129/// message about it.
130///
131/// The two document *writes* reuse [`unwritable`]: a source with no write side refuses a
132/// document write for the reason it refuses every other write, and saying it twice in two
133/// wordings would make one refusal read as two.
134#[must_use]
135pub fn documentless(kind: &str) -> SourceError {
136    SourceError::Refused {
137        message: format!("the {kind} plugin has no documents"),
138    }
139}