Skip to main content

es_fluent_shared/resource/
paths.rs

1use crate::namespace::NamespacePathError;
2
3/// Errors produced while validating locale-relative Fluent resource paths.
4#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
5pub enum LocaleRelativeFtlPathError {
6    /// The path is empty.
7    #[error("path must not be empty")]
8    Empty,
9    /// The path is absolute.
10    #[error("path must be relative")]
11    Absolute,
12    /// The path contains a Windows path separator.
13    #[error("path must use '/' separators")]
14    Backslash,
15    /// The path does not end with `.ftl`.
16    #[error("path must end with .ftl")]
17    MissingFtlSuffix,
18    /// The path stem is not a valid locale-relative namespace-style path.
19    #[error("{0}")]
20    InvalidStem(#[from] NamespacePathError),
21}
22
23/// Locale-relative path to a Fluent resource file.
24///
25/// Paths use the canonical shape `{domain}.ftl` or
26/// `{domain}/{namespace}.ftl` and are relative to a locale root.
27#[derive(
28    Clone,
29    Debug,
30    derive_more::AsRef,
31    derive_more::Deref,
32    derive_more::Display,
33    Eq,
34    Hash,
35    Ord,
36    PartialEq,
37    PartialOrd,
38)]
39#[as_ref(str)]
40#[deref(forward)]
41pub struct LocaleRelativeFtlPath(String);
42
43impl LocaleRelativeFtlPath {
44    /// Validates and creates a locale-relative Fluent resource path.
45    pub fn try_new(path: impl Into<String>) -> Result<Self, LocaleRelativeFtlPathError> {
46        let path = path.into();
47        validate_locale_relative_ftl_path(&path)?;
48        Ok(Self(path))
49    }
50
51    /// Validates and creates a locale-relative Fluent resource path from a static literal.
52    #[allow(
53        clippy::panic,
54        reason = "static metadata uses literal paths; use try_new for dynamic input"
55    )]
56    pub fn from_static_path(path: &'static str) -> Self {
57        Self::try_new(path)
58            .unwrap_or_else(|error| panic!("invalid locale-relative FTL path '{path}': {error}"))
59    }
60
61    /// Returns the path as a string slice.
62    pub fn as_str(&self) -> &str {
63        &self.0
64    }
65
66    /// Returns the owned string.
67    pub fn into_string(self) -> String {
68        self.0
69    }
70}
71
72impl PartialEq<&str> for LocaleRelativeFtlPath {
73    fn eq(&self, other: &&str) -> bool {
74        self.as_str() == *other
75    }
76}
77
78impl PartialEq<LocaleRelativeFtlPath> for &str {
79    fn eq(&self, other: &LocaleRelativeFtlPath) -> bool {
80        *self == other.as_str()
81    }
82}
83
84impl serde::Serialize for LocaleRelativeFtlPath {
85    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
86    where
87        S: serde::Serializer,
88    {
89        serializer.serialize_str(self.as_str())
90    }
91}
92
93impl<'de> serde::Deserialize<'de> for LocaleRelativeFtlPath {
94    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
95    where
96        D: serde::Deserializer<'de>,
97    {
98        let value = <String as serde::Deserialize>::deserialize(deserializer)?;
99        Self::try_new(value).map_err(serde::de::Error::custom)
100    }
101}
102
103fn validate_locale_relative_ftl_path(path: &str) -> Result<(), LocaleRelativeFtlPathError> {
104    if path.is_empty() {
105        return Err(LocaleRelativeFtlPathError::Empty);
106    }
107    if path.starts_with('/') {
108        return Err(LocaleRelativeFtlPathError::Absolute);
109    }
110    if path.contains('\\') {
111        return Err(LocaleRelativeFtlPathError::Backslash);
112    }
113
114    let Some(stem) = path.strip_suffix(".ftl") else {
115        return Err(LocaleRelativeFtlPathError::MissingFtlSuffix);
116    };
117
118    crate::namespace::validate_namespace_path_typed(stem)?;
119    Ok(())
120}