Skip to main content

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.members` ([`Self::MEMBERS_KEY`]), `onetaskgraph.member_of`
34    /// ([`Self::MEMBER_OF_KEY`]) and `onetaskgraph.assets` ([`Self::ASSETS_KEY`]) — and so
35    /// does any key it reserves later, which is why the whole namespace is refused rather than
36    /// a list.
37    pub const RESERVED_NAMESPACE: &'static str = "onetaskgraph";
38
39    /// The reserved key a task or a document rendered from a template records where it came
40    /// from under: an object holding the template's reference, the chain digest it was
41    /// rendered with, and the SHA-256 of its content and of its resolved answers.
42    ///
43    /// A key of this product's own, so no [`MetadataKey`] can name it: only a rendering write
44    /// — [`TaskSource::write_task_rendered`](crate::TaskSource::write_task_rendered) and
45    /// [`TaskSource::set_task_rendering`](crate::TaskSource::set_task_rendering) and their
46    /// document siblings — ever sets it, and a copy carries it like any other entry. The
47    /// engine builds and reads the value; a plugin only puts it where its metadata lives.
48    pub const TEMPLATE_KEY: &'static str = "onetaskgraph.template";
49
50    /// The reserved key a copied item records where it landed under: a JSON object mapping a
51    /// destination source name to the qualified id of that item's counterpart there, at
52    /// most one entry per destination.
53    ///
54    /// A key of this product's own, so [`Self::new`] refuses it like every other key in the
55    /// namespace: only the engine's copy writes it, through the narrow metadata write, and
56    /// [`Self::copies`] is how it names the key there. A plugin only puts it where its
57    /// metadata lives.
58    pub const COPIES_KEY: &'static str = "onetaskgraph.copies";
59
60    /// The reserved key a **home** project records its member projects under: a JSON list of
61    /// qualified project ids, each in a different source from the home and from the others,
62    /// so a home has at most one member per source.
63    ///
64    /// Store-owned, like every key in the namespace: only the engine's routed writes keep it —
65    /// a copy, or a `task create` routed away from its project's source — through the home's
66    /// own project write, and nothing a caller types can name it. A plugin
67    /// only puts it where its metadata lives.
68    pub const MEMBERS_KEY: &'static str = "onetaskgraph.members";
69
70    /// The reserved key a **member** project records its home under: the home's qualified id.
71    ///
72    /// Written once, when a routed write — a copy or a `task create` — creates the member, and
73    /// never by a caller.
74    pub const MEMBER_OF_KEY: &'static str = "onetaskgraph.member_of";
75
76    /// The reserved key a hosted source records what it uploaded for a record's image assets
77    /// under: an [`AssetUploads`](crate::AssetUploads) — an object keyed by asset name, each
78    /// value `{"sha256": <lowercase hex>, "url": <string>}`.
79    ///
80    /// Written by the hosted plugin itself, in the same write that lands the record, through
81    /// [`serve_asset_references`](crate::serve_asset_references); handed back to it as
82    /// [`AssetWrite::recorded_assets`](crate::AssetWrite::recorded_assets) on the next copy onto
83    /// that record, so an asset whose SHA-256 is unchanged reuses its URL and is not uploaded
84    /// again. A copy never carries a source record's entry to its destination: the entry says
85    /// where *that* source serves the bytes.
86    pub const ASSETS_KEY: &'static str = "onetaskgraph.assets";
87
88    /// [`Self::COPIES_KEY`], as the one reserved key a narrow metadata write may carry.
89    ///
90    /// Not a way round [`Self::new`] for a caller: nothing a person types reaches this, and
91    /// the one write that sends it is the copy recording where an item landed.
92    #[must_use]
93    // llmlint: ignore[invalid_states_unrepresentable] A key type of its own would change the
94    // signature of the three narrow metadata writes on `TaskSource`, which every plugin
95    // implements and this crate keeps still (AGENTS.md); the reserved key reaches that seam
96    // only through this one named constructor, while `new`, deserialization, the command line
97    // and both SDKs refuse it, and the stdio boundary admits it only with a value that is links.
98    pub fn copies() -> Self {
99        Self(Self::COPIES_KEY.to_owned())
100    }
101
102    /// Whether this is [`Self::COPIES_KEY`].
103    #[must_use]
104    pub fn is_copies(&self) -> bool {
105        self.0 == Self::COPIES_KEY
106    }
107
108    /// One key, once it is established it is a caller's own dotted key.
109    ///
110    /// # Errors
111    ///
112    /// Returns a message saying why, and what to write instead, when the key has no dot, has
113    /// an empty segment, or is in [`Self::RESERVED_NAMESPACE`].
114    pub fn new(key: impl Into<String>) -> Result<Self, String> {
115        let key = key.into();
116        let segments: Vec<&str> = key.split('.').collect();
117        if segments.len() < 2 {
118            return Err(format!(
119                "the metadata key {key:?} has no namespace: a key is `<namespace>.<name>`, two \
120                 or more non-empty segments separated by dots; next: name it under a namespace \
121                 of your own, such as `myapp.{key}`"
122            ));
123        }
124        if segments.iter().any(|segment| segment.is_empty()) {
125            return Err(format!(
126                "the metadata key {key:?} has an empty segment: a key is `<namespace>.<name>`, \
127                 two or more non-empty segments separated by dots; next: remove the extra dot"
128            ));
129        }
130        if segments[0] == Self::RESERVED_NAMESPACE {
131            return Err(format!(
132                "the metadata key {key:?} is in the `{}.` namespace, which this product owns \
133                 and keeps in step itself; next: name the key under a namespace of your own",
134                Self::RESERVED_NAMESPACE
135            ));
136        }
137        Ok(Self(key))
138    }
139
140    /// The key as a record's metadata map spells it.
141    #[must_use]
142    pub fn as_str(&self) -> &str {
143        &self.0
144    }
145}
146
147impl TryFrom<String> for MetadataKey {
148    type Error = String;
149
150    fn try_from(key: String) -> Result<Self, Self::Error> {
151        Self::new(key)
152    }
153}
154
155impl From<MetadataKey> for String {
156    fn from(key: MetadataKey) -> Self {
157        key.0
158    }
159}
160
161impl std::fmt::Display for MetadataKey {
162    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        self.0.fmt(formatter)
164    }
165}
166
167/// Which kind of record a narrow metadata write names.
168///
169/// The three a record can be, and no fourth: a refusal, a not-found error or a protocol guard
170/// that names the record takes one of these rather than a noun, so it cannot name something
171/// that is not a record.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
173pub enum MetadataRecord {
174    /// A task, written through [`TaskSource::set_task_metadata`](crate::TaskSource::set_task_metadata).
175    Task,
176    /// A project, written through
177    /// [`TaskSource::set_project_metadata`](crate::TaskSource::set_project_metadata).
178    Project,
179    /// A document, written through
180    /// [`TaskSource::set_document_metadata`](crate::TaskSource::set_document_metadata).
181    Document,
182}
183
184impl MetadataRecord {
185    /// The record's noun as a message spells it: `task`, `project` or `document`.
186    #[must_use]
187    pub const fn noun(self) -> &'static str {
188        match self {
189            Self::Task => "task",
190            Self::Project => "project",
191            Self::Document => "document",
192        }
193    }
194}
195
196impl std::fmt::Display for MetadataRecord {
197    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        formatter.write_str(self.noun())
199    }
200}
201
202/// The refusal a source answers a narrow metadata write with when it cannot make one on its
203/// own.
204///
205/// It reads `the linear plugin cannot write a document's metadata on its own`, naming the
206/// record. Spelled once beside [`unwritable_field`](crate::unwritable_field) for that
207/// function's reason: the trait's defaults and the engine's refusal of a plugin whose
208/// handshake does not declare the write say the same thing in the same words.
209#[must_use]
210pub fn unwritable_metadata(kind: &str, record: MetadataRecord) -> SourceError {
211    SourceError::Refused {
212        message: format!("the {kind} plugin cannot write a {record}'s metadata on its own"),
213    }
214}