From 5cdccda3c4b79ca83d1157102aab8b937f673a04 Mon Sep 17 00:00:00 2001 From: Gray Logan Date: Mon, 5 May 2025 17:07:06 -0400 Subject: [PATCH] v0.5.0 --- .gitignore | 1 - Cargo.toml | 14 +- LICENSE | 23 +-- LICENSE-APACHE | 202 +++++++++++++++++++++++++++ LICENSE-MIT | 21 +++ README.md | 263 +++++++++++++++-------------------- examples/from_section.rs | 3 +- examples/general_macro.rs | 5 +- examples/general_manual.rs | 4 +- examples/get_values.rs | 4 +- examples/load_file.rs | 26 +++- examples/sectioned_macro.rs | 5 +- examples/sectioned_manual.rs | 4 +- src/builder.rs | 4 + src/config.rs | 31 +++-- src/error.rs | 5 + src/lib.rs | 5 +- src/outcome.rs | 37 +++++ tests/config_tests.rs | 45 ++++++ tests/outcome_tests.rs | 26 ++++ 20 files changed, 532 insertions(+), 196 deletions(-) create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 src/outcome.rs create mode 100644 tests/outcome_tests.rs diff --git a/.gitignore b/.gitignore index 53e6897..58f4d26 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ **/target Cargo.lock -*.ini \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 3b9685c..30e0dd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,21 @@ [package] name = "config-tools" -version = "0.4.0" +version = "0.5.0" edition = "2021" authors = ["Gray Logan "] description = "A simplified set of tools for working with configuration files." -license = "MIT" +documentation = "https://docs.rs/config-tools" +homepage = "https://github.com/piccoloser/config-tools" +license = "MIT OR Apache-2.0" +readme = "README.md" repository = "https://github.com/piccoloser/config-tools" -keywords = ["config", "configuration"] -categories = ["config"] +keywords = ["config", "ini", "builder", "macros", "serde"] +categories = ["config", "parser-implementations", "development-tools"] [dependencies] config_tools_derive = "0.1.1" rust-ini = "0.21.1" serde = { version = "1.0", features = ["derive"] } + +[dev-dependencies] +tempfile = "3" \ No newline at end of file diff --git a/LICENSE b/LICENSE index d090600..4d71e64 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,6 @@ -MIT License +This project is licensed under either of: -Copyright (c) 2024 Gray Logan + * MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT) + * Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +at your option. diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..d090600 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Gray Logan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 044f488..69d7ca1 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,43 @@ -# `config_tools` +# config_tools -## Overview +`config_tools` is a lightweight, ergonomic Rust library for working with INI-style configuration files. It offers: -`config_tools` is a configuration management library designed for handling hierarchical configurations using sections and key-value pairs. It provides builders to customize and create `Config` objects, macros to simplify the creation of configuration files, and error handling for configuration loading and saving. +* A builder pattern for programmatic config creation +* Macros for concise default declarations +* Optional typed section parsing using `FromSection` +* Graceful fallbacks with `load_or_default_outcome` +* `serde` support for full serialization and deserialization -`config_tools` is built on top of [rust-ini](https://github.com/zonyitoo/rust-ini) and focuses mostly on convenience. +It is built on top of [`rust-ini`](https://github.com/zonyitoo/rust-ini) and designed for developer ergonomics first. -# Usage Examples +--- -## Manually Creating a New Configuration +## Quickstart + +```rust +use config_tools::{Config, sectioned_defaults}; + +let outcome = Config::load_or_default_outcome( + "config.ini", + sectioned_defaults! { + { "debug" => "true" } + + ["App"] { + "threads" => "4" + } + } +); + +if outcome.used_default() { + eprintln!("Using fallback config."); +} + +let config = outcome.into_inner(); +``` + +--- + +## Manual Configuration ```rust use config_tools::Config; @@ -23,150 +52,103 @@ let config = Config::builder() .build(); ``` -## Creating Configurations Using Macros +--- + +## Macros for Inline Defaults ```rust use config_tools::{sectioned_defaults, general_defaults, Config}; -// Using sectioned_defaults! macro -let sectioned_config: Config = sectioned_defaults! { - { "logging" => "true" } // General section +let sectioned: Config = sectioned_defaults! { + { "logging" => "true" } - ["Server"] { // Named section(s) + ["Server"] { "host" => "127.0.0.1", "port" => "8080" } }; -// Using general_defaults! macro -let general_config: Config = general_defaults! { +let general: Config = general_defaults! { "console" => "true", "logging" => "true", }; ``` -## Parsing Sections into Structs with `FromSection` +--- -```rust -use config_tools::{Config, ServerConfig}; +## Loading and Saving Configs -#[derive(FromSection)] -struct ServerConfig { - host: String, - port: u16, -} +```rust +use config_tools::Config; let config = Config::load("config.ini")?; -let server_section = config.section("Server").unwrap(); -let server_config = ServerConfig::from_section(server_section)?; - -println!("{:?}", server_config); +config.save("out.ini")?; ``` -## Updating a Configuration +You can also handle missing files gracefully: ```rust -use config_tools::{sectioned_defaults, Config}; - -let mut config = sectioned_defaults! { - { - "logging" => "true", - "verbose" => "false", - } - - ["Database"] { - "host" => "localhost", - "port" => "5432", - } -} - -config.update(None, "verbose", "true"); -config.update(Some("Database"), "port", "3306"); +let default = Config::builder().set("fallback", "true").build(); +let config = Config::load_or_default("config.ini", default); ``` -## Loading and Saving Configurations +Or check whether defaults were used: ```rust -use config_tools::Config; +let outcome = Config::load_or_default_outcome("config.ini", Config::default()); -fn main() -> Result<(), config_tools::Error> { - // Load config from file - let config = Config::load("config.ini")?; - - // Access a value - if let Some(host) = config.get(Some("Database"), "host") { - println!("Database host: {}", host); - } - - // Save the config - config.save("new_config.ini")?; - - Ok(()) +if outcome.used_default() { + println!("File not found; using defaults."); } + +let config = outcome.into_inner(); ``` --- -## Error Handling - -All configuration operations return `Result` types that include the custom `Error` enum, which provides more specific details about the nature of failures, such as file I/O errors or missing keys. +## Typed Section Parsing with `FromSection` -## Structs - -### `Config` - -#### Traits - -1. `Debug` and `Default` -2. From [serde](https://serde.rs/derive.html): `Deserialize` and `Serialize` - -Represents the entire configuration, with support for both general (non-sectioned) values and sectioned values. - -- **Fields**: +```rust +use config_tools::{Config, FromSection}; - - `sections`: A `BTreeMap>` where each key is the section title, and the values are the key-value pairs for that section. - - `general_values`: A `BTreeMap` that stores key-value pairs not tied to a specific section. +#[derive(FromSection)] +struct ServerConfig { + host: String, + port: u16, +} -- **Methods**: - - `general(&self) -> &BTreeMap`: Returns a reference to the general section. - - `get(section: Option<&str>, key: &str) -> Option`: Retrieves a value from a specific section or from the general section if no section is provided. - - `get_as(&self, section: Option<&str>, key: &str) -> Option`: Retrieve a value from a specific section or from the general section if no section is provided, parsing said value into a given type `T` so long as the type implements `std::str::FromStr` and `std::fmt::Debug`. - - `load(filename: &str) -> Result`: Loads a configuration from an `.ini` file. - - `load_or_default(filename: &str, default: Config) -> Self`: Loads a configuration from an `.ini` file or uses a provided default. - - `builder() -> ConfigBuilder`: Starts the creation of a new configuration with a builder. - - `save(&self, filename: &str) -> Result<&Self, Error>`: Saves the current configuration to an `.ini` file. - - `section(title: &str) -> Option<&BTreeMap>`: Retrieves a given section from the configuration or `None`. - - `sections() -> &BTreeMap>`: Retrieves the section map of the configuration. - - `update(&mut self, section: Option<&str>, key: &str, value: &str) -> &mut Self`: Updates or adds a key-value pair to a specific section or general configuration. +let config = Config::load("config.ini")?; +let server_section = config.section("Server").unwrap(); +let server: ServerConfig = ServerConfig::from_section(server_section)?; +``` -### `ConfigBuilder` +--- -A builder pattern for creating and customizing `Config` objects before finalizing them. +## `Config` API -- **Methods**: - - `general() -> Self`: Specifies that the builder is targeting the general section (no specific section). - - `section(title: &str) -> Self`: Specifies a section to set key-value pairs in. - - `set(key: &str, value: &str) -> Self`: Sets a key-value pair in the current section or general section. - - `build() -> Config`: Finalizes and returns the built `Config` object. +* `Config::builder()`: Starts a new builder +* `Config::load(path)`: Loads from file +* `Config::save(path)`: Saves to file +* `Config::load_or_default(path, default)`: Uses a fallback if loading fails +* `Config::load_or_default_outcome(...)`: Same as above, but returns `LoadOutcome` +* `config.get(section, key)`: Returns a value as `Option` +* `config.get_as::(...)`: Parses value into a type +* `config.update(...)`: Updates or inserts a key-value pair --- -## Enums +## `LoadOutcome` -### `Error` +Returned from `load_or_default_outcome`: -Defines the possible errors that can occur during the use of the crate. +* `LoadOutcome::FromFile(config)` +* `LoadOutcome::FromDefault(config)` -- **Variants**: +### Methods: - - `AlreadyExists`: Returned when a key already exists in a configuration. - - `NotFound`: Returned when a key is not found in the configuration. - - `ConfigLoad(ini::Error)`: Error variant for failures during loading of `.ini` files. - - `ConfigCreation(std::io::Error)`: Error variant for issues during the saving of `.ini` files. - - `ConfigParse(String)`: Error variant for issues encountered during parsing of configuration values. - -- **Trait Implementation**: - - `fmt::Display`: Custom error message formatting for each error variant. +* `.into_inner()`: Extract the config +* `.as_ref()`, `.as_mut()`: Borrow access +* `.used_default() -> bool`: Did fallback occur? --- @@ -174,62 +156,41 @@ Defines the possible errors that can occur during the use of the crate. ### `sectioned_defaults!` -Generates a `Config` object with support for sections and default values. - -- **Syntax**: - - ```rust - let mut config: Config = sectioned_defaults! { - { "console" => "true" } // General section +```rust +let config = sectioned_defaults! { + { "logging" => "true" } - ["Server"] { // Section with title - "host" => "127.0.0.1", - "port" => "8080", - } + ["App"] { + "theme" => "dark" + } +}; +``` - ["Window"] { - "width" => "720", - "height" => "480", - } - }; - ``` +Supports variables for section names, keys, and values (must be strings). General keys must come first. -- **Notes**: - - Supports variables for section names, keys, and values as long as they are strings. - - General key-value pairs must be specified first if included. +--- ### `general_defaults!` -Generates a `Config` object with default values in a general section. - -- **Syntax**: - ```rust - let mut config: Config = general_defaults! { - "console" => "true", - "logging" => "true", - }; - ``` -- **Notes**: - - The keys and values must be strings. - - This macro is focused on generating default configurations without specific sections. +```rust +let config = general_defaults! { + "logging" => "true", + "console" => "true" +}; +``` --- -## Procedural Macros - -### `FromSection` +## Procedural Macro: `#[derive(FromSection)]` -This procedural macro derives an implementation of the `Section` trait for a struct, enabling automatic parsing from a `BTreeMap`. +Allows typed parsing of section contents: -- **Syntax**: - - ```rust - #[derive(FromSection)] - struct ServerConfig { - host: String, - port: u16, - } - ``` +```rust +#[derive(FromSection)] +struct MySettings { + path: String, + enabled: bool, +} +``` -- **Notes**: - - The fields of the struct must implement `FromStr`, and the macro will automatically attempt to parse each field from the corresponding string value in the section. +Fields must implement `FromStr`. \ No newline at end of file diff --git a/examples/from_section.rs b/examples/from_section.rs index f2ca878..b5c80e9 100644 --- a/examples/from_section.rs +++ b/examples/from_section.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] use config_tools::{sectioned_defaults, Config, FromSection, Section}; +use tempfile::NamedTempFile; #[derive(Debug, FromSection)] struct ServerSettings { @@ -16,7 +17,7 @@ struct LdapSettings { fn main() { let config = Config::load_or_default( - "get-values.ini", + NamedTempFile::new().unwrap().path(), sectioned_defaults! { { "console" => "true", diff --git a/examples/general_macro.rs b/examples/general_macro.rs index d24a3eb..e26155f 100644 --- a/examples/general_macro.rs +++ b/examples/general_macro.rs @@ -1,11 +1,14 @@ +use tempfile::NamedTempFile; + fn main() { let config = config_tools::general_defaults! { "host" => "127.0.0.1", "port" => "8080", }; + let tmp = NamedTempFile::new().unwrap(); config - .save("general-manual.ini") + .save(tmp.path()) .expect("Failed to save config."); println!("{:#?}", config); diff --git a/examples/general_manual.rs b/examples/general_manual.rs index 168dcf3..44ff1d9 100644 --- a/examples/general_manual.rs +++ b/examples/general_manual.rs @@ -1,4 +1,5 @@ use config_tools::Config; +use tempfile::NamedTempFile; fn main() { let config = Config::builder() @@ -6,8 +7,9 @@ fn main() { .set("port", "8080") .build(); + let tmp = NamedTempFile::new().unwrap(); config - .save("general-manual.ini") + .save(tmp.path()) .expect("Failed to save config."); println!("{:#?}", config); diff --git a/examples/get_values.rs b/examples/get_values.rs index f6194bc..a4d13ce 100644 --- a/examples/get_values.rs +++ b/examples/get_values.rs @@ -29,11 +29,11 @@ fn main() { let server_settings = ServerSettings::from_section(&config.section("Server").unwrap()).unwrap(); println!( - "General:\n console={:?}\n log_level={:?}", + "General:\n\tconsole={:?}\n\tlog_level={:?}", console, log_level ); println!( - "Server:\n address={:?}\n port={:?}\n threads={:?}", + "Server:\n\taddress={:?}\n\tport={:?}\n\tthreads={:?}", server_settings.address, server_settings.port, server_settings.threads ); } diff --git a/examples/load_file.rs b/examples/load_file.rs index 64858a3..9c4e1b5 100644 --- a/examples/load_file.rs +++ b/examples/load_file.rs @@ -1,15 +1,16 @@ use config_tools::{sectioned_defaults, Config}; +use tempfile::NamedTempFile; fn main() { - let filename = "load-file.ini"; + let tmp = NamedTempFile::new().unwrap(); // If you want to handle errors manually, use Config::load() instead. // Returns Result - // let config = Config::load(filename); + // let config = Config::load(tmp); // Load and use defaults on failure let config = Config::load_or_default( - filename, + tmp.path(), sectioned_defaults! { { "host" => "127.0.0.1", @@ -18,7 +19,24 @@ fn main() { }, ); - config.save(filename).expect("Failed to save config."); + // If you need to know whether or not defaults were used, call `load_or_default_outcome()` instead: + // let outcome = Config::load_or_default_outcome( + // tmp.path(), + // sectioned_defaults! { + // { + // "host" => "127.0.0.1", + // "port" => "8080", + // } + // }, + // ); + // + // if outcome.used_default() { + // println!("Using default config!"); + // } + // + // let config = outcome.into_inner(); + + config.save(tmp.path()).expect("Failed to save config."); println!("{config:#?}"); } diff --git a/examples/sectioned_macro.rs b/examples/sectioned_macro.rs index ae7b9f8..7f725f0 100644 --- a/examples/sectioned_macro.rs +++ b/examples/sectioned_macro.rs @@ -1,3 +1,5 @@ +use tempfile::NamedTempFile; + fn main() { let config = config_tools::sectioned_defaults! { { "console" => "true" } @@ -13,8 +15,9 @@ fn main() { } }; + let tmp = NamedTempFile::new().unwrap(); config - .save("sectioned-macro.ini") + .save(tmp.path()) .expect("Failed to save config."); println!("{:#?}", config); diff --git a/examples/sectioned_manual.rs b/examples/sectioned_manual.rs index 08cc070..5c46cd4 100644 --- a/examples/sectioned_manual.rs +++ b/examples/sectioned_manual.rs @@ -1,4 +1,5 @@ use config_tools::Config; +use tempfile::NamedTempFile; fn main() { let config = Config::builder() @@ -12,8 +13,9 @@ fn main() { .set("console", "true") .build(); + let tmp = NamedTempFile::new().unwrap(); config - .save("sectioned-manual.ini") + .save(tmp.path()) .expect("Failed to save config."); println!("{:#?}", config); diff --git a/src/builder.rs b/src/builder.rs index 1f4f3aa..4b93dca 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1,5 +1,9 @@ use crate::Config; +/// A builder for incrementally constructing a [`Config`] object. +/// +/// Supports fluent-style API for setting values in the general section or +/// named sections. To finalize the configuration, call [`ConfigBuilder::build`]. pub struct ConfigBuilder { pub(crate) config: Config, pub(crate) section: Option, diff --git a/src/config.rs b/src/config.rs index 9f7e95e..2a94d53 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,12 +1,18 @@ -use crate::{builder::ConfigBuilder, error::Error}; +use crate::{builder::ConfigBuilder, error::Error, outcome::LoadOutcome}; use ini::Ini; -use std::collections::BTreeMap; +use std::{collections::BTreeMap, path::Path}; pub trait Section: Sized { fn from_section(map: &BTreeMap) -> Result; } -#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)] +/// Represents an INI-style configuration, including both general +/// values (not tied to any section) and sectioned key-value pairs. +/// +/// You can build a `Config` manually using the [`ConfigBuilder`] API, +/// load one from a file, or create defaults using macros like +/// [`crate::sectioned_defaults!`] and [`crate::general_defaults!`]. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub struct Config { pub sections: BTreeMap>, pub general_values: BTreeMap, @@ -32,8 +38,8 @@ impl Config { self.get(section, key).and_then(|v| v.parse().ok()) } - pub fn load(filename: &str) -> Result { - let ini = Ini::load_from_file(filename).map_err(Error::ConfigLoad)?; + pub fn load>(path: P) -> Result { + let ini = Ini::load_from_file(path).map_err(Error::ConfigLoad)?; let mut sections = BTreeMap::new(); let mut general_values = BTreeMap::new(); @@ -58,13 +64,20 @@ impl Config { }) } - pub fn load_or_default(filename: &str, default: Config) -> Self { - match Self::load(filename) { + pub fn load_or_default>(path: P, default: Config) -> Self { + match Self::load(path) { Ok(config) => config, Err(_) => default, } } + pub fn load_or_default_outcome>(path: P, default: Config) -> LoadOutcome { + match Self::load(path) { + Ok(config) => LoadOutcome::FromFile(config), + Err(_) => LoadOutcome::FromDefault(default), + } + } + pub fn builder() -> ConfigBuilder { ConfigBuilder { config: Config::default(), @@ -72,7 +85,7 @@ impl Config { } } - pub fn save(&self, filename: &str) -> Result<&Self, Error> { + pub fn save>(&self, path: P) -> Result<&Self, Error> { let mut ini = Ini::new(); let mut section = ini.with_general_section(); @@ -87,7 +100,7 @@ impl Config { } } - ini.write_to_file(filename).map_err(Error::ConfigCreation)?; + ini.write_to_file(path).map_err(Error::ConfigCreation)?; Ok(self) } diff --git a/src/error.rs b/src/error.rs index f275c71..a2da477 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,10 @@ use std::fmt; +/// Represents errors that may occur during loading, saving, or parsing +/// configuration files. +/// +/// This includes I/O errors from file operations as well as user-facing +/// errors like missing keys or invalid values. #[derive(Debug)] pub enum Error { AlreadyExists, diff --git a/src/lib.rs b/src/lib.rs index 5ba3cef..38ee4c0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,11 @@ -mod builder; +pub mod builder; mod config; mod error; mod macros; +mod outcome; +pub use builder::ConfigBuilder; pub use config::{Config, Section}; pub use config_tools_derive::FromSection; pub use error::Error; +pub use outcome::LoadOutcome; diff --git a/src/outcome.rs b/src/outcome.rs new file mode 100644 index 0000000..5eb3e39 --- /dev/null +++ b/src/outcome.rs @@ -0,0 +1,37 @@ +use crate::Config; + +/// The result of loading a configuration, indicating whether the config was +/// loaded from a file or constructed from a default fallback. +/// +/// Use [`LoadOutcome::used_default`] to determine which case occurred, +/// or extract the inner config using [`LoadOutcome::into_inner`]. +#[derive(Clone, Debug, PartialEq)] +pub enum LoadOutcome { + FromDefault(Config), + FromFile(Config), +} + +impl LoadOutcome { + #[must_use] + pub fn into_inner(self) -> Config { + match self { + LoadOutcome::FromDefault(cfg) | LoadOutcome::FromFile(cfg) => cfg + } + } + + pub fn as_mut(&mut self) -> &mut Config { + match self { + LoadOutcome::FromDefault(cfg) | LoadOutcome::FromFile(cfg) => cfg + } + } + + pub fn as_ref(&self) -> &Config { + match self { + LoadOutcome::FromDefault(cfg) | LoadOutcome::FromFile(cfg) => cfg + } + } + + pub fn used_default(&self) -> bool { + matches!(self, LoadOutcome::FromDefault(_)) + } +} \ No newline at end of file diff --git a/tests/config_tests.rs b/tests/config_tests.rs index f2215be..335560d 100644 --- a/tests/config_tests.rs +++ b/tests/config_tests.rs @@ -1,4 +1,5 @@ use config_tools::Config; +use tempfile::NamedTempFile; #[test] fn test_config_builder_general() { @@ -50,6 +51,32 @@ fn test_config_builder_update() { ); } +#[test] +fn test_config_equality() { + let config1 = Config::default(); + let config2 = Config::builder().build(); + + assert_eq!(config1, config2); +} + + +#[test] +fn test_config_save_and_load_roundtrip() { + let config = Config::builder() + .section("App") + .set("theme", "dark") + .general() + .set("debug", "true") + .build(); + + let tmp = NamedTempFile::new().expect("Failed to create temporary file."); + config.save(tmp.path().to_str().unwrap()).unwrap(); + + let loaded = Config::load(tmp.path().to_str().unwrap()).unwrap(); + assert_eq!(config, loaded, "Saved and loaded configs should match"); +} + + #[test] fn test_default_config_loading() { use config_tools::sectioned_defaults; @@ -122,3 +149,21 @@ fn test_get_as_type_mismatch() { "get_as should return None on type mismatch" ); } + +#[test] +fn test_get_as_success() { + let config = Config::builder() + .section("Types") + .set("port", "8080") + .set("enabled", "true") + .set("pi", "3.14") + .build(); + + let port: u16 = config.get_as(Some("Types"), "port").unwrap(); + let enabled: bool = config.get_as(Some("Types"), "enabled").unwrap(); + let pi: f32 = config.get_as(Some("Types"), "pi").unwrap(); + + assert_eq!(port, 8080); + assert_eq!(enabled, true); + assert!((pi - 3.14).abs() < f32::EPSILON); +} diff --git a/tests/outcome_tests.rs b/tests/outcome_tests.rs new file mode 100644 index 0000000..29326a7 --- /dev/null +++ b/tests/outcome_tests.rs @@ -0,0 +1,26 @@ +use config_tools::{Config, sectioned_defaults}; + +#[test] +fn test_load_outcome_used_default() { + let default = sectioned_defaults! { + { + "debug" => "true" + } + }; + + let outcome = Config::load_or_default_outcome("nonexistent_file.ini", default.clone()); + + assert!(outcome.used_default(), "Should detect that default was used"); + assert_eq!(outcome.as_ref(), &default, "Should match the default config"); +} + +#[test] +fn test_load_outcome_mutation() { + let mut outcome = Config::load_or_default_outcome( + "nonexistent_file.ini", + Config::default(), + ); + + outcome.as_mut().update(None, "key", "value"); + assert_eq!(outcome.as_ref().get(None, "key"), Some("value".to_string())); +} \ No newline at end of file