onetaskgraph_plugin_api/metadata.rs
1//! The one key a narrow metadata write names, and the refusal a source that cannot make one
2//! answers with.
3//!
4//! A narrow metadata write sets one key of one record's metadata and changes nothing else
5//! about the record — see [`TaskSource::set_task_metadata`](crate::TaskSource::set_task_metadata).
6//! What a caller may name there is narrower than what a record may hold: a record read from a
7//! source carries this product's own `onetaskgraph.` keys, and a caller writing one of them
8//! through this seam would be editing the store's bookkeeping by hand.
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13use crate::SourceError;
14
15/// One caller-owned metadata key: `<namespace>.<name>`, at least two non-empty segments
16/// separated by dots, whose first segment is not [`MetadataKey::RESERVED_NAMESPACE`].
17///
18/// Validated wherever one is built, deserialized included, so a plugin handed one never has
19/// to ask whether it names a key this product owns.
20#[derive(
21 Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
22)]
23#[serde(try_from = "String", into = "String")]
24pub struct MetadataKey(String);
25
26impl MetadataKey {
27 /// The namespace this product owns, and a narrow write therefore never names.
28 ///
29 /// Every key the contract reserves lives under it — `onetaskgraph.origin`,
30 /// `onetaskgraph.repositories`, `onetaskgraph.depends_on`, `onetaskgraph.delivers`,
31 /// `onetaskgraph.delivered_by`, `onetaskgraph.item_kind`, `onetaskgraph.template`
32 /// ([`Self::TEMPLATE_KEY`]), `onetaskgraph.copies` ([`Self::COPIES_KEY`]),
33 /// `onetaskgraph.copy_locations` ([`Self::COPY_LOCATIONS_KEY`]),
34 /// `onetaskgraph.members` ([`Self::MEMBERS_KEY`]), `onetaskgraph.member_of`
35 /// ([`Self::MEMBER_OF_KEY`]) and `onetaskgraph.assets` ([`Self::ASSETS_KEY`]) — and so
36 /// does any key it reserves later, which is why the whole namespace is refused rather than
37 /// a list.
38 pub const RESERVED_NAMESPACE: &'static str = "onetaskgraph";
39
40 /// The reserved key a task or a document rendered from a template records where it came
41 /// from under: an object holding the template's reference, the chain digest it was
42 /// rendered with, and the SHA-256 of its content and of its resolved answers.
43 ///
44 /// A key of this product's own, so no [`MetadataKey`] can name it: only a rendering write
45 /// — [`TaskSource::write_task_rendered`](crate::TaskSource::write_task_rendered) and
46 /// [`TaskSource::set_task_rendering`](crate::TaskSource::set_task_rendering) and their
47 /// document siblings — ever sets it, and a copy carries it like any other entry. The
48 /// engine builds and reads the value; a plugin only puts it where its metadata lives.
49 pub const TEMPLATE_KEY: &'static str = "onetaskgraph.template";
50
51 /// The reserved key a copied item records where it landed under: a JSON object mapping a
52 /// destination source name to the qualified id of that item's counterpart there, at
53 /// most one entry per destination.
54 ///
55 /// A key of this product's own, so [`Self::new`] refuses it like every other key in the
56 /// namespace: only the engine's copy writes it, through the narrow metadata write, and
57 /// [`Self::copies`] is how it names the key there. A plugin only puts it where its
58 /// metadata lives.
59 pub const COPIES_KEY: &'static str = "onetaskgraph.copies";
60
61 /// The reserved key a copied item records, beside [`Self::COPIES_KEY`], where each
62 /// counterpart it links to is: a JSON object mapping a destination source name to
63 /// `{"id": <the qualified id the link names>, "location": <that counterpart's Location>}`.
64 ///
65 /// An entry says something only while its `id` is what [`Self::COPIES_KEY`] names for the
66 /// same destination: a location whose link moved, or that no link stands beside, is
67 /// ignored. So [`Self::COPIES_KEY`] is unchanged by it, and a reader that knows only that
68 /// key reads exactly what it did. Written like that key, through the narrow metadata write,
69 /// and [`Self::copy_locations`] is how it names the key there.
70 pub const COPY_LOCATIONS_KEY: &'static str = "onetaskgraph.copy_locations";
71
72 /// The reserved key a **home** project records its member projects under: a JSON list of
73 /// qualified project ids, each in a different source from the home and from the others,
74 /// so a home has at most one member per source.
75 ///
76 /// Store-owned, like every key in the namespace: only the engine's routed writes keep it —
77 /// a copy, or a `task create` routed away from its project's source — through the home's
78 /// own project write, and nothing a caller types can name it. A plugin
79 /// only puts it where its metadata lives.
80 pub const MEMBERS_KEY: &'static str = "onetaskgraph.members";
81
82 /// The reserved key a **member** project records its home under: the home's qualified id.
83 ///
84 /// Written once, when a routed write — a copy or a `task create` — creates the member, and
85 /// never by a caller.
86 pub const MEMBER_OF_KEY: &'static str = "onetaskgraph.member_of";
87
88 /// The reserved key a hosted source records what it uploaded for a record's image assets
89 /// under: an [`AssetUploads`](crate::AssetUploads) — an object keyed by asset name, each
90 /// value `{"sha256": <lowercase hex>, "url": <string>}`.
91 ///
92 /// Written by the hosted plugin itself, in the same write that lands the record, through
93 /// [`serve_asset_references`](crate::serve_asset_references); handed back to it as
94 /// [`AssetWrite::recorded_assets`](crate::AssetWrite::recorded_assets) on the next copy onto
95 /// that record, so an asset whose SHA-256 is unchanged reuses its URL and is not uploaded
96 /// again. A copy never carries a source record's entry to its destination: the entry says
97 /// where *that* source serves the bytes.
98 pub const ASSETS_KEY: &'static str = "onetaskgraph.assets";
99
100 /// The reserved key a copy records on the **destination** item the source
101 /// [`Task::version`](crate::Task::version) it copied under — and, under `--comments`, a
102 /// token that covered the comments it carried.
103 ///
104 /// Read back by the next copy of the same item, which skips it when the source's current
105 /// version is still this one and the destination is unchanged since, by
106 /// [`Self::COPIED_DESTINATION_VERSION_KEY`]. Absent when the source gave no version.
107 pub const COPIED_VERSION_KEY: &'static str = "onetaskgraph.copied_version";
108
109 /// The reserved key a `--comments` copy records on the destination item as the boolean
110 /// `true`, and a copy without `--comments` removes: whether the copy recorded at
111 /// [`Self::COPIED_VERSION_KEY`] carried the item's comments.
112 ///
113 /// A `--comments` copy never skips an item whose destination lacks it, so a copy that adds
114 /// `--comments` over an unchanged source still carries the comments.
115 pub const COPIED_WITH_COMMENTS_KEY: &'static str = "onetaskgraph.copied_with_comments";
116
117 /// The reserved key a copy records on the destination item a digest of that item as the
118 /// copy left it under, computed by the engine over the item without the three `copied_*`
119 /// keys: `sha256:<hex>`.
120 ///
121 /// A later copy skips the item only when the digest of the destination as it reads it
122 /// then is still this one, so an edit of copied content at the destination — a copied
123 /// comment edited or deleted included — is restored by the next copy rather than skipped.
124 pub const COPIED_DESTINATION_VERSION_KEY: &'static str =
125 "onetaskgraph.copied_destination_version";
126
127 /// The reserved key a copy onto a destination not declared private marks a task it lands
128 /// with, as the integer `1`, once every edge that task depends on is on its own record —
129 /// none withheld and kept on a private source's side instead.
130 ///
131 /// A dependency read of a task carrying it never asks a private source what it records
132 /// of that task. Nothing but a copy writes it.
133 pub const EDGES_ON_RECORD_KEY: &'static str = "onetaskgraph.edges_on_record";
134
135 /// [`Self::COPIES_KEY`], as the one reserved key a narrow metadata write may carry.
136 ///
137 /// Not a way round [`Self::new`] for a caller: nothing a person types reaches this, and
138 /// the one write that sends it is the copy recording where an item landed.
139 #[must_use]
140 // llmlint: ignore[invalid_states_unrepresentable] A key type of its own would change the
141 // signature of the three narrow metadata writes on `TaskSource`, which every plugin
142 // implements and this crate keeps still (AGENTS.md); the reserved key reaches that seam
143 // only through this one named constructor, while `new`, deserialization, the command line
144 // and both SDKs refuse it, and the stdio boundary admits it only with a value that is links.
145 pub fn copies() -> Self {
146 Self(Self::COPIES_KEY.to_owned())
147 }
148
149 /// Whether this is [`Self::COPIES_KEY`].
150 #[must_use]
151 pub fn is_copies(&self) -> bool {
152 self.0 == Self::COPIES_KEY
153 }
154
155 /// [`Self::COPY_LOCATIONS_KEY`], as the other reserved key a narrow metadata write may
156 /// carry: the copy writes it beside [`Self::copies`], and nothing a person types reaches it.
157 #[must_use]
158 // llmlint: ignore[invalid_states_unrepresentable] The same seam as `copies` above, for the
159 // same reason: a key type of its own would change the three narrow metadata writes every
160 // plugin implements, and `new`, deserialization, the command line and both SDKs refuse it.
161 pub fn copy_locations() -> Self {
162 Self(Self::COPY_LOCATIONS_KEY.to_owned())
163 }
164
165 /// Whether this is [`Self::COPY_LOCATIONS_KEY`].
166 #[must_use]
167 pub fn is_copy_locations(&self) -> bool {
168 self.0 == Self::COPY_LOCATIONS_KEY
169 }
170
171 /// One key, once it is established it is a caller's own dotted key.
172 ///
173 /// # Errors
174 ///
175 /// Returns a message saying why, and what to write instead, when the key has no dot, has
176 /// an empty segment, or is in [`Self::RESERVED_NAMESPACE`].
177 pub fn new(key: impl Into<String>) -> Result<Self, String> {
178 let key = key.into();
179 let segments: Vec<&str> = key.split('.').collect();
180 if segments.len() < 2 {
181 return Err(format!(
182 "the metadata key {key:?} has no namespace: a key is `<namespace>.<name>`, two \
183 or more non-empty segments separated by dots; next: name it under a namespace \
184 of your own, such as `myapp.{key}`"
185 ));
186 }
187 if segments.iter().any(|segment| segment.is_empty()) {
188 return Err(format!(
189 "the metadata key {key:?} has an empty segment: a key is `<namespace>.<name>`, \
190 two or more non-empty segments separated by dots; next: remove the extra dot"
191 ));
192 }
193 if segments[0] == Self::RESERVED_NAMESPACE {
194 return Err(format!(
195 "the metadata key {key:?} is in the `{}.` namespace, which this product owns \
196 and keeps in step itself; next: name the key under a namespace of your own",
197 Self::RESERVED_NAMESPACE
198 ));
199 }
200 Ok(Self(key))
201 }
202
203 /// The key as a record's metadata map spells it.
204 #[must_use]
205 pub fn as_str(&self) -> &str {
206 &self.0
207 }
208}
209
210impl TryFrom<String> for MetadataKey {
211 type Error = String;
212
213 fn try_from(key: String) -> Result<Self, Self::Error> {
214 Self::new(key)
215 }
216}
217
218impl From<MetadataKey> for String {
219 fn from(key: MetadataKey) -> Self {
220 key.0
221 }
222}
223
224impl std::fmt::Display for MetadataKey {
225 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226 self.0.fmt(formatter)
227 }
228}
229
230/// Which kind of record a narrow metadata write names.
231///
232/// The three a record can be, and no fourth: a refusal, a not-found error or a protocol guard
233/// that names the record takes one of these rather than a noun, so it cannot name something
234/// that is not a record.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
236pub enum MetadataRecord {
237 /// A task, written through [`TaskSource::set_task_metadata`](crate::TaskSource::set_task_metadata).
238 Task,
239 /// A project, written through
240 /// [`TaskSource::set_project_metadata`](crate::TaskSource::set_project_metadata).
241 Project,
242 /// A document, written through
243 /// [`TaskSource::set_document_metadata`](crate::TaskSource::set_document_metadata).
244 Document,
245}
246
247impl MetadataRecord {
248 /// The record's noun as a message spells it: `task`, `project` or `document`.
249 #[must_use]
250 pub const fn noun(self) -> &'static str {
251 match self {
252 Self::Task => "task",
253 Self::Project => "project",
254 Self::Document => "document",
255 }
256 }
257}
258
259impl std::fmt::Display for MetadataRecord {
260 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261 formatter.write_str(self.noun())
262 }
263}
264
265/// The refusal a source answers a narrow metadata write with when it cannot make one on its
266/// own.
267///
268/// It reads `the linear plugin cannot write a document's metadata on its own`, naming the
269/// record. Spelled once beside [`unwritable_field`](crate::unwritable_field) for that
270/// function's reason: the trait's defaults and the engine's refusal of a plugin whose
271/// handshake does not declare the write say the same thing in the same words.
272#[must_use]
273pub fn unwritable_metadata(kind: &str, record: MetadataRecord) -> SourceError {
274 SourceError::Refused {
275 message: format!("the {kind} plugin cannot write a {record}'s metadata on its own"),
276 }
277}