From e6b2f89f93bd4c92b05a55121324a9b45beba231 Mon Sep 17 00:00:00 2001 From: fluxdiv <156196590+fluxdiv@users.noreply.github.com> Date: Thu, 16 Oct 2025 19:13:33 -0400 Subject: [PATCH] feat: provide access to underlying generic field values --- README.md | 90 ++++++++++++++++++-- bin_test/src/usage_tests.rs | 163 ++++++++++++++++++++++++++++++++++++ bin_test/src/utils.rs | 123 +++++++++++++++++++++++++++ quickfig/src/lib.rs | 90 ++++++++++++++++++-- quickfig_core/src/config.rs | 13 +++ quickfig_core/src/field.rs | 26 ++++-- 6 files changed, 486 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3d37221..a37d859 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ let path_to_config: PathBuf = { }; ``` +--- + * List of get methods available on Vec: * **NOTE**: Any numbers outside of `i64` range will error on TOML files as TOML spec does not support them @@ -110,10 +112,8 @@ let path_to_config: PathBuf = { let config = Config::::open("/path/to/config.json").unwrap(); let field = config.get(MyFields::SomeField).unwrap(); - let f: Option = field.get_string(); - let f: Option = field.get_char(); - let f: Option = field.get_bool(); - let f: Option = field.get_u8(); + // If you need the underlying Value for custom deserialization + let f: Option<&serde_json::Value> = field.get_generic_inner(); let f: Option = field.get_string(); let f: Option = field.get_char(); @@ -132,10 +132,86 @@ let path_to_config: PathBuf = { let f: Option = field.get_f64(); ``` -* Sometimes you may not know the exact path to a user's config file, but - you instead inform your user that it must in a list of possible locations. +--- + +* Sometimes a config's field isn't a basic type like String or u8. + + In these cases, instead of using `field.get_u8()` etc., you can use + `field.get_generic_inner()` to access the field value directly. + + If the key requested is present, Quickfig will get you a reference + to its field (as `&Value`) which you can then deserialize as needed. + + Ex: You expect a config to have "colors" & "fonts" keys, and you + open a `config.json` with this content: +```json + { + "colors": { + "primary": "blue", + "accents": ["purple", "cyan"], + "filter": { + "brightness": 7, + "inverted": false + } + }, + "fonts": [ + { "size": 1, "name": "roboto" }, + { "size": 2, "name": "verdana" } + ] + } +``` + +In your application: +```rust +// Fields you expect to be in the config +#[derive(ConfigFields)] +enum AppConfig { + #[keys("colors")] + Colors, + #[keys("fonts")] + Fonts +} + +// Types for your expected config structure +#[derive(serde::Deserialize)] +struct Colors { + primary: String, + accents: Vec, + filter: Filter +} +#[derive(serde::Deserialize)] +struct Filter { + brightness: u8, + inverted: bool +} +#[derive(serde::Deserialize)] +struct Fonts(Vec); +#[derive(serde::Deserialize)] +struct Font { + size: u8, + name: String +} + +// Opening the config.json file +let config = Config::::open("/path/to/config.json").unwrap(); +// Access "colors" key & verify only 1 match +let colors_field = config.get(AppConfig::Colors).unwrap(); +colors_field.only_one_key().unwrap(); + +// Get the underlying value without trying to parse it +let colors_inner: &serde_json::Value = colors_field + .get_generic_inner() + .unwrap(); + +// Deserialize it yourself +let colors: Colors = Colors::deserialize(colors_inner).unwrap(); +``` + +--- + +* Sometimes you want to allow multiple possible paths for a user's config. - For example, your docs may state: + For example, your docs might say: ```txt MyApp will first check for your config at "~/.config/MyApp/config.json", then "~/.MyApp/config.json", then "~/.local/share/MyApp/config.json"... diff --git a/bin_test/src/usage_tests.rs b/bin_test/src/usage_tests.rs index a97b79f..e0810c6 100644 --- a/bin_test/src/usage_tests.rs +++ b/bin_test/src/usage_tests.rs @@ -14,6 +14,8 @@ use super::utils::*; use super::utils::TestFileType as TFT; // MODS +// generics : testing generic types +// with custom deserialization // // json_main : testing JSON configs // toml_main : testing TOML configs @@ -21,6 +23,167 @@ use super::utils::TestFileType as TFT; // misc_tests_json : overlapping keys, // misc_tests_toml : overlapping keys, + +#[cfg(test)] +mod generics { + use anyhow::Result; + use quickfig::core::{ + config_types::{ JSON, TOML }, + VecField, + Field, + Config, + GetInner, + }; + use quickfig::derive::ConfigFields; + use super::super::utils::*; + use super::super::utils::TestFileType as TFT; + use serde::Deserialize; + + /// Top level structure + #[derive(Debug, Deserialize)] + pub struct Root { + pub courses: Vec, + pub contact: Contact, + } + + /// Each item in the courses array + #[derive(Debug, Deserialize)] + pub struct Course { + pub title: String, + pub credits: u32, + pub details: Option
, + } + + /// Wrapper around coursrs array + #[derive(Debug, Deserialize)] + pub struct Courses(Vec); + + /// Nested object under details + #[derive(Debug, Deserialize)] + pub struct Details { + pub room_number: u32, + pub teacher: String, + pub keywords: Vec, + } + + /// Contact info at the top level + #[derive(Debug, Deserialize)] + pub struct Contact { + pub email: String, + // JSON uses null, TOML uses "" + pub phone: Option, + } + + #[allow(non_camel_case_types)] + #[derive(ConfigFields)] + pub enum GenericTestEnum { + #[keys("courses")] + Courses, + #[keys("contact")] + Contact, + #[keys("not_there")] + NotThere, + } + + #[cfg(test)] + mod json_generics { + use super::*; + const TEST_FILE_TYPE: TestFileType = TFT::JSON; + + #[test] + fn test_generic() { + let mut testfile = TestFile::new(TEST_FILE_TYPE).unwrap(); + testfile.add_all_generic_entries(TEST_FILE_TYPE).unwrap(); + let config = Config::::open(testfile.get_path()).unwrap(); + testfile.delete().unwrap(); + + // Courses + let courses = config.get(GenericTestEnum::Courses).unwrap(); + courses.only_one_key().unwrap(); + // Should be the array of courses + let courses_inner = courses.get_generic_inner().unwrap(); + let courses_de = Courses::deserialize(courses_inner).unwrap(); + let c_vec = courses_de.0; + assert!(c_vec.len() == 2); + let history = &c_vec[0]; + assert_eq!(history.title, "History 101"); + assert_eq!(history.credits, 3); + assert!(history.details.is_some()); + let details = history.details.as_ref().unwrap(); + assert_eq!(details.room_number, 413); + assert_eq!(details.teacher, "Lopez"); + assert_eq!(details.keywords, vec!["US", "History", "Introduction"]); + let math = &c_vec[1]; + assert_eq!(math.title, "Mathematics 201"); + assert_eq!(math.credits, 4); + assert!(math.details.is_none()); + + // Contact + let contact = config.get(GenericTestEnum::Contact).unwrap(); + contact.only_one_key().unwrap(); + // Should deserialize into contact + let contact_inner = contact.get_generic_inner().unwrap(); + let contact_de = Contact::deserialize(contact_inner).unwrap(); + assert_eq!(contact_de.email, String::from("john.smith@example.com")); + assert_eq!(contact_de.phone, None); + + // Not there + let e = config.get(GenericTestEnum::NotThere); + assert!(e.is_none()); + } + } + + #[cfg(test)] + mod toml_generics { + use super::*; + const TEST_FILE_TYPE: TestFileType = TFT::TOML; + + #[test] + fn test_generic() { + let mut testfile = TestFile::new(TEST_FILE_TYPE).unwrap(); + testfile.add_all_generic_entries(TEST_FILE_TYPE).unwrap(); + let config = Config::::open(testfile.get_path()).unwrap(); + testfile.delete().unwrap(); + + // Courses + let courses = config.get(GenericTestEnum::Courses).unwrap(); + courses.only_one_key().unwrap(); + // Should be the array of courses + let courses_inner = courses.get_generic_inner().unwrap(); + let courses_de = Courses::deserialize(courses_inner.clone()).unwrap(); + let c_vec = courses_de.0; + assert!(c_vec.len() == 2); + let history = &c_vec[0]; + assert_eq!(history.title, "History 101"); + assert_eq!(history.credits, 3); + assert!(history.details.is_some()); + let details = history.details.as_ref().unwrap(); + assert_eq!(details.room_number, 413); + assert_eq!(details.teacher, "Lopez"); + assert_eq!(details.keywords, vec!["US", "History", "Introduction"]); + let math = &c_vec[1]; + assert_eq!(math.title, "Mathematics 201"); + assert_eq!(math.credits, 4); + assert!(math.details.is_none()); + + // Contact + let contact = config.get(GenericTestEnum::Contact).unwrap(); + contact.only_one_key().unwrap(); + // Should deserialize into contact + let contact_inner = contact.get_generic_inner().unwrap(); + let contact_de = Contact::deserialize(contact_inner.clone()).unwrap(); + assert_eq!(contact_de.email, String::from("john.smith@example.com")); + // toml uses empty strings not null + assert!(contact_de.phone.is_some_and(|x| x.is_empty())); + + // Not there + let e = config.get(GenericTestEnum::NotThere); + assert!(e.is_none()); + } + } + +} + #[allow(non_camel_case_types)] #[derive(ConfigFields)] pub enum TestEnum { diff --git a/bin_test/src/utils.rs b/bin_test/src/utils.rs index 27a6cc5..fba99a1 100644 --- a/bin_test/src/utils.rs +++ b/bin_test/src/utils.rs @@ -315,4 +315,127 @@ impl TestFile { Ok(()) } + + /// Adds following structure: + /// ```json + /// { + /// "courses": [ + /// { + /// "title": "History 101", + /// "credits": 3, + /// "details": { + /// "room_number": 413, + /// "teacher": "Lopez", + /// "keywords": ["US", "History", "Introduction"] + /// } + /// }, + /// { + /// "title": "Mathematics 201", + /// "credits": 4 + /// } + /// ], + /// "contact": { + /// "email": "john.smith@example.com", + /// "phone": null + /// } + /// } + /// + /// ``` + /// or if toml: + /// ```toml + /// [[courses]] + /// title = "History 101" + /// credits = 3 + /// [courses.details] + /// room_number = 413 + /// teacher = "Lopez" + /// keywords = ["US", "History", "Introduction"] + /// + /// [[courses]] + /// title = "Mathematics 201" + /// credits = 4 + /// + /// [contact] + /// email = "john.smith@example.com" + /// phone = "" + /// ``` + pub fn add_all_generic_entries( + &mut self, + tft: TestFileType + ) -> Result<(), FileError> { + + match tft { + TestFileType::JSON => { + // Build JSON structure using serde_json::json! + let json_value = serde_json::json!({ + "courses": [ + { + "title": "History 101", + "credits": 3, + "details": { + "room_number": 413, + "teacher": "Lopez", + "keywords": ["US", "History", "Introduction"] + } + }, + { + "title": "Mathematics 201", + "credits": 4 + } + ], + "contact": { + "email": "john.smith@example.com", + "phone": null + } + }); + + let mut file = File::create(&self.path).map_err(FileError::IoError)?; + serde_json::to_writer_pretty(&mut file, &json_value).map_err(FileError::SerdeError)?; + } + TestFileType::TOML => { + // Build TOML structure manually using toml::value::Value + use toml::value::{Value as TomlValue, Table}; + + let mut courses = Vec::new(); + + let mut history = Table::new(); + history.insert("title".into(), TomlValue::String("History 101".into())); + history.insert("credits".into(), TomlValue::Integer(3)); + + let mut history_details = Table::new(); + history_details.insert("room_number".into(), TomlValue::Integer(413)); + history_details.insert("teacher".into(), TomlValue::String("Lopez".into())); + history_details.insert( + "keywords".into(), + TomlValue::Array(vec![ + TomlValue::String("US".into()), + TomlValue::String("History".into()), + TomlValue::String("Introduction".into()), + ]), + ); + history.insert("details".into(), TomlValue::Table(history_details)); + courses.push(TomlValue::Table(history)); + + let mut math = Table::new(); + math.insert("title".into(), TomlValue::String("Mathematics 201".into())); + math.insert("credits".into(), TomlValue::Integer(4)); + courses.push(TomlValue::Table(math)); + + let mut contact = Table::new(); + contact.insert("email".into(), TomlValue::String("john.smith@example.com".into())); + contact.insert("phone".into(), TomlValue::String("".into())); // TOML has no null + + let mut root = Table::new(); + root.insert("courses".into(), TomlValue::Array(courses)); + root.insert("contact".into(), TomlValue::Table(contact)); + + let toml_str = toml::ser::to_string_pretty(&TomlValue::Table(root)) + .map_err(|e| FileError::TomlError(e.to_string()))?; + let mut file = File::create(&self.path).map_err(FileError::IoError)?; + file.write_all(toml_str.as_bytes()).map_err(FileError::IoError)?; + } + } + + Ok(()) + } } diff --git a/quickfig/src/lib.rs b/quickfig/src/lib.rs index 1fb922f..f055e3d 100644 --- a/quickfig/src/lib.rs +++ b/quickfig/src/lib.rs @@ -104,6 +104,8 @@ //! }; //! ``` //! +//! --- +//! //! * List of get methods available on Vec: //! * **NOTE**: Any numbers outside of `i64` range will //! error on TOML files as TOML spec does not support them @@ -111,10 +113,8 @@ //! let config = Config::::open("/path/to/config.json").unwrap(); //! let field = config.get(MyFields::SomeField).unwrap(); //! -//! let f: Option = field.get_string(); -//! let f: Option = field.get_char(); -//! let f: Option = field.get_bool(); -//! let f: Option = field.get_u8(); +//! // If you need the underlying Value for custom deserialization +//! let f: Option<&serde_json::Value> = field.get_generic_inner(); //! //! let f: Option = field.get_string(); //! let f: Option = field.get_char(); @@ -132,11 +132,87 @@ //! let f: Option = field.get_f32(); //! let f: Option = field.get_f64(); //! ``` +//! +//! --- +//! +//! * Sometimes a config's field isn't a basic type like String or u8. +//! +//! In these cases, instead of using `field.get_u8()` etc., you can use +//! `field.get_generic_inner()` to access the field value directly. +//! +//! If the key requested is present, Quickfig will get you a reference +//! to its field (as `&Value`) which you can then deserialize as needed. +//! +//! Ex: You expect a config to have "colors" & "fonts" keys, and you +//! open a `config.json` with this content: +//! ```json +//! { +//! "colors": { +//! "primary": "blue", +//! "accents": ["purple", "cyan"], +//! "filter": { +//! "brightness": 7, +//! "inverted": false +//! } +//! }, +//! "fonts": [ +//! { "size": 1, "name": "roboto" }, +//! { "size": 2, "name": "verdana" } +//! ] +//! } +//! ``` +//! +//! In your application: +//! ```rust,ignore +//! // Fields you expect to be in the config +//! #[derive(ConfigFields)] +//! enum AppConfig { +//! #[keys("colors")] +//! Colors, +//! #[keys("fonts")] +//! Fonts +//! } +//! +//! // Types for your expected config structure +//! #[derive(serde::Deserialize)] +//! struct Colors { +//! primary: String, +//! accents: Vec, +//! filter: Filter +//! } +//! #[derive(serde::Deserialize)] +//! struct Filter { +//! brightness: u8, +//! inverted: bool +//! } +//! #[derive(serde::Deserialize)] +//! struct Fonts(Vec); +//! #[derive(serde::Deserialize)] +//! struct Font { +//! size: u8, +//! name: String +//! } +//! +//! // opening the config.json file +//! let config = Config::::open("/path/to/config.json").unwrap(); +//! // Access "colors" key & verify only 1 match +//! let colors_field = config.get(AppConfig::Colors).unwrap(); +//! colors_field.only_one_key().unwrap(); +//! +//! // Get the underlying value without trying to parse it +//! let colors_inner: &serde_json::Value = colors_field +//! .get_generic_inner() +//! .unwrap(); +//! +//! // Deserialize it yourself +//! let colors: Colors = Colors::deserialize(colors_inner).unwrap(); +//! ``` //! -//! * Sometimes you may not know the exact path to a user's config file, but -//! you instead inform your user that it must in a list of possible locations. +//! --- +//! +//! * Sometimes you want to allow multiple possible paths for a user's config. //! -//! For example, your docs may state: +//! For example, your docs might say: //! ```txt //! MyApp will first check for your config at "~/.config/MyApp/config.json", //! then "~/.MyApp/config.json", then "~/.local/share/MyApp/config.json"... diff --git a/quickfig_core/src/config.rs b/quickfig_core/src/config.rs index 6d0820b..6e42c1c 100644 --- a/quickfig_core/src/config.rs +++ b/quickfig_core/src/config.rs @@ -151,6 +151,8 @@ pub mod config_types { fn get_at_idx(&self, idx: usize) -> Option<&Self>; fn as_str(&self) -> Option<&str>; fn has_key(&self, key: &str) -> bool; + // Generic to allow custom deserialization on user side + fn get_inner(&self) -> &Self; fn get_string(&self) -> Option; fn get_char(&self) -> Option; fn get_u8(&self) -> Option; @@ -181,9 +183,15 @@ pub mod config_types { fn has_key(&self, key: &str) -> bool { self.get(key).is_some() } + + fn get_inner(&self) -> &Self { + self + } + fn get_string(&self) -> Option { self.as_str().map(String::from) } + fn get_char(&self) -> Option { self.as_str() .and_then(|s| s.chars().next()) @@ -252,6 +260,7 @@ pub mod config_types { } impl DeserializedConfig for TOML { + fn get_at_str(&self, key: &str) -> Option<&Self> { self.get(key) } @@ -265,6 +274,10 @@ pub mod config_types { self.get(key).is_some() } + fn get_inner(&self) -> &Self { + self + } + fn get_string(&self) -> Option { self.as_str().map(String::from) } diff --git a/quickfig_core/src/field.rs b/quickfig_core/src/field.rs index 25692ae..3635c2d 100644 --- a/quickfig_core/src/field.rs +++ b/quickfig_core/src/field.rs @@ -29,10 +29,17 @@ impl<'a, S: DeserializeOwned + DeserializedConfig> Field<'a, S> { pub fn new(key: &str, value: &'a S) -> Field<'a, S> { Field { key: key.to_string(), value } } + + pub fn get_inner(&'a self) -> &'a S { + self.value + } } -pub trait VecField { +pub trait VecField { fn only_one_key(&self) -> Result<()>; + /// Get the inner deserializable value for custom deserialization + fn get_generic_inner(&self) -> Option<&S>; + fn get_wrapper(&self) -> Option<&Field<'_, S>>; fn get_string(&self) -> Option; fn get_char(&self) -> Option; fn get_u8(&self) -> Option; @@ -50,7 +57,7 @@ pub trait VecField { fn get_f64(&self) -> Option; } -impl VecField for Vec> { +impl VecField for Vec> { // Validates that all `Field`s have the same key. // If this returns successfully, it is guaranteed that @@ -71,6 +78,12 @@ impl VecField for Vec> { Ok(()) } + fn get_generic_inner(&self) -> Option<&S> { + Some(self.get_wrapper()?.get_inner()) + } + fn get_wrapper(&self) -> Option<&Field<'_, S>> { + self.iter().find_map(|field| field.get_wrapper()) + } fn get_string(&self) -> Option { self.iter().find_map(|field| field.get_string()) } @@ -122,6 +135,9 @@ impl VecField for Vec> { pub trait GetInner { /// Get the key associated with this `Field` fn get_key(&self) -> String; + /// * Get the wrapper (Field) Deserializable value of this field + /// * Use if you need custom deserialization + fn get_wrapper(&self) -> Option<&Self>; /// * Get the parsed `String` of this `Field` /// * Returns `None` if field could not be parsed to String fn get_string(&self) -> Option; @@ -175,9 +191,9 @@ impl GetInner for Field<'_, S> { self.key.clone() } - // constrain S further to DeserializedConfig - // add trait fns for .get_string() etc to it - // call them here + fn get_wrapper(&self) -> Option<&Self> { + Some(self) + } fn get_string(&self) -> Option { self.value.get_string()