Skip to main content

onetaskgraph_plugin_api/
id.rs

1//! Identifiers a plugin deals in.
2//!
3//! A plugin only ever sees its own source's opaque [`NativeId`]. Qualifying one
4//! into a `<source>:<native>` global id is the engine's job, in
5//! `onetaskgraph-core`, so nothing here knows about it.
6
7use std::fmt;
8
9use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
10use serde::{Deserialize, Serialize};
11
12use crate::SourceError;
13
14/// A source's own opaque identifier for one item.
15///
16/// Deliberately unvalidated: a native id is whatever the upstream system says it
17/// is, colons included. The engine parses a qualified id by splitting on the
18/// *first* colon precisely so this stays true.
19#[derive(
20    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
21)]
22#[serde(transparent)]
23pub struct NativeId(pub String);
24
25impl NativeId {
26    /// Borrow the underlying string.
27    #[must_use]
28    pub fn as_str(&self) -> &str {
29        &self.0
30    }
31}
32
33impl From<&str> for NativeId {
34    fn from(value: &str) -> Self {
35        Self(value.to_owned())
36    }
37}
38
39impl From<String> for NativeId {
40    fn from(value: String) -> Self {
41        Self(value)
42    }
43}
44
45impl fmt::Display for NativeId {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        f.write_str(&self.0)
48    }
49}
50
51/// The pattern every [`SourceName`] matches.
52///
53/// Underscores are excluded on purpose: `ONETASKGRAPH_SOURCES__<NAME>__...`
54/// joins path segments with a double underscore, so a name containing one would
55/// make that mapping ambiguous.
56pub const SOURCE_NAME_PATTERN: &str = "^[a-z0-9][a-z0-9-]*$";
57
58/// The name a configuration document gives one configured source.
59///
60/// A plugin learns its own name from
61/// [`SourcePlugin::build`](crate::SourcePlugin::build) and nowhere else. It quotes it
62/// in an error message, and it compares it against the source segment of a qualified
63/// [`DependencyEndpoint`](crate::DependencyEndpoint) — which is how a plugin tells a far
64/// end its own backend could have related from one in a system it knows nothing about.
65/// Nothing else about a plugin's behaviour may depend on it: a source answers the same
66/// way whatever a document chose to call it.
67#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
68#[serde(try_from = "String", into = "String")]
69pub struct SourceName(String);
70
71impl SourceName {
72    /// Validate and wrap a source name.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`SourceError::Config`] when `value` does not match
77    /// [`SOURCE_NAME_PATTERN`].
78    pub fn new(value: impl Into<String>) -> Result<Self, SourceError> {
79        let value = value.into();
80        if Self::is_valid(&value) {
81            Ok(Self(value))
82        } else {
83            Err(SourceError::Config {
84                message: format!(
85                    "source name {value:?} is not usable; names must match {SOURCE_NAME_PATTERN} \
86                     (lower-case letters, digits and hyphens, starting with a letter or digit)"
87                ),
88            })
89        }
90    }
91
92    /// The same language [`SOURCE_NAME_PATTERN`] describes, hand-rolled so building a
93    /// name costs no regex. The two are one rule in two places, so
94    /// `source_name_validation_agrees_with_the_pattern_it_publishes` in
95    /// `tests/contract.rs` derives a matcher from the constant and fails if they ever
96    /// describe different languages. Change both together.
97    fn is_valid(value: &str) -> bool {
98        let mut chars = value.chars();
99        let Some(first) = chars.next() else {
100            return false;
101        };
102        if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
103            return false;
104        }
105        chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
106    }
107
108    /// Borrow the underlying string.
109    #[must_use]
110    pub fn as_str(&self) -> &str {
111        &self.0
112    }
113}
114
115impl TryFrom<String> for SourceName {
116    type Error = SourceError;
117
118    fn try_from(value: String) -> Result<Self, Self::Error> {
119        Self::new(value)
120    }
121}
122
123impl From<SourceName> for String {
124    fn from(value: SourceName) -> Self {
125        value.0
126    }
127}
128
129impl fmt::Display for SourceName {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        f.write_str(&self.0)
132    }
133}
134
135impl JsonSchema for SourceName {
136    fn schema_name() -> std::borrow::Cow<'static, str> {
137        "SourceName".into()
138    }
139
140    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
141        json_schema!({
142            "type": "string",
143            "pattern": SOURCE_NAME_PATTERN,
144            "description": "The name a configuration document gives one configured source.",
145        })
146    }
147}