Skip to main content

astarte_interfaces/mapping/
path.rs

1// This file is part of Astarte.
2//
3// Copyright 2023 - 2025 SECO Mind Srl
4//
5// Licensed under the Apache License, Version 2.0 (the "License");
6// you may not use this file except in compliance with the License.
7// You may obtain a copy of the License at
8//
9//    http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing, software
12// distributed under the License is distributed on an "AS IS" BASIS,
13// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14// See the License for the specific language governing permissions and
15// limitations under the License.
16//
17// SPDX-License-Identifier: Apache-2.0
18
19//! Path of a mapping in interface. It's the parsed struct path received from the MQTT levels
20//! structure of the topic received.
21
22use std::fmt::Display;
23
24/// Path of a mapping in interface.
25///
26/// This is used to access the [`Interface`](crate::interface::Interface) so we can compare the parsed [`MappingPath`]
27/// with the [`Endpoint`](crate::Endpoint).
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub struct MappingPath<'a> {
30    pub(crate) path: &'a str,
31    pub(crate) levels: Vec<&'a str>,
32}
33
34impl MappingPath<'_> {
35    /// Returns the mapping as a string.
36    #[must_use]
37    pub fn as_str(&self) -> &str {
38        self.path
39    }
40
41    /// Returns the mapping length.
42    #[must_use]
43    pub fn len(&self) -> usize {
44        self.levels.len()
45    }
46
47    /// Returns true if the path has no levels.
48    #[must_use]
49    pub fn is_empty(&self) -> bool {
50        self.levels.is_empty()
51    }
52}
53
54impl Display for MappingPath<'_> {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        write!(f, "{}", self.path)
57    }
58}
59
60impl<'a> TryFrom<&'a str> for MappingPath<'a> {
61    type Error = MappingPathError;
62
63    fn try_from(value: &'a str) -> Result<Self, Self::Error> {
64        parse_mapping(value)
65    }
66}
67
68/// Error that can happen while parsing the MQTT levels structure of the topic received.
69#[non_exhaustive]
70#[derive(Debug, PartialEq, Eq, Clone, thiserror::Error)]
71pub enum MappingPathError {
72    /// Missing forward slash at the beginning of the path.
73    #[error("path missing prefix: {0}")]
74    Prefix(String),
75    /// The path must contain at least one level.
76    #[error("path should have at least one level")]
77    Empty,
78    /// A path level must contain at least one character, it cannot be `//`.
79    #[error("path has an empty level: {0}")]
80    EmptyLevel(String),
81}
82
83/// Parses the MQTT levels structure of the topic received.
84fn parse_mapping(input: &str) -> Result<MappingPath<'_>, MappingPathError> {
85    let path = input
86        .strip_prefix('/')
87        .ok_or_else(|| MappingPathError::Prefix(input.to_string()))?;
88
89    // Split and check that none are empty
90    let levels: Vec<&str> = path
91        .split('/')
92        .map(|level| {
93            if level.is_empty() {
94                return Err(MappingPathError::EmptyLevel(input.to_string()));
95            }
96
97            Ok(level)
98        })
99        .collect::<Result<_, _>>()?;
100
101    if levels.is_empty() {
102        return Err(MappingPathError::Empty);
103    }
104
105    Ok(MappingPath {
106        path: input,
107        levels,
108    })
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn empty_endpoint() {
117        let path = MappingPath::try_from("/").unwrap_err();
118
119        assert_eq!(path, MappingPathError::EmptyLevel("/".into()));
120    }
121
122    #[test]
123    fn getters_success() {
124        let value = "/some/path";
125        let path = MappingPath::try_from(value).unwrap();
126
127        assert_eq!(path.as_str(), value);
128        assert_eq!(path.len(), 2);
129        assert!(!path.is_empty());
130        assert_eq!(path.to_string(), value);
131    }
132
133    #[test]
134    fn parse_mappings_success() {
135        let cases = [
136            "/foo/value",
137            "/bar/value",
138            "/value",
139            "/foo/bar/valu",
140            "/foo/value/ba",
141        ];
142
143        for case in cases {
144            MappingPath::try_from(case).unwrap_or_else(|err| panic!("failed for {case}: {err}"));
145        }
146    }
147
148    #[test]
149    fn parse_mappings_error() {
150        let err = MappingPath::try_from("/").unwrap_err();
151
152        assert!(matches!(err, MappingPathError::EmptyLevel(_)), "{err:?}");
153    }
154}