Skip to main content

uni_plugin/
qname.rs

1//! Qualified plugin item names — `namespace.local`.
2//!
3//! Every plugin-registered extension is identified by a [`QName`]: the
4//! plugin's owning namespace (reverse-DNS, e.g. `ai.dragonscale.geo`) plus a
5//! local name (e.g. `haversine`). Stored case-sensitively; matched
6//! case-insensitively at Cypher call sites, case-sensitively at Locy call
7//! sites.
8
9use std::fmt;
10use std::str::FromStr;
11
12use serde::{Deserialize, Serialize};
13use smol_str::SmolStr;
14
15use crate::errors::PluginError;
16
17/// Reserved single-token plugin ids that are exempt from the reverse-DNS
18/// id-format requirement.
19///
20/// Third-party plugins must use reverse-DNS ids (e.g. `ai.example.geo`).
21/// The framework ships a handful of single-token ids for its own
22/// built-ins and migration aids; conformance probes accept these as
23/// valid id shapes.
24pub const RESERVED_PLUGIN_IDS: &[&str] = &["builtin", "apoc-core", "custom", "user.legacy"];
25
26/// Returns `true` if `id` is one of the framework-reserved single-token
27/// plugin ids exempt from the reverse-DNS requirement.
28#[must_use]
29pub fn is_reserved_plugin_id(id: &str) -> bool {
30    RESERVED_PLUGIN_IDS.contains(&id)
31}
32
33/// Qualified plugin item name — `namespace.local`.
34///
35/// `QName` is the address every plugin-registered extension is looked up by.
36/// The namespace is the registering plugin's id; the local is the per-plugin
37/// item name. Built-ins use the reserved namespace [`QName::BUILTIN_NS`].
38///
39/// # Examples
40///
41/// ```
42/// use uni_plugin::QName;
43/// let q = QName::parse("ai.dragonscale.geo.haversine").unwrap();
44/// assert_eq!(q.namespace(), "ai.dragonscale.geo");
45/// assert_eq!(q.local(), "haversine");
46/// ```
47///
48/// # Errors
49///
50/// [`QName::parse`] returns [`PluginError::InvalidQName`] if the input does
51/// not contain at least one `.` separating namespace from local, or if either
52/// side is empty.
53#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub struct QName {
55    namespace: SmolStr,
56    local: SmolStr,
57}
58
59impl QName {
60    /// Reserved namespace for uni-db built-in extensions.
61    ///
62    /// Built-ins registered by `uni-plugin-builtin` use this namespace so
63    /// they are distinguishable from third-party plugins at the registry
64    /// level. The user-facing Cypher / Locy syntax does not require the
65    /// namespace prefix for built-ins — `RETURN toUpper(s)` resolves to
66    /// `builtin.toUpper` through Cypher's case-insensitive matching.
67    pub const BUILTIN_NS: &'static str = "builtin";
68
69    /// Construct a `QName` from already-validated parts.
70    ///
71    /// # Panics
72    ///
73    /// Panics if `namespace` or `local` is empty, since this is a programming
74    /// error rather than a fallible parse — use [`QName::parse`] to validate
75    /// untrusted input.
76    #[must_use]
77    pub fn new(namespace: impl Into<SmolStr>, local: impl Into<SmolStr>) -> Self {
78        let namespace = namespace.into();
79        let local = local.into();
80        assert!(!namespace.is_empty(), "QName namespace must not be empty");
81        assert!(!local.is_empty(), "QName local must not be empty");
82        Self { namespace, local }
83    }
84
85    /// Construct a `QName` in the [`QName::BUILTIN_NS`] namespace.
86    ///
87    /// Shorthand for built-in registrations.
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// use uni_plugin::QName;
93    /// let q = QName::builtin("MIN");
94    /// assert_eq!(q.namespace(), "builtin");
95    /// assert_eq!(q.local(), "MIN");
96    /// ```
97    #[must_use]
98    pub fn builtin(local: impl Into<SmolStr>) -> Self {
99        Self::new(Self::BUILTIN_NS, local)
100    }
101
102    /// Parse a fully-qualified name like `"ai.dragonscale.geo.haversine"`.
103    ///
104    /// The last segment (after the final `.`) is taken as the local name; the
105    /// preceding segments are joined back as the namespace. A namespace with
106    /// no `.` (e.g. `"builtin.MIN"`) is also accepted.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`PluginError::InvalidQName`] if the input lacks a `.`, or if
111    /// either side of the final `.` is empty.
112    pub fn parse(s: impl AsRef<str>) -> Result<Self, PluginError> {
113        let s = s.as_ref();
114        let (ns, local) = s
115            .rsplit_once('.')
116            .ok_or_else(|| PluginError::InvalidQName(s.to_owned()))?;
117        if ns.is_empty() || local.is_empty() {
118            return Err(PluginError::InvalidQName(s.to_owned()));
119        }
120        Ok(Self {
121            namespace: SmolStr::new(ns),
122            local: SmolStr::new(local),
123        })
124    }
125
126    /// Returns the namespace portion (the plugin id).
127    #[must_use]
128    pub fn namespace(&self) -> &str {
129        &self.namespace
130    }
131
132    /// Returns the local portion (the per-plugin item name).
133    #[must_use]
134    pub fn local(&self) -> &str {
135        &self.local
136    }
137
138    /// Returns `true` if this name is in the reserved built-in namespace.
139    #[must_use]
140    pub fn is_builtin(&self) -> bool {
141        self.namespace == Self::BUILTIN_NS
142    }
143
144    /// Cypher-style case-insensitive equality.
145    ///
146    /// Cypher function-call sites compare names case-insensitively
147    /// (`toUpper` and `TOUPPER` resolve identically). Locy uses
148    /// [`PartialEq`] (case-sensitive) directly.
149    #[must_use]
150    pub fn matches_cypher(&self, other: &Self) -> bool {
151        self.namespace.eq_ignore_ascii_case(&other.namespace)
152            && self.local.eq_ignore_ascii_case(&other.local)
153    }
154}
155
156impl fmt::Display for QName {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        write!(f, "{}.{}", self.namespace, self.local)
159    }
160}
161
162impl FromStr for QName {
163    type Err = PluginError;
164
165    fn from_str(s: &str) -> Result<Self, Self::Err> {
166        Self::parse(s)
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn parse_simple() {
176        let q = QName::parse("foo.bar").unwrap();
177        assert_eq!(q.namespace(), "foo");
178        assert_eq!(q.local(), "bar");
179    }
180
181    #[test]
182    fn parse_nested_namespace() {
183        let q = QName::parse("ai.dragonscale.geo.haversine").unwrap();
184        assert_eq!(q.namespace(), "ai.dragonscale.geo");
185        assert_eq!(q.local(), "haversine");
186    }
187
188    #[test]
189    fn parse_rejects_empty_local() {
190        assert!(matches!(
191            QName::parse("foo."),
192            Err(PluginError::InvalidQName(_))
193        ));
194    }
195
196    #[test]
197    fn parse_rejects_empty_namespace() {
198        assert!(matches!(
199            QName::parse(".bar"),
200            Err(PluginError::InvalidQName(_))
201        ));
202    }
203
204    #[test]
205    fn parse_rejects_no_dot() {
206        assert!(matches!(
207            QName::parse("nodothere"),
208            Err(PluginError::InvalidQName(_))
209        ));
210    }
211
212    #[test]
213    fn builtin_helper() {
214        let q = QName::builtin("MIN");
215        assert!(q.is_builtin());
216        assert_eq!(q.local(), "MIN");
217    }
218
219    #[test]
220    fn cypher_match_case_insensitive() {
221        let a = QName::builtin("toUpper");
222        let b = QName::builtin("TOUPPER");
223        assert!(a.matches_cypher(&b));
224        assert_ne!(a, b);
225    }
226
227    #[test]
228    fn display_round_trip() {
229        let q = QName::new("foo.bar", "baz");
230        assert_eq!(q.to_string(), "foo.bar.baz");
231        let parsed: QName = "foo.bar.baz".parse().unwrap();
232        assert_eq!(q, parsed);
233    }
234}