1use crate::{
52 entity::ConfigurationEntity,
53 loader::{self, Error, Loader, SoftErrors},
54};
55use anyhow::anyhow;
56use cfg_if::cfg_if;
57use serde::Deserialize;
58use std::fmt::{Display, Formatter};
59use std::{
60 collections::HashMap,
61 env::current_dir,
62 fmt::Debug,
63 fs, io,
64 path::{Path, PathBuf},
65};
66use url::Url;
67
68pub const NAME: &str = "File";
69pub const SCHEME_LIST: &[&str] = &["fs", "file"];
70
71#[derive(Default, Clone, Debug)]
73pub struct Fs {
74 options: FsOptions,
75}
76
77#[derive(Debug, Clone, Default, Deserialize)]
78#[serde(default, rename_all = "kebab-case")]
79pub struct FsOptions {
80 strip_slash: Option<bool>,
81 soft_errors: SoftErrors<SoftErrorsFs>,
82}
83
84impl FsOptions {
85 pub fn contains(&self, error: io::ErrorKind) -> bool {
86 SoftErrorsFs::try_from(error)
87 .map(|error| self.soft_errors.contains(&error))
88 .unwrap_or_default()
89 }
90}
91
92#[derive(Debug, Clone, Copy, Deserialize, PartialEq)]
94#[serde(rename_all = "kebab-case")]
95pub enum SoftErrorsFs {
96 NotFound,
97 PermissionDenied,
98}
99
100impl TryFrom<io::ErrorKind> for SoftErrorsFs {
101 type Error = String;
102
103 fn try_from(value: io::ErrorKind) -> Result<Self, Self::Error> {
104 match value {
105 io::ErrorKind::NotFound => Ok(Self::NotFound),
106 io::ErrorKind::PermissionDenied => Ok(Self::PermissionDenied),
107 _ => Err("Unhandled IO error".into()),
108 }
109 }
110}
111
112#[doc(hidden)]
113impl Fs {
114 #[inline]
115 pub fn get_plugin_name_and_format<P: AsRef<Path>>(path: P) -> Option<(String, String)> {
116 Self::get_plugin_name(&path)
117 .and_then(|name| Self::get_format(&path).map(|format| (name, format)))
118 }
119
120 #[inline]
121 pub fn get_plugin_name<P: AsRef<Path>>(path: P) -> Option<String> {
122 path.as_ref()
123 .file_stem()
124 .and_then(|name| name.to_str())
125 .map(|name| name.to_lowercase())
126 .and_then(|name| if name.is_empty() { None } else { Some(name) })
127 }
128
129 #[inline]
130 pub fn get_format<P: AsRef<Path>>(path: P) -> Option<String> {
131 path.as_ref()
132 .extension()
133 .and_then(|format| format.to_str())
134 .map(|format| format.to_lowercase())
135 .and_then(|format| {
136 if format.is_empty() {
137 None
138 } else {
139 Some(format)
140 }
141 })
142 }
143
144 #[inline]
145 pub(super) fn get_entity_list(
146 url: &Url,
147 options: &FsOptions,
148 maybe_whitelist: Option<&[String]>,
149 skip_soft_errors: bool,
150 ) -> Result<Vec<ConfigurationEntity>, Error> {
151 let path = Self::url_to_path(url, options)
152 .map_err(|_| Error::Other(anyhow!("Could not detect current working directory")))?;
153 if path.is_dir() {
154 let list = match Self::get_directory_file_list(&path, maybe_whitelist) {
155 Ok(list) => list,
156 Err(error) => {
157 return if skip_soft_errors
158 && (options.soft_errors.skip_all() || options.contains(error.kind()))
159 {
160 cfg_if! {
161 if #[cfg(feature = "tracing")] {
162 tracing::info!(path=?path, skip_error=true, "Could not load directory contents");
163 } else if #[cfg(feature = "logging")] {
164 log::info!("msg=\"Could not load directory contents\" path={path:?} skip_error=true");
165 }
166 }
167 Ok(Vec::new())
168 } else {
169 Err(Error::Load {
170 loader: NAME.to_string(),
171 url: url.clone(),
172 description: "load directory file list".to_string().into(),
173 source: error.into(),
174 })
175 }
176 }
177 };
178 let mut plugins: HashMap<&String, &String> = HashMap::with_capacity(list.len());
179 for (plugin_name, format, _) in list.iter() {
180 if let Some(other_format) = plugins.get(plugin_name) {
181 let mut url = url.clone();
182 url.set_query(None);
183 return Err(Error::Duplicate {
184 loader: NAME.to_string().into(),
185 url,
186 plugin: plugin_name.to_string().into(),
187 format_1: other_format.to_string().into(),
188 format_2: format.to_string().into(),
189 });
190 } else {
191 plugins.insert(plugin_name, format);
192 }
193 }
194 Ok(list
195 .into_iter()
196 .map(|(plugin_name, format, path)| {
197 ConfigurationEntity::new(path.to_str().unwrap(), url.clone(), plugin_name, NAME)
198 .with_format(format)
199 })
200 .collect())
201 } else if path.is_file() {
202 if let Some((plugin_name, format)) = Self::get_plugin_name_and_format(&path) {
203 if maybe_whitelist
204 .map(|whitelist| whitelist.contains(&plugin_name))
205 .unwrap_or(true)
206 {
207 let entity = ConfigurationEntity::new(
208 path.to_str().unwrap(),
209 url.clone(),
210 plugin_name,
211 NAME,
212 )
213 .with_format(format);
214 Ok([entity].into())
215 } else {
216 Ok(Vec::new())
217 }
218 } else if skip_soft_errors && options.soft_errors.skip_all() {
219 cfg_if! {
220 if #[cfg(feature = "tracing")] {
221 tracing::info!(url=%url, skip_error=true, "Could not parse plugin name/format");
222 } else if #[cfg(feature = "logging")] {
223 log::info!(
224 "msg=\"Could not parse plugin name/format\" url={:?} skip_error=true",
225 url.to_string()
226 );
227 }
228 }
229 Ok(Vec::new())
230 } else {
231 Err(Error::InvalidUrl {
232 loader: NAME.to_string(),
233 url: url.to_string(),
234 source: anyhow!("Could not parse plugin name/format"),
235 })
236 }
237 } else if path.exists() {
238 if skip_soft_errors && options.soft_errors.skip_all() {
239 cfg_if! {
240 if #[cfg(feature = "tracing")] {
241 tracing::info!(url=%url, skip_error=true, "URL is not pointing to a directory or regular file");
242 } else if #[cfg(feature = "logging")] {
243 log::info!(
244 "msg=\"URL is not pointing to a directory or regular file\" url={:?} skip_error=true",
245 url.to_string()
246 );
247 }
248 }
249 Ok(Vec::new())
250 } else {
251 Err(Error::InvalidUrl {
252 loader: NAME.to_string(),
253 url: url.to_string(),
254 source: anyhow!("URL is not pointing to a directory or regular file"),
255 })
256 }
257 } else if skip_soft_errors && options.contains(io::ErrorKind::NotFound) {
258 cfg_if! {
259 if #[cfg(feature = "tracing")] {
260 tracing::info!(url=%url, skip_error=true, "Could not find path");
261 } else if #[cfg(feature = "logging")] {
262 log::info!(
263 "msg=\"Could not find path\" url={:?} skip_error=true",
264 url.to_string()
265 );
266 }
267 }
268 Ok(Vec::new())
269 } else {
270 Err(Error::NotFound {
271 loader: NAME.to_string(),
272 url: url.clone(),
273 item: format!("path `{path:?}`").into(),
274 })
275 }
276 }
277
278 #[inline]
279 pub fn get_directory_file_list<P: AsRef<Path>>(
280 path: P,
281 maybe_whitelist: Option<&[String]>,
282 ) -> Result<Vec<(String, String, PathBuf)>, io::Error> {
283 Ok(fs::read_dir(path)?
284 .filter_map(|maybe_entry| maybe_entry.ok())
285 .map(|entry| entry.path())
286 .filter_map(|path| {
287 if let Some((plugin_name, format)) = Self::get_plugin_name_and_format(&path) {
288 cfg_if! {
289 if #[cfg(feature = "tracing")] {
290 tracing::trace!(plugin=plugin_name, path=?path, "Detected configuration file");
291 } else if #[cfg(feature = "logging")] {
292 log::trace!("msg=\"Detected configuration file\" plugin={plugin_name:?} path={path:?}");
293 }
294 }
295 Some((plugin_name, format, path))
296 } else {
297 cfg_if! {
298 if #[cfg(feature = "tracing")] {
299 tracing::warn!(path=?path, "Could not parse plugin name/format");
300 } else if #[cfg(feature = "logging")] {
301 log::warn!("msg=\"Could not parse plugin name/format\" path={path:?}");
302 }
303 }
304 None
305 }
306 })
307 .filter(|(plugin_name, _, _)| {
308 maybe_whitelist
309 .map(|whitelist| whitelist.contains(plugin_name))
310 .unwrap_or(true)
311 })
312 .filter_map(|(plugin_name, format, path)| {
313 if path.is_file() {
314 Some((plugin_name, format, path))
315 } else {
316 cfg_if! {
317 if #[cfg(feature = "tracing")] {
318 tracing::warn!(path=?path, "Path is not pointing to a regular file");
319 } else if #[cfg(feature = "logging")] {
320 log::warn!("msg=\"Path is not pointing to a regular file\" path={path:?}");
321 }
322 }
323 None
324 }
325 })
326 .collect())
327 }
328
329 #[inline]
330 pub fn read_entity_contents(entity: &mut ConfigurationEntity) -> Result<(), io::Error> {
331 fs::read_to_string(entity.item()).map(|contents| {
332 entity.set_contents(contents);
333 })
334 }
335
336 #[inline]
337 pub fn url_to_path(url: &Url, options: &FsOptions) -> Result<PathBuf, io::Error> {
338 cfg_if! {
339 if #[cfg(target_os="windows")] {
340 let is_windows = true;
341 } else {
342 let is_windows = false;
343 }
344 }
345 let url_path = if url.path() == "/" || url.path().is_empty() {
346 let cwd = current_dir()?;
347 cfg_if! {
348 if #[cfg(feature = "tracing")] {
349 tracing::debug!(url=%url, cwd=?cwd,"set current working directory");
350 } else if #[cfg(feature = "logging")] {
351 log::debug!("msg=\"set current working directory\" url=\"{url}\", cwd={cwd:?}");
352 }
353 }
354 cwd
355 } else if (options.strip_slash.unwrap_or(false) || is_windows)
356 && url.path().starts_with('/')
357 {
358 PathBuf::from(
359 url.path()
360 .strip_prefix('/')
361 .expect("URL path with length > 1"),
362 )
363 } else {
364 PathBuf::from(url.path())
365 };
366 let mut path = PathBuf::new();
367 url_path.components().for_each(|component| {
368 path = path.join(component);
369 });
370 cfg_if! {
371 if #[cfg(feature = "tracing")] {
372 tracing::trace!(url_path=?url_path, os_path=?path,"Changed URL path to OS path");
373 } else if #[cfg(feature = "logging")] {
374 log::trace!("msg=\"Changed URL path to OS path\" url_path={url_path:?}, os_path={path:?}");
375 }
376 }
377 Ok(path)
378 }
379}
380
381impl Fs {
382 pub fn new() -> Self {
383 Default::default()
384 }
385
386 pub fn add_soft_error(&mut self, error: SoftErrorsFs) {
387 self.options.soft_errors.add_soft_error(error)
388 }
389
390 pub fn with_soft_error(mut self, error: SoftErrorsFs) -> Self {
391 self.add_soft_error(error);
392 self
393 }
394
395 fn get_options(&self, url: &Url) -> Result<FsOptions, Error> {
396 loader::deserialize_query_string::<FsOptions>(NAME, url).map(|mut options| {
397 if let Some(soft_errors) = self.options.soft_errors.maybe_soft_error_list() {
398 soft_errors
399 .iter()
400 .for_each(|soft_error| options.soft_errors.add_soft_error(*soft_error))
401 }
402 options
403 })
404 }
405}
406
407impl Display for Fs {
408 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
409 f.write_str(NAME)
410 }
411}
412
413impl Loader for Fs {
414 fn scheme_list(&self) -> Vec<String> {
416 SCHEME_LIST.iter().cloned().map(String::from).collect()
417 }
418
419 fn load(
420 &self,
421 url: &Url,
422 maybe_whitelist: Option<&[String]>,
423 skip_soft_errors: bool,
424 ) -> Result<Vec<(String, ConfigurationEntity)>, Error> {
425 let options = self.get_options(url)?;
426 let mut entity_list =
427 Self::get_entity_list(url, &options, maybe_whitelist, skip_soft_errors)?;
428 entity_list.iter_mut().try_for_each(|entity| {
429 match Self::read_entity_contents(entity) {
430 Ok(_) => {
431 cfg_if! {
432 if #[cfg(feature = "tracing")] {
433 tracing::trace!(
434 url=%entity.url(),
435 contents=entity
436 .maybe_contents()
437 .expect("Contents is set inside `utils::read_entity_contents`"),
438 "Read configuration file"
439 );
440 } else if #[cfg(feature = "logging")] {
441 log::trace!(
442 "msg=\"Read configuration file\" url={:?} contents={:?}",
443 entity.url().to_string(),
444 entity.maybe_contents().expect("Contents is set inside `utils::read_entity_contents`")
445 );
446 }
447 }
448 Ok(())
449 },
450 Err(error) => {
451 if skip_soft_errors && (options.soft_errors.skip_all() || options.contains(error.kind())) {
452 cfg_if! {
453 if #[cfg(feature = "tracing")] {
454 tracing::info!(
455 path=entity.url().path(),
456 skip_error=true,
457 "Could not read contents of file"
458 );
459 } else if #[cfg(feature = "logging")] {
460 log::info!(
461 "msg=\"Could not read contents of file\" path={:?} skip_error=true",
462 entity.url().path()
463 );
464 }
465 }
466 Ok(())
467 } else {
468 Err(Error::Load {
469 loader: NAME.to_string(),
470 url: entity.url().clone(),
471 description: "read contents of file".to_string().into(),
472 source: error.into(),
473 })
474 }
475 }
476 }
477 })?;
478 let result = entity_list
479 .into_iter()
480 .filter(|entity| entity.maybe_contents().is_some())
482 .map(|entity| (entity.plugin_name().clone(), entity))
483 .collect();
484 Ok(result)
485 }
486}