strut_config/scanner/
file.rs

1use config::{File, FileFormat, FileSourceFile};
2use std::cmp::Ordering;
3use std::path::PathBuf;
4use strut_core::AppProfile;
5
6/// Represents a single config file.
7#[derive(Debug, Eq, PartialEq)]
8pub enum ConfigFile {
9    /// A file like `app.toml`.
10    GenericToml(PathBuf),
11
12    /// A file like `app.yaml` / `app.yml`.
13    GenericYaml(PathBuf),
14
15    /// A file like `app.prod.toml`.
16    SpecificToml {
17        /// The path to the file.
18        path: PathBuf,
19
20        /// The associated profile name.
21        profile: String,
22    },
23
24    /// A file like `app.prod.yaml` / `app.prod.yml`.
25    SpecificYaml {
26        /// The path to the file.
27        path: PathBuf,
28
29        /// The associated profile name.
30        profile: String,
31    },
32}
33
34impl ConfigFile {
35    /// Creates a [`ConfigFile`] from the given [`PathBuf`], if the path points
36    /// to a workable config file.
37    pub fn try_at(path: PathBuf) -> Option<Self> {
38        Self::try_make_with_profile(path, None)
39    }
40
41    /// Creates a [`ConfigFile`] from the given [`PathBuf`], if the path points
42    /// to a workable config file, optionally applying the given known profile
43    /// name.
44    pub fn try_make_with_profile(path: PathBuf, known_profile: Option<&str>) -> Option<Self> {
45        // Read file name
46        let name = match path.file_name().and_then(std::ffi::OsStr::to_str) {
47            Some(name) => name,
48            None => return None,
49        };
50
51        // Split file name on `.`
52        let chunks = name.split('.').collect::<Vec<_>>();
53
54        // Match chunk pattern
55        match *chunks.as_slice() {
56            [_name, extension] => {
57                if is_toml_extension(extension) {
58                    Self::toml_from(path, known_profile)
59                } else if is_yaml_extension(extension) {
60                    Self::yaml_from(path, known_profile)
61                } else {
62                    None
63                }
64            }
65            [_name, profile, extension] => {
66                // Do we know the profile already?
67                if let Some(known_profile) = known_profile {
68                    // If we know the profile already, only take specific file of that profile
69                    if !known_profile.eq_ignore_ascii_case(profile) {
70                        return None;
71                    }
72                }
73
74                // Only take supported extensions
75                if is_toml_extension(extension) {
76                    let profile = profile.to_string();
77                    Self::toml_from(path, Some(profile))
78                } else if is_yaml_extension(extension) {
79                    let profile = profile.to_string();
80                    Self::yaml_from(path, Some(profile))
81                } else {
82                    None
83                }
84            }
85            _ => None,
86        }
87    }
88
89    fn toml_from(path: PathBuf, profile: Option<impl Into<String>>) -> Option<Self> {
90        match profile {
91            None => Some(ConfigFile::GenericToml(path)),
92            Some(profile) => {
93                let profile = profile.into();
94
95                Some(ConfigFile::SpecificToml { path, profile })
96            }
97        }
98    }
99
100    fn yaml_from(path: PathBuf, profile: Option<impl Into<String>>) -> Option<Self> {
101        match profile {
102            None => Some(ConfigFile::GenericYaml(path)),
103            Some(profile) => {
104                let profile = profile.into();
105
106                Some(ConfigFile::SpecificYaml { path, profile })
107            }
108        }
109    }
110}
111
112impl ConfigFile {
113    /// Reports whether this [`ConfigFile`] is applicable regardless of the
114    /// [active](AppProfile::active) [`AppProfile`].
115    pub fn is_generic(&self) -> bool {
116        match *self {
117            Self::GenericToml(_) | Self::GenericYaml(_) => true,
118            Self::SpecificToml { .. } | Self::SpecificYaml { .. } => false,
119        }
120    }
121
122    /// Reports whether this [`ConfigFile`] is applicable only to a particular
123    /// [`AppProfile`].
124    pub fn is_specific(&self) -> bool {
125        !self.is_generic()
126    }
127
128    /// Returns a reference to the internally held [`PathBuf`].
129    pub fn path(&self) -> &PathBuf {
130        match *self {
131            Self::GenericToml(ref path) => path,
132            Self::GenericYaml(ref path) => path,
133            Self::SpecificToml { ref path, .. } => path,
134            Self::SpecificYaml { ref path, .. } => path,
135        }
136    }
137
138    /// Returns a reference to the internally held profile name (if this
139    /// variant is [specific](ConfigFile::is_specific)).
140    pub fn profile(&self) -> Option<&str> {
141        match *self {
142            Self::GenericToml(_) => None,
143            Self::GenericYaml(_) => None,
144            Self::SpecificToml { ref profile, .. } => Some(profile),
145            Self::SpecificYaml { ref profile, .. } => Some(profile),
146        }
147    }
148
149    /// Reports whether this [`ConfigFile`] [applies](ConfigFile::applies_to) to
150    /// the [active](AppProfile::active) [`AppProfile`].
151    pub fn applies_to_active_profile(&self) -> bool {
152        self.applies_to(AppProfile::active())
153    }
154
155    /// Reports whether this [`ConfigFile`] applies to the given [`AppProfile`].
156    ///
157    /// A generic config file (without a profile name in its file name) applies
158    /// to any profile by default. A specific config file (with a profile name
159    /// in its file name) applies to the given profile if the profile name
160    /// matches.
161    pub fn applies_to(&self, profile: impl AsRef<AppProfile>) -> bool {
162        let given_profile = profile.as_ref();
163
164        match *self {
165            Self::GenericToml(_) | Self::GenericYaml(_) => true,
166            Self::SpecificToml { ref profile, .. } | Self::SpecificYaml { ref profile, .. } => {
167                given_profile.is(profile)
168            }
169        }
170    }
171}
172
173impl PartialOrd for ConfigFile {
174    /// Delegates to the [`Ord`] implementation.
175    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
176        Some(self.cmp(other))
177    }
178}
179
180impl Ord for ConfigFile {
181    /// Implements the custom ordering rules for [`ConfigFile`].
182    ///
183    /// First rule is that generics always come before specifics (so that
184    /// specifics would override generics). After that, both subgroups are
185    /// ordered by their file path. For specific files, we order by the profile
186    /// name before we order by the path.
187    fn cmp(&self, other: &Self) -> Ordering {
188        // Are the given two generic?
189        let self_generic = self.is_generic();
190        let other_generic = other.is_generic();
191
192        // If we have a generic against specific, it’s an easy job
193        match (self_generic, other_generic) {
194            (true, false) => return Ordering::Less,
195            (false, true) => return Ordering::Greater,
196            _ => { /* either both generic or both specific */ }
197        }
198
199        // Extract path references
200        let self_path = self.path();
201        let other_path = other.path();
202
203        // If both are generic, it’s also an easy job
204        if self_generic {
205            return self_path.cmp(other_path);
206        }
207
208        // Both are profile-specific: extract profile names
209        let self_profile = self.profile();
210        let other_profile = other.profile();
211
212        // Unfortunately, profiles are optional, so check them first
213        match (self_profile, other_profile) {
214            // Both have profile name
215            (Some(self_profile_name), Some(other_profile_name)) => {
216                // Compare profile names first, then paths
217                match self_profile_name.cmp(other_profile_name) {
218                    Ordering::Equal => self_path.cmp(other_path),
219                    non_eq => non_eq,
220                }
221            }
222            // No profile names: just compare paths
223            _ => self_path.cmp(other_path),
224        }
225    }
226}
227
228/// Reports whether the given string slice is a recognized YAML extension.
229fn is_yaml_extension(ext: &str) -> bool {
230    ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml")
231}
232
233/// Reports whether the given string slice is a recognized TOML extension.
234fn is_toml_extension(ext: &str) -> bool {
235    ext.eq_ignore_ascii_case("toml")
236}
237
238impl ConfigFile {
239    /// Returns the corresponding [`FileFormat`].
240    fn format(&self) -> FileFormat {
241        match *self {
242            Self::GenericToml(_) | Self::SpecificToml { .. } => FileFormat::Toml,
243            Self::GenericYaml(_) | Self::SpecificYaml { .. } => FileFormat::Yaml,
244        }
245    }
246}
247
248impl From<ConfigFile> for PathBuf {
249    fn from(file: ConfigFile) -> Self {
250        match file {
251            ConfigFile::GenericToml(path) => path,
252            ConfigFile::GenericYaml(path) => path,
253            ConfigFile::SpecificToml { path, .. } => path,
254            ConfigFile::SpecificYaml { path, .. } => path,
255        }
256    }
257}
258
259impl From<ConfigFile> for File<FileSourceFile, FileFormat> {
260    fn from(file: ConfigFile) -> Self {
261        let format = file.format();
262        let path = PathBuf::from(file);
263
264        File::from(path).format(format)
265    }
266}