From 3f9f3335915d0632d888cb2c43524272375ce24c Mon Sep 17 00:00:00 2001 From: Sergio Benitez Date: Wed, 28 Aug 2024 13:30:10 -0700 Subject: [PATCH] Make file searching dynamically configurable. This commit extends the previous commit by also making source file searching dynamically configurable as opposed to forcing the choice when the data source is created. The `file_exact()` constructor is deprecated in favor of `file().search(false)`. --- src/error.rs | 7 -- src/providers/data.rs | 261 ++++++++++++++++++++++++------------------ 2 files changed, 151 insertions(+), 117 deletions(-) diff --git a/src/error.rs b/src/error.rs index 84395f5..75168e0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,7 +2,6 @@ use std::fmt::{self, Display}; use std::borrow::Cow; -use std::path::PathBuf; use serde::{ser, de}; @@ -129,9 +128,6 @@ pub enum Kind { /// [`serde::de::Error::duplicate_field()`]. DuplicateField(&'static str), - /// A file marked as required was not present. - MissingFile(PathBuf), - /// The `isize` was not in range of any known sized signed integer. ISizeOutOfRange(isize), /// The `usize` was not in range of any known sized unsigned integer. @@ -464,9 +460,6 @@ impl Display for Kind { Kind::DuplicateField(v) => { write!(f, "duplicate field `{}`", v) } - Kind::MissingFile(v) => { - write!(f, "missing file `{}`", v.to_string_lossy()) - } Kind::ISizeOutOfRange(v) => { write!(f, "signed integer `{}` is out of range", v) } diff --git a/src/providers/data.rs b/src/providers/data.rs index b46d872..d135de8 100644 --- a/src/providers/data.rs +++ b/src/providers/data.rs @@ -4,15 +4,8 @@ use std::path::{Path, PathBuf}; use serde::de::{self, DeserializeOwned}; use crate::value::{Map, Dict}; -use crate::error::Kind; use crate::{Error, Profile, Provider, Metadata}; - -#[derive(Debug, Clone)] -enum Source { - /// [`Ok`] if the file exists, [`Err`] if not. - File(Result), - String(String), -} +use crate::error::Kind; /// A `Provider` that sources values from a file or string in a given /// [`Format`]. @@ -53,9 +46,9 @@ enum Source { /// When nesting is _not_ specified, the source file or string is read and /// parsed, and the parsed dictionary is emitted into the profile /// configurable via [`Data::profile()`], which defaults to -/// [`Profile::Default`]. If the source is a file and the file is not -/// present, an empty dictionary is emitted, unless it was set to required -/// using [`Data::required()`]. +/// [`Profile::Default`]. If the source is a file path and the file is not +/// present, an empty dictionary is emitted unless [`Data::required()`] is +/// set to `true` in which case the provider fails. /// /// * **Data (Nested)** /// @@ -65,21 +58,21 @@ enum Source { #[derive(Debug, Clone)] pub struct Data { source: Source, - required: bool, /// The profile data will be emitted to if nesting is disabled. Defaults to /// [`Profile::Default`]. pub profile: Option, _format: PhantomData, } +#[derive(Debug, Clone)] +enum Source { + File { path: PathBuf, required: bool, search: bool, }, + String(String), +} + impl Data { - fn new(source: Source, profile: Option) -> Self { - Data { - source, - required: false, - profile, - _format: PhantomData, - } + fn new(profile: Option, source: Source) -> Self { + Data { source, profile, _format: PhantomData } } /// Returns a `Data` provider that sources its values by parsing the file at @@ -119,69 +112,11 @@ impl Data { /// }); /// ``` pub fn file>(path: P) -> Self { - fn find(path: &Path) -> Result { - if path.is_absolute() { - return match path.is_file() { - true => Ok(path.to_path_buf()), - false => Err(path.to_path_buf()), - }; - } - - let cwd = std::env::current_dir().map_err(|_| path.to_path_buf())?; - let mut cwd = cwd.as_path(); - loop { - let file_path = cwd.join(path); - if file_path.is_file() { - return Ok(file_path); - } - - cwd = cwd.parent().ok_or_else(|| path.to_path_buf())?; - } - } - - Data::new(Source::File(find(path.as_ref())), Some(Profile::Default)) - } - - /// Returns a `Data` provider that sources its values by parsing the file at - /// `path` as format `F`. If `path` is relative, it is located relative to - /// the current working directory. No other directories are searched. - /// - /// If you want to search parent directories for `path`, use - /// [`Data::file()`] instead. - /// - /// Nesting is disabled by default. Use [`Data::nested()`] to enable it. - /// - /// ```rust - /// use serde::Deserialize; - /// use figment::{Figment, Jail, providers::{Format, Toml}}; - /// - /// #[derive(Debug, PartialEq, Deserialize)] - /// struct Config { - /// foo: usize, - /// } - /// - /// Jail::expect_with(|jail| { - /// // Create 'subdir/config.toml' and set `cwd = subdir`. - /// jail.create_file("config.toml", "foo = 123")?; - /// jail.change_dir(jail.create_dir("subdir")?)?; - /// - /// // We are in `subdir`. `config.toml` is in `../`. `file()` finds it. - /// let config = Figment::from(Toml::file("config.toml")).extract::()?; - /// assert_eq!(config.foo, 123); - /// - /// // `file_exact()` doesn't search, so it doesn't find it. - /// let config = Figment::from(Toml::file_exact("config.toml")).extract::(); - /// assert!(config.is_err()); - /// Ok(()) - /// }); - /// ``` - pub fn file_exact>(path: P) -> Self { - let file = match path.as_ref().to_owned() { - path if path.exists() => Ok(path), - path => Err(path), - }; - - Data::new(Source::File(file), Some(Profile::Default)) + Data::new(Some(Profile::Default), Source::File { + path: path.as_ref().to_path_buf(), + required: false, + search: true, + }) } /// Returns a `Data` provider that sources its values by parsing the string @@ -214,7 +149,16 @@ impl Data { /// }); /// ``` pub fn string(string: &str) -> Self { - Data::new(Source::String(string.into()), Some(Profile::Default)) + Data::new(Some(Profile::Default), Source::String(string.into())) + } + + /// Deprecated alias for `Data::file(path).search(false)`. + /// + /// Use [`file(path).search(false)`](Data::search) instead. + #[doc(hidden)] + #[deprecated(since = "0.10.20", note = "use `::file(path).search(false)` instead")] + pub fn file_exact>(path: P) -> Self { + Data::file(path.as_ref()).search(false) } /// Enables nesting on `self`, which results in top-level keys of the @@ -267,15 +211,23 @@ impl Data { self } - /// Sets `self` to require its underlying source file to be present. - /// Does nothing for string sources. + /// Sets whether the source file is required to be present. The default is + /// `false`. + /// + /// When `false`, a non-existent file is treated as an empty source, that + /// is, it deserializes to an empty dictionary. When `true`, a non-existent + /// file causes an error. If the source is a string, this setting has no + /// effect. + /// + /// # Example /// /// ```rust - /// use figment::{Figment, Jail, providers::{Format, Toml}}; /// use serde::Deserialize; + /// use figment::{Figment, Jail, providers::{Format, Toml}}; /// /// #[derive(Debug, PartialEq, Deserialize)] /// struct Config { + /// #[serde(default)] /// foo: usize, /// } /// @@ -283,23 +235,78 @@ impl Data { /// // Create 'config.toml'. /// jail.create_file("config.toml", "foo = 123")?; /// - /// // The default behaviour is to permit missing files. + /// // By default, missing files are treated as empty. This implies + /// // `foo` is not set, so it defaults to `0` via `serde(default)`. + /// let config = Figment::from(Toml::file("missing.toml")).extract::()?; + /// assert_eq!(config.foo, 0); + /// + /// // Set `required` to true to disallow missing files. + /// let source = Toml::file("missing.toml"); + /// let config = Figment::from(source.required(true)).extract::(); + /// assert!(config.is_err()); + /// + /// // Set `required` to false to explicitly allow missing files. + /// # let source = Toml::file("missing.toml").required(true); + /// let config = Figment::from(source.required(false)).extract::()?; + /// assert_eq!(config.foo, 0); + /// + /// // The setting has no effect when the file is present. /// let config = Figment::from(Toml::file("config.toml")).extract::()?; /// assert_eq!(config.foo, 123); /// - /// // Setting the file to required works because it is present. - /// let config = Figment::from(Toml::file("config.toml").required(true)).extract::()?; + /// Ok(()) + /// }); + /// ``` + pub fn required(mut self, yes: bool) -> Self { + if let Source::File { required, .. } = &mut self.source { + *required = yes; + } + + self + } + + /// Set whether to enable recursively searching in parent directories for + /// the source file path. The default is `true`. + /// + /// When `true`, the search is enabled. When `false` or when the file path + /// is absolute, no search is performed and only the exact file path is + /// used. If the source is a string, this setting has no effect. + /// + /// # Example + /// + /// ```rust + /// use serde::Deserialize; + /// use figment::{Figment, Jail, providers::{Format, Toml}}; + /// + /// #[derive(Debug, PartialEq, Deserialize)] + /// struct Config { + /// foo: usize, + /// } + /// + /// Jail::expect_with(|jail| { + /// // Create 'subdir/config.toml' and set `cwd = subdir`. + /// jail.create_file("config.toml", "foo = 123")?; + /// jail.change_dir(jail.create_dir("subdir")?)?; + /// + /// // We are in `subdir`. `config.toml` is in `../`. Since `search` is + /// // enabled by default, the file is found in the parent directory. + /// let source = Toml::file("config.toml"); + /// let config = Figment::from(source).extract::()?; /// assert_eq!(config.foo, 123); /// - /// // Setting a missing file to required causes extract to error. - /// let config = Figment::from(Toml::file("doesntexist.toml").required(true)).extract::(); + /// // Set `search` to false to disable searching. + /// let source = Toml::file("config.toml").search(false); + /// let config = Figment::from(source).extract::(); /// assert!(config.is_err()); /// /// Ok(()) /// }); /// ``` - pub fn required(mut self, required: bool) -> Self { - self.required = required; + pub fn search(mut self, enabled: bool) -> Self { + if let Source::File { search, .. } = &mut self.source { + *search = enabled; + } + self } @@ -324,26 +331,58 @@ impl Data { self.profile = Some(profile.into()); self } + + /// Resolves `path` to a valid file path or returns `None`. If `search` is + /// `true` and `path` is not absolute, searches the current working + /// directory and all parent directories until the root and return the first + /// valid file path. Otherwise returns `path` if it points to a valid file. + fn resolve(path: &Path, search: bool) -> Option { + if path.is_absolute() || !search { + return path.is_file().then(|| path.to_path_buf()); + } + + let cwd = std::env::current_dir().ok()?; + let mut cwd = cwd.as_path(); + loop { + let file_path = cwd.join(path); + if file_path.is_file() { + return Some(file_path.into()); + } + + cwd = cwd.parent()?; + } + } } impl Provider for Data { fn metadata(&self) -> Metadata { - use Source as S; + use Source::*; match &self.source { - S::String(_) => Metadata::named(format!("{} source string", F::NAME)), - S::File(Err(_)) => Metadata::named(format!("{} file", F::NAME)), - S::File(Ok(p)) => Metadata::from(format!("{} file", F::NAME), &**p), + String(_) => Metadata::named(format!("{} source string", F::NAME)), + File { path, search, required: _ } => { + let path = Self::resolve(path, *search).unwrap_or_else(|| path.clone()); + Metadata::from(format!("{} file", F::NAME), path.as_path()) + } } } fn data(&self) -> Result, Error> { use Source as S; let map: Result, _> = match (&self.source, &self.profile) { - (S::File(Err(path)), _) if self.required => return Err(Kind::MissingFile(path.clone()).into()), - (S::File(Err(_)), _) => return Ok(Map::new()), - (S::File(Ok(path)), None) => F::from_path(path), + (S::File { path, required, search }, profile) => { + match Self::resolve(path, *search) { + Some(path) => match profile { + Some(prof) => F::from_path(&path).map(|v| prof.collect(v)), + None => F::from_path(&path), + }, + None if !required => Ok(Map::new()), + None => { + let msg = format!("required file `{}` not found", path.display()); + return Err(Kind::Message(msg).into()); + } + } + }, (S::String(s), None) => F::from_str(s), - (S::File(Ok(path)), Some(prof)) => F::from_path(path).map(|v| prof.collect(v)), (S::String(s), Some(prof)) => F::from_str(s).map(|v| prof.collect(v)), }; @@ -388,8 +427,8 @@ impl Provider for Data { /// /// 2. [`Format::from_str()`]: This is the core string deserialization method. /// A typical implementation will simply call an existing method like -/// [`toml::from_str`]. For writing a custom data format, see [serde's -/// writing a data format guide]. +/// [`toml_edit::de::from_str`]. For writing a custom data format, see +/// [serde's writing a data format guide]. /// /// The default implementations for [`Format::from_path()`], [`Format::file()`], /// and [`Format::string()`] methods should likely not be overwritten. @@ -410,13 +449,6 @@ pub trait Format: Sized { Data::file(path) } - /// Returns a `Data` provider that sources its values by parsing the file at - /// `path` as format `Self`. See [`Data::file_exact()`] for more details. The - /// default implementation calls `Data::file_exact(path)`. - fn file_exact>(path: P) -> Data { - Data::file_exact(path) - } - /// Returns a `Data` provider that sources its values by parsing `string` as /// format `Self`. See [`Data::string()`] for more details. The default /// implementation calls `Data::string(string)`. @@ -424,6 +456,15 @@ pub trait Format: Sized { Data::string(string) } + /// Deprecated alias for `file(path).search(false)`. + /// + /// Use [`file(path).search(false)`](Data::search) instead. + #[doc(hidden)] + #[deprecated(since = "0.10.20", note = "use `::file(path).search(false)` instead")] + fn file_exact>(path: P) -> Data { + Data::file(path.as_ref()).search(false) + } + /// Parses `string` as the data format `Self` as a `T` or returns an error /// if the `string` is an invalid `T`. **_Note:_** This method is _not_ /// intended to be called directly. Instead, it is intended to be