saluki_core/topology/
ids.rs

1//! Component and component output identifiers.
2use core::fmt;
3use std::{borrow::Cow, ops::Deref};
4
5use crate::{components::ComponentType, support::SubsystemIdentifier, topology::graph::DataType};
6
7const INVALID_COMPONENT_ID: &str = "component IDs may only contain alphanumerics (a-z, A-Z, or 0-9) and underscores, \
8     and must start and end with an alphanumeric character";
9const INVALID_COMPONENT_OUTPUT_ID: &str =
10    "component output IDs may only contain alphanumerics (a-z, A-Z, or 0-9), underscores, and up to one period \
11     separator, where each side of the separator must start and end with an alphanumeric character";
12
13/// A component identifier.
14///
15/// Component identifiers contain only alphanumerics and underscores, and must start and end with an alphanumeric
16/// character.
17#[derive(Clone, Debug, Hash, Eq, Ord, PartialEq, PartialOrd)]
18pub struct ComponentId(Cow<'static, str>);
19
20impl TryFrom<&str> for ComponentId {
21    type Error = &'static str;
22
23    fn try_from(value: &str) -> Result<Self, Self::Error> {
24        if !validate_component_id(value, false) {
25            Err(INVALID_COMPONENT_ID)
26        } else {
27            Ok(Self(value.to_string().into()))
28        }
29    }
30}
31
32impl Deref for ComponentId {
33    type Target = str;
34
35    fn deref(&self) -> &Self::Target {
36        self.0.as_ref()
37    }
38}
39
40impl fmt::Display for ComponentId {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        self.0.fmt(f)
43    }
44}
45
46/// A component output identifier.
47#[derive(Clone, Debug, Hash, Eq, Ord, PartialEq, PartialOrd)]
48pub struct ComponentOutputId(Cow<'static, str>);
49
50impl ComponentOutputId {
51    /// Creates a new `ComponentOutputId` from an identifier and output definition.
52    ///
53    /// # Errors
54    ///
55    /// If generated component output ID isn't valid (identifier or output definition containing invalid characters,
56    /// etc), an error is returned.
57    pub fn from_definition<T: Copy>(
58        component_id: ComponentId, output_def: &OutputDefinition<T>,
59    ) -> Result<Self, (String, &'static str)> {
60        match output_def.output_name() {
61            None => Ok(Self(component_id.0)),
62            Some(output_name) => {
63                let output_id = format!("{}.{}", component_id.0, output_name);
64
65                if validate_component_id(&output_id, true) {
66                    Ok(Self(output_id.into()))
67                } else {
68                    Err((output_id, INVALID_COMPONENT_OUTPUT_ID))
69                }
70            }
71        }
72    }
73
74    /// Returns the component ID.
75    pub fn component_id(&self) -> ComponentId {
76        if let Some((component_id, _)) = self.0.split_once('.') {
77            ComponentId(component_id.to_string().into())
78        } else {
79            ComponentId(self.0.clone())
80        }
81    }
82
83    /// Returns the output name.
84    pub fn output(&self) -> OutputName {
85        if let Some((_, output_name)) = self.0.split_once('.') {
86            OutputName::Given(output_name.to_string().into())
87        } else {
88            OutputName::Default
89        }
90    }
91
92    /// Returns `true` if this is a default output.
93    pub fn is_default(&self) -> bool {
94        self.0.split_once('.').is_none()
95    }
96}
97
98impl TryFrom<&str> for ComponentOutputId {
99    type Error = &'static str;
100
101    fn try_from(value: &str) -> Result<Self, Self::Error> {
102        if !validate_component_id(value, true) {
103            Err(INVALID_COMPONENT_OUTPUT_ID)
104        } else {
105            Ok(Self(value.to_string().into()))
106        }
107    }
108}
109
110impl fmt::Display for ComponentOutputId {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        self.0.fmt(f)
113    }
114}
115
116/// Validates a component ID, or -- when `as_output_id` is `true` -- a component output ID.
117///
118/// A component ID must be non-empty, contain only ASCII alphanumerics and underscores, and both start and end with an
119/// alphanumeric character. A component output ID additionally permits a single period, which separates the component ID
120/// from the output name; each side of the separator must independently satisfy the component ID rules.
121const fn validate_component_id(id: &str, as_output_id: bool) -> bool {
122    let id_bytes = id.as_bytes();
123    let end = id_bytes.len();
124
125    // Identifiers cannot be empty strings.
126    if end == 0 {
127        return false;
128    }
129
130    // Walk the string a byte at a time. Periods are only meaningful for output IDs, where a single period separates the
131    // component ID from the output name; every other byte must be an alphanumeric or underscore. Each segment (the whole
132    // string when there's no separator) is validated as it's closed out.
133    let mut idx = 0;
134    let mut segment_start = 0;
135    let mut seen_separator = false;
136    while idx < end {
137        let b = id_bytes[idx];
138        if b == b'.' {
139            if !as_output_id || seen_separator {
140                // We're not validating as an output ID, or we already saw a period separator: either way, invalid.
141                return false;
142            }
143            seen_separator = true;
144
145            // Close out and validate the segment that just ended.
146            if !is_valid_component_id_segment(id_bytes, segment_start, idx) {
147                return false;
148            }
149            segment_start = idx + 1;
150        } else if !b.is_ascii_alphanumeric() && b != b'_' {
151            // Anything other than an alphanumeric, underscore, or (handled above) period separator is invalid.
152            return false;
153        }
154
155        idx += 1;
156    }
157
158    // Validate the final (or only) segment.
159    is_valid_component_id_segment(id_bytes, segment_start, end)
160}
161
162/// Returns `true` if the byte range `[start, end)` of `bytes` is a valid component ID segment.
163///
164/// The caller guarantees the range contains only alphanumerics and underscores; this additionally requires the segment
165/// to be non-empty and to start and end with an alphanumeric character (which rejects leading/trailing underscores as
166/// well as empty segments arising from leading, trailing, or duplicate separators).
167const fn is_valid_component_id_segment(bytes: &[u8], start: usize, end: usize) -> bool {
168    // Segment cannot be empty.
169    if start >= end {
170        return false;
171    }
172
173    // Segments must start and end with an alphanumeric character.
174    bytes[start].is_ascii_alphanumeric() && bytes[end - 1].is_ascii_alphanumeric()
175}
176
177/// An output name.
178///
179/// Components must always have at least one output, but an output can either be the default output or a named output.
180/// This allows for components to have multiple outputs, potentially with one (the default) acting as a catch-all.
181///
182/// `OutputName` is used to differentiate between a default output and named outputs.
183#[derive(Clone, Debug, Eq, Hash, PartialEq)]
184pub enum OutputName {
185    /// Default output.
186    Default,
187
188    /// Named output.
189    Given(Cow<'static, str>),
190}
191
192impl fmt::Display for OutputName {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        match self {
195            OutputName::Default => write!(f, "_default"),
196            OutputName::Given(name) => write!(f, "{}", name),
197        }
198    }
199}
200
201/// An output definition.
202///
203/// Outputs are a combination of the output name and data type, which defines the data type (or types) of events that
204/// can be emitted from a particular component output.
205#[derive(Clone, Debug)]
206pub struct OutputDefinition<T> {
207    name: OutputName,
208    data_ty: T,
209}
210
211impl<T> OutputDefinition<T>
212where
213    T: Copy,
214{
215    /// Creates a default output with the given data type.
216    pub const fn default_output(data_ty: T) -> Self {
217        Self {
218            name: OutputName::Default,
219            data_ty,
220        }
221    }
222
223    /// Creates a named output with the given name and data type.
224    pub fn named_output<S>(name: S, data_ty: T) -> Self
225    where
226        S: Into<Cow<'static, str>>,
227    {
228        Self {
229            name: OutputName::Given(name.into()),
230            data_ty,
231        }
232    }
233
234    /// Returns the output name.
235    ///
236    /// If this is a default output, `None` is returned.
237    pub fn output_name(&self) -> Option<&str> {
238        match &self.name {
239            OutputName::Default => None,
240            OutputName::Given(name) => Some(name.as_ref()),
241        }
242    }
243
244    /// Returns the data type.
245    pub fn data_ty(&self) -> T {
246        self.data_ty
247    }
248}
249
250/// A component identifier that specifies the component type.
251#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
252pub struct TypedComponentId {
253    id: ComponentId,
254    ty: ComponentType,
255}
256
257impl TypedComponentId {
258    /// Creates a new `TypedComponentId` from the given component ID and component type.
259    pub fn new(id: ComponentId, ty: ComponentType) -> Self {
260        Self { id, ty }
261    }
262
263    /// Returns a reference to the component ID.
264    pub fn component_id(&self) -> &ComponentId {
265        &self.id
266    }
267
268    /// Returns the component type.
269    pub fn component_type(&self) -> ComponentType {
270        self.ty
271    }
272
273    /// Consumes the `TypedComponentId` and returns its component ID and component type.
274    pub fn into_parts(self) -> (ComponentId, ComponentType) {
275        (self.id, self.ty)
276    }
277}
278
279/// Unique identifier for a specified output of a component, including the data type of the output.
280#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
281pub struct TypedComponentOutputId {
282    component_output: ComponentOutputId,
283    output_ty: DataType,
284}
285
286impl TypedComponentOutputId {
287    /// Creates a new `TypedComponentOutputId` from the given component output ID and output data type.
288    pub fn new(component_output: ComponentOutputId, output_ty: DataType) -> Self {
289        Self {
290            component_output,
291            output_ty,
292        }
293    }
294
295    /// Gets a reference to the component output ID.
296    pub fn component_output(&self) -> &ComponentOutputId {
297        &self.component_output
298    }
299
300    /// Returns the output data type.
301    pub fn output_ty(&self) -> DataType {
302        self.output_ty
303    }
304}
305
306/// Disambiguation marker for [`AsComponentIds`] when a single component ID is given.
307pub struct Single;
308
309/// Disambiguation marker for [`AsComponentIds`] when multiple component IDs are given.
310pub struct Multiple;
311
312/// Conversion into an iterator of component IDs.
313///
314/// Intended for use in methods that accept component IDs as string references, where a single or multiple IDs may be
315/// passed within a single parameter. This allows being generic over those possibilities such that callers can use more
316/// natural values rather than contrived values (such as always having to wrap a single string in a slice, etc).
317pub trait AsComponentIds<Marker> {
318    /// Converts `self` into an iterator of component output IDs.
319    ///
320    /// This borrows `self` -- rather than consuming it as the `into_` prefix would normally imply -- so that the
321    /// iterator can be built multiple times from the same value, which is necessary for connecting every upstream ID
322    /// to every downstream ID when making many-to-many connections.
323    fn as_component_ids(&self) -> impl Iterator<Item: AsRef<str>>;
324}
325
326impl<T> AsComponentIds<Single> for T
327where
328    T: AsRef<str>,
329{
330    fn as_component_ids(&self) -> impl Iterator<Item: AsRef<str>> {
331        std::iter::once(self)
332    }
333}
334
335impl<I> AsComponentIds<Multiple> for I
336where
337    for<'a> &'a I: IntoIterator<Item: AsRef<str>>,
338{
339    fn as_component_ids(&self) -> impl Iterator<Item: AsRef<str>> {
340        self.into_iter()
341    }
342}
343
344pub(super) fn get_component_relative_identifier(
345    component_type: ComponentType, component_id: &ComponentId,
346) -> SubsystemIdentifier {
347    SubsystemIdentifier::from_segments([component_type.as_category_str(), component_id])
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn component_id() {
356        let id = ComponentId::try_from("component").unwrap();
357        assert_eq!(id, ComponentId::try_from("component").unwrap());
358        assert_eq!(&*id, "component");
359
360        let id = ComponentId::try_from("component_1").unwrap();
361        assert_eq!(id, ComponentId::try_from("component_1").unwrap());
362        assert_eq!(&*id, "component_1");
363    }
364
365    #[test]
366    fn component_id_invalid() {
367        assert!(ComponentId::try_from("").is_err());
368        assert!(ComponentId::try_from("non_alphanumeric_$#!").is_err());
369        assert!(ComponentId::try_from("cant_have_periods_for_non_component_output_id.foo").is_err());
370
371        // Hyphens are rejected: they would sanitize to underscores, so `foo-bar` and `foo_bar` would otherwise collide
372        // on the same canonical identity.
373        assert!(ComponentId::try_from("dsd-in").is_err());
374        assert!(ComponentId::try_from("foo-bar").is_err());
375
376        // Leading/trailing underscores are rejected: they are trimmed during sanitization, so `_foo`/`foo_` would
377        // otherwise collide with `foo`.
378        assert!(ComponentId::try_from("_foo").is_err());
379        assert!(ComponentId::try_from("foo_").is_err());
380        assert!(ComponentId::try_from("--").is_err());
381    }
382
383    #[test]
384    fn component_id_is_sanitization_fixed_point() {
385        // A ComponentId must be valid if and only if it is already in canonical form -- that is, sanitizing it via
386        // `get_sanitized_name` is a no-op. This is what guarantees distinct component IDs can never collapse to the same
387        // canonical identity across subsystems. If the ComponentId rules and the sanitizer ever drift apart, this test
388        // fails.
389        use crate::runtime::get_sanitized_name;
390
391        let cases = [
392            "component",
393            "component_1",
394            "foo__bar",
395            "a",
396            "0",
397            "dsd-mapper",
398            "foo-bar",
399            "_foo",
400            "foo_",
401            "_foo_",
402            "--",
403            "",
404            "foo.bar",
405            "foo bar",
406            "foo$bar",
407        ];
408
409        for case in cases {
410            let is_valid = ComponentId::try_from(case).is_ok();
411
412            // The empty string is a trivial fixed point of the sanitizer but is not a valid ID, so we require
413            // non-emptiness alongside the fixed-point property.
414            let is_canonical = !case.is_empty() && &*get_sanitized_name(case) == case;
415
416            assert_eq!(
417                is_valid, is_canonical,
418                "ComponentId validity must match the sanitization fixed point for {case:?}: valid={is_valid}, canonical={is_canonical}"
419            );
420        }
421    }
422
423    #[test]
424    fn component_output_id_default() {
425        let id = ComponentOutputId::try_from("component").unwrap();
426        assert_eq!(id.component_id(), ComponentId::try_from("component").unwrap());
427        assert_eq!(id.output(), OutputName::Default);
428        assert!(id.is_default());
429    }
430
431    #[test]
432    fn component_output_id_named() {
433        let id = ComponentOutputId::try_from("component.metrics").unwrap();
434        assert_eq!(id.component_id(), ComponentId::try_from("component").unwrap());
435        assert_eq!(id.output(), OutputName::Given("metrics".into()));
436        assert!(!id.is_default());
437    }
438
439    #[test]
440    fn component_output_id_invalid() {
441        assert!(ComponentOutputId::try_from("").is_err());
442        assert!(ComponentOutputId::try_from("non_alphanumeric_$#!").is_err());
443        assert!(ComponentOutputId::try_from("too.many.periods").is_err());
444        assert!(ComponentOutputId::try_from(".one_side_of_named_output_is_empty").is_err());
445        assert!(ComponentOutputId::try_from("one_side_of_named_output_is_empty.").is_err());
446    }
447
448    #[test]
449    fn component_output_id_from_definition() {
450        use crate::data_model::event::EventType;
451
452        let component_id = ComponentId::try_from("comp").expect("component ID should be valid");
453
454        // A default output yields the bare component ID.
455        let default_def = OutputDefinition::default_output(EventType::EventD);
456        let default_id =
457            ComponentOutputId::from_definition(component_id.clone(), &default_def).expect("default output is valid");
458        assert_eq!(default_id, ComponentOutputId::try_from("comp").unwrap());
459        assert!(default_id.is_default());
460
461        // A valid named output yields the `<component>.<output>` form.
462        let named_def = OutputDefinition::named_output("errors", EventType::EventD);
463        let named_id =
464            ComponentOutputId::from_definition(component_id.clone(), &named_def).expect("named output is valid");
465        assert_eq!(named_id, ComponentOutputId::try_from("comp.errors").unwrap());
466        assert!(!named_id.is_default());
467
468        // The documented `# Errors` branch: a named output whose generated ID is invalid (here, an embedded space) is
469        // rejected, returning the offending generated ID and the reason string.
470        let invalid_def = OutputDefinition::named_output("bad name", EventType::EventD);
471        let err = ComponentOutputId::from_definition(component_id, &invalid_def)
472            .expect_err("an invalid generated output ID must be rejected");
473        assert_eq!(err, ("comp.bad name".to_string(), INVALID_COMPONENT_OUTPUT_ID));
474    }
475}
476
477#[cfg(test)]
478mod property_tests {
479    use proptest::prelude::*;
480
481    use super::ComponentId;
482    use crate::runtime::get_sanitized_name;
483
484    proptest! {
485        #[test]
486        fn property_test_component_id_sanitized_name_equality_ascii(s in "[A-Za-z0-9_.\\- ]{0,16}") {
487            // Anti-drift invariant: when a given ASCII string requires no sanitization (`get_sanitized_name(input) ==
488            // input`), we should never fail to parse it as a valid `ComponentId`.
489            //
490            // This tries to ensure that if the parsing rules for `ComponentId` or `get_sanitized_name` change, we will
491            // surface that through this test failing.
492            let is_valid = ComponentId::try_from(s.as_str()).is_ok();
493            let is_canonical = !s.is_empty() && &*get_sanitized_name(&s) == s.as_str();
494            prop_assert_eq!(is_valid, is_canonical, "ComponentId validity must match canonical form for {:?}", s);
495        }
496
497        #[test]
498        fn property_test_valid_component_id_always_canonical(s in ".{0,16}") {
499            // Safety invariant: when a given string (any Unicode string) is accepted as a `ComponentId`, it must
500            // already be canonical, so routing it through the sanitizer is a no-op and two distinct IDs can never
501            // collapse onto the same identity.
502            //
503            // This is a superset of the ASCII-only validation: `get_sanitized_name` is able to sanitize non-ASCII names
504            // into a canonical representation, but only ASCII names are accepted by `ComponentId`, so we only check for
505            // canonical equality if the string was able to be parsed as a `ComponentId` in the first place.
506            if ComponentId::try_from(s.as_str()).is_ok() {
507                prop_assert!(!s.is_empty(), "an accepted ComponentId must be non-empty");
508                prop_assert_eq!(&*get_sanitized_name(&s), s.as_str(), "an accepted ComponentId must already be canonical");
509            }
510        }
511    }
512}