├── .github ├── FUNDING.yml └── workflows │ └── ci.yaml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── examples ├── files │ ├── etc │ │ └── simple.yaml │ ├── simple.toml │ └── validate.toml ├── parse_env.rs ├── simple.rs └── validate.rs ├── macro ├── Cargo.toml └── src │ ├── gen │ ├── meta.rs │ └── mod.rs │ ├── ir.rs │ ├── lib.rs │ ├── parse.rs │ └── util.rs ├── src ├── builder.rs ├── env │ ├── mod.rs │ ├── parse.rs │ └── tests.rs ├── error.rs ├── file.rs ├── internal.rs ├── json5.rs ├── lib.rs ├── meta.rs ├── template.rs ├── test_utils │ ├── example1.rs │ ├── example2.rs │ └── mod.rs ├── toml.rs └── yaml.rs └── tests ├── array_default.rs ├── check_symbols.rs ├── env.rs ├── format-output ├── 1-default.json5 ├── 1-default.toml ├── 1-default.yaml ├── 1-indent-2.toml ├── 1-nested-gap-2.toml ├── 1-no-comments.json5 ├── 1-no-comments.toml ├── 1-no-comments.yaml ├── 2-default.json5 ├── 2-default.toml └── 2-default.yaml ├── general.rs ├── indirect-serde ├── .gitignore ├── Cargo.toml ├── README.md ├── run.rs └── src │ └── main.rs ├── map_default.rs └── validation.rs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: LukasKalbertodt 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ main ] 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | RUSTFLAGS: --deny warnings 11 | 12 | jobs: 13 | style: 14 | name: Check basic style 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: LukasKalbertodt/check-basic-style@v0.1 19 | 20 | check: 21 | name: 'Build & test' 22 | runs-on: ubuntu-20.04 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Restore Cache 26 | uses: Swatinem/rust-cache@v2 27 | - name: Build 28 | run: cargo build 29 | - name: Run tests with file formats 30 | run: cargo test --features=toml,yaml,json5 31 | - name: Run tests 32 | run: cargo test 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | 6 | ## [Unreleased] 7 | 8 | ## [0.3.0] - 2024-10-18 9 | 10 | - **Breaking**: Raise MSRV to 1.61.0 11 | - **Breaking**: `toml`, `yaml` and `json5` are no longer default features ([`19d9ddc`](https://github.com/LukasKalbertodt/confique/commit/19d9ddc9537baf4e82274591ba92f02d4c5c1f36)). You now have to manually specify the features you need in `Cargo.toml`. 12 | - **Breaking**: env vars set to an empty string, which fail to deserialize/parse/validate are now treated as not set. This is technically a breaking change, but I think this is the expected behavior and shouldn't affect you. ([#39](https://github.com/LukasKalbertodt/confique/pull/39)) 13 | - ⭐ Add validation feature ([#40](https://github.com/LukasKalbertodt/confique/pull/40)) 14 | - `#[config(validate = path::to::function)] field: u32` to call the given function during field deserialization. 15 | - `#[config(validate(!s.is_empty(), "user must not be empty"))] user: String` is a `assert!`-style syntax to simple validation checks. 16 | - Validation can also be added to full structs. 17 | - See docs and examples for more information! 18 | - Stop including `Error::source` in the `Display` output of `Error` ([`d454f0957`](https://github.com/LukasKalbertodt/confique/commit/d454f0957eb1cb4d566ebc448224b323a609d080)) 19 | - Improve & refactor docs of `derive(Config` a bit 20 | - Update dependencies (syn to 2.0, heck to 0.5): this shouldn't affect you, except for faster compile times due to smaller dependency tree. 21 | 22 | 23 | ## [0.2.6] - 2024-10-10 24 | - Fix compile errors when using `confique` derive without having `serde` in your direct dependencies (see [#38](https://github.com/LukasKalbertodt/confique/issues/38)). 25 | - Update `toml` dependency to 0.8 26 | - Fix some typos in docs 27 | 28 | ## [0.2.5] - 2023-12-10 29 | - Add `#[config(partial_attr(...))]` struct attribute to specify attributes for 30 | the partial type. 31 | - Allow "yes" and "no" as values when deserializing `bool` from env. Also, the 32 | match is done completely case insensitive now, such that e.g. "True", "tRuE" 33 | are accepted now. 34 | 35 | ## [0.2.4] - 2023-07-02 36 | - Fixed enum deserialization from env values 37 | 38 | ## [0.2.3] - 2023-03-10 39 | ### Fixed 40 | - Add `#[allow(missing_docs)]` to some generated code to avoid problems in 41 | crates that `#[forbid(missing_docs)]` globally. 42 | - Fix badge in README 43 | 44 | ### Added 45 | - Add short docs to generated module (to explains its purpose and avoid 46 | confusion when people find it in their docs) 47 | 48 | ### Changed 49 | - Internal change that potentially improves compile time a tiny bit. 50 | 51 | ## [0.2.2] - 2022-11-25 52 | ### Fixed 53 | - Use fully qualified paths for all symbols emitted by the derive macro. 54 | Before this, the derive would throw errors if you shadowed any of the symbols 55 | `Result`, `Option`, `Ok`, `None` or `Some`. A test has been added to make sure 56 | this does not happen again in the future. 57 | (Partially in [#23](https://github.com/LukasKalbertodt/confique/pull/23), thanks @aschey) 58 | 59 | 60 | ## [0.2.1] - 2022-11-06 61 | ### Added 62 | - `parse_env` attribute for custom parsing of environment variables (allows you 63 | to load lists and other complex objects from env vars). 64 | (in [#22](https://github.com/LukasKalbertodt/confique/pull/22), thanks @cyphersnake) 65 | 66 | ### Changed 67 | - Updated `serde_yaml` to 0.9 (this is only an internal dependency). 68 | 69 | ## [0.2.0] - 2022-10-21 70 | ### Added 71 | - Add support for **array default values**, e.g. `#[config(default = [1, 2, 3])` 72 | - Add support for **map default values**, e.g. `#[config(default = { "cat": 3, "dog": 5 })` 73 | - **Add JSON5 support** 74 | - Show environment variable key in config template 75 | - Impl `PartialEq` for all `meta` items 76 | - Impl `Serialize` for `meta::Expr` 77 | 78 | ### Changed 79 | - **Breaking**: rename `{toml,yaml}::format` to `template` 80 | - **Breaking**: make `FormatOptions` and some `meta` types `#[non_exhaustive]` 81 | - Move to Rust 2021 (bumps MSRV to 1.56) 82 | - Improved docs 83 | 84 | ### Fixed 85 | - Fix type inference for float default values 86 | - Fix name clash with generated helper functions 87 | - Fix incorrect newlines for string default values in YAML config template 88 | 89 | ### Internal 90 | - Rewrite large parts of the crate, mostly to deduplicate logic 91 | - Add lots of tests 92 | 93 | ## [0.1.4] - 2022-10-14 94 | ### Fixed 95 | - Derive attribute `env` can now be used together with `deserialize_with` (#2) 96 | 97 | ## [0.1.3] - 2022-04-07 98 | ### Fixed 99 | - Derive macro does not product unparsable output anymore if the visibility 100 | modifier of the struct is `pub` or `pub(in path)`. 101 | 102 | ### Changed 103 | - The output of `toml::format` now emits empty lines above nested objects in a 104 | more useful manner. 105 | 106 | 107 | ## [0.1.2] - 2022-03-30 108 | ### Fixed 109 | - Fixed output of `toml::format` when leaf fields were listed after `nested` 110 | fields in a configuration. 111 | 112 | 113 | ## [0.1.1] - 2021-11-03 114 | ### Added 115 | - `deserialize_with` attribute which is (basically) forwarded to `serde` 116 | 117 | ### Fixed 118 | - Improve some spans in error messages 119 | 120 | 121 | ## 0.1.0 - 2021-07-28 122 | ### Added 123 | - Everything. 124 | 125 | 126 | [Unreleased]: https://github.com/LukasKalbertodt/confique/compare/v0.3.0...HEAD 127 | [0.3.0]: https://github.com/LukasKalbertodt/confique/compare/v0.2.6...v0.3.0 128 | [0.2.6]: https://github.com/LukasKalbertodt/confique/compare/v0.2.5...v0.2.6 129 | [0.2.5]: https://github.com/LukasKalbertodt/confique/compare/v0.2.4...v0.2.5 130 | [0.2.4]: https://github.com/LukasKalbertodt/confique/compare/v0.2.3...v0.2.4 131 | [0.2.3]: https://github.com/LukasKalbertodt/confique/compare/v0.2.2...v0.2.3 132 | [0.2.2]: https://github.com/LukasKalbertodt/confique/compare/v0.2.1...v0.2.2 133 | [0.2.1]: https://github.com/LukasKalbertodt/confique/compare/v0.2.0...v0.2.1 134 | [0.2.0]: https://github.com/LukasKalbertodt/confique/compare/v0.1.4...v0.2.0 135 | [0.1.4]: https://github.com/LukasKalbertodt/confique/compare/v0.1.3...v0.1.4 136 | [0.1.3]: https://github.com/LukasKalbertodt/confique/compare/v0.1.2...v0.1.3 137 | [0.1.2]: https://github.com/LukasKalbertodt/confique/compare/v0.1.1...v0.1.2 138 | [0.1.1]: https://github.com/LukasKalbertodt/confique/compare/v0.1.0...v0.1.1 139 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "confique" 3 | version = "0.3.0" 4 | authors = ["Lukas Kalbertodt "] 5 | edition = "2021" 6 | rust-version = "1.61.0" 7 | 8 | description = "Type-safe, layered, light-weight, `serde`-based configuration library" 9 | documentation = "https://docs.rs/confique/" 10 | repository = "https://github.com/LukasKalbertodt/confique/" 11 | readme = "README.md" 12 | license = "MIT/Apache-2.0" 13 | 14 | keywords = ["config", "configuration", "conf", "serde", "type-safe"] 15 | categories = ["config"] 16 | exclude = [".github"] 17 | 18 | 19 | [[example]] 20 | name = "simple" 21 | required-features = ["toml"] 22 | 23 | [[example]] 24 | name = "validate" 25 | required-features = ["toml"] 26 | 27 | [[test]] 28 | name = "indirect-serde" 29 | path = "tests/indirect-serde/run.rs" 30 | harness = false 31 | 32 | [[test]] 33 | name = "validation" 34 | required-features = ["toml"] 35 | 36 | 37 | [features] 38 | default = [] 39 | yaml = ["serde_yaml"] 40 | 41 | 42 | [dependencies] 43 | confique-macro = { version = "=0.0.11", path = "macro" } 44 | json5 = { version = "0.4.1", optional = true } 45 | serde = { version = "1", features = ["derive"] } 46 | serde_yaml = { version = "0.9", optional = true } 47 | toml = { version = "0.8", optional = true } 48 | 49 | [dev-dependencies] 50 | pretty_assertions = "1.2.1" 51 | 52 | 53 | [package.metadata.docs.rs] 54 | all-features = true 55 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Project Developers 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Confique: type-safe, layered configuration library 2 | 3 | [CI status of main](https://github.com/LukasKalbertodt/confique/actions/workflows/ci.yaml) 4 | [Crates.io Version](https://crates.io/crates/confique) 5 | [docs.rs](https://docs.rs/confique) 6 | 7 | Confique is a rather light-weight library that helps with configuration management in a type-safe and DRY (don't repeat yourself) fashion. 8 | 9 | **Features**: 10 | 11 | - **Type safe**: the code using the config values does not need to parse strings or `unwrap` any `Option`s. 12 | All values already have the correct type. 13 | - **Layered configuration**: you can load from and then merge multiple sources of configuration. 14 | - **Load config values from**: 15 | - Environment variables 16 | - Files: [TOML](https://toml.io/), [YAML](https://yaml.org/), and [JSON5](https://json5.org/) 17 | - Anything with a `serde` Deserializer 18 | - **Based on `serde`**: less code in `confique` (more light-weight) and access to a huge ecosystem of high quality parsers. 19 | - **Easily generate configuration "templates"**: describe all available config values to your users without repeating yourself. 20 | - **Simple validation**: validity checks can easily be added via attributes. 21 | 22 | 23 | ## Simple example 24 | 25 | ```rust 26 | use std::{net::IpAddr, path::PathBuf}; 27 | use confique::Config; 28 | 29 | 30 | #[derive(Config)] 31 | struct Conf { 32 | /// Port to listen on. 33 | #[config(env = "PORT", default = 8080)] 34 | port: u16, 35 | 36 | /// Bind address. 37 | #[config(default = "127.0.0.1")] 38 | address: IpAddr, 39 | 40 | #[config(nested)] 41 | log: LogConf, 42 | } 43 | 44 | #[derive(Config)] 45 | struct LogConf { 46 | #[config(default = true)] 47 | stdout: bool, 48 | 49 | #[config(validate(file.is_absolute(), "log file requires absolute path"))] 50 | file: Option, 51 | 52 | #[config(default = ["debug"])] 53 | ignored_modules: Vec, 54 | } 55 | 56 | 57 | let config = Conf::builder() 58 | .env() 59 | .file("example-app.toml") 60 | .file("/etc/example-app/config.toml") 61 | .load()?; 62 | ``` 63 | 64 | See [**the documentation**](https://docs.rs/confique) for more information. 65 | 66 | ### Configuration Template 67 | 68 | With the above example, you can automatically generate a configuration template: 69 | a file in a chosen format that lists all values with their description, default values, and env values. 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 99 | 120 | 144 | 145 |
toml::template::<Conf>()yaml::template::<Conf>()json5::template::<Conf>()
79 | 80 | ```toml 81 | # Port to listen on. 82 | # 83 | # Can also be specified via 84 | # environment variable `PORT`. 85 | # 86 | # Default value: 8080 87 | #port = 8080 88 | 89 | # Bind address. 90 | # 91 | # Default value: "127.0.0.1" 92 | #address = "127.0.0.1" 93 | 94 | [log] 95 | # 96 | ``` 97 | 98 | 100 | 101 | ```yaml 102 | # Port to listen on. 103 | # 104 | # Can also be specified via 105 | # environment variable `PORT`. 106 | # 107 | # Default value: 8080 108 | #port: 8080 109 | 110 | # Bind address. 111 | # 112 | # Default value: 127.0.0.1 113 | #address: 127.0.0.1 114 | 115 | log: 116 | # 117 | ``` 118 | 119 | 121 | 122 | ```json5 123 | { 124 | // Port to listen on. 125 | // 126 | // Can also be specified via 127 | // environment variable `PORT`. 128 | // 129 | // Default value: 8080 130 | //port: 8080, 131 | 132 | // Bind address. 133 | // 134 | // Default value: "127.0.0.1" 135 | //address: "127.0.0.1", 136 | 137 | log: { 138 | // 139 | }, 140 | } 141 | ``` 142 | 143 |
146 | 147 | (Note: The "environment variable" sentence is on a single line; I just split it into two lines for readability in this README.) 148 | 149 | ## Comparison with other libraries/solutions 150 | 151 | ### [`config`](https://crates.io/crates/config) 152 | 153 | - Loosely typed: 154 | - You access configuration values via string path (e.g. `"http.port"`) and deserialize at "use site". 155 | - No defined schema 156 | - More features 157 | - Larger library 158 | - If you need a "config template", you need to repeat code/docs 159 | 160 | ### [`figment`](https://crates.io/crates/figment) 161 | 162 | - Also based on `serde` and also uses your own structs as data store, thus type safe 163 | - Instead of using partial types, aggregates different layers in a dynamic data store 164 | - If you need a "config template", you need to repeat code/docs 165 | 166 | ### Just `serde`? 167 | 168 | Serde is not a configuration, but a deserialization library. 169 | But you can get surprisingly far with just serde and it might actually be sufficient for your project. 170 | However, once you want to load from multiple sources, you either have make all your fields `Option` or repeat code/docs. 171 | With `confique` you also get some other handy helpers. 172 | 173 | 174 | ## Status of this project 175 | 176 | There is still some design space to explore and there are certainly still many features one could add. 177 | However, the core interface (the derive macro and the core traits) probably won't change a lot anymore. 178 | Confique is used by a web project (that's already used in production) which I'm developing alongside of confique. 179 | 180 | 181 |
182 | 183 | --- 184 | 185 | ## License 186 | 187 | Licensed under either of Apache License, Version 188 | 2.0 or MIT license at your option. 189 | Unless you explicitly state otherwise, any contribution intentionally submitted 190 | for inclusion in this project by you, as defined in the Apache-2.0 license, 191 | shall be dual licensed as above, without any additional terms or conditions. 192 | -------------------------------------------------------------------------------- /examples/files/etc/simple.yaml: -------------------------------------------------------------------------------- 1 | http: 2 | port: 4321 3 | -------------------------------------------------------------------------------- /examples/files/simple.toml: -------------------------------------------------------------------------------- 1 | [log] 2 | stdout = false 3 | -------------------------------------------------------------------------------- /examples/files/validate.toml: -------------------------------------------------------------------------------- 1 | name = "peter" 2 | port = 1234 3 | 4 | [watch] 5 | busy_poll = true 6 | poll_period = 300 7 | -------------------------------------------------------------------------------- /examples/parse_env.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use confique::Config; 4 | use std::{collections::HashSet, num::NonZeroU64, path::PathBuf, str::FromStr, convert::Infallible}; 5 | 6 | 7 | #[derive(Debug, Config)] 8 | struct Conf { 9 | #[config(env = "PATHS", parse_env = confique::env::parse::list_by_colon)] 10 | paths: HashSet, 11 | 12 | #[config(env = "PORTS", parse_env = confique::env::parse::list_by_comma)] 13 | ports: Vec, 14 | 15 | #[config(env = "NAMES", parse_env = confique::env::parse::list_by_sep::<'|', _, _>)] 16 | names: Vec, 17 | 18 | #[config(env = "TIMEOUT", parse_env = NonZeroU64::from_str)] 19 | timeout_seconds: NonZeroU64, 20 | 21 | #[config(env = "FORMATS", parse_env = parse_formats)] 22 | formats: Vec, 23 | } 24 | 25 | #[derive(Debug, serde::Deserialize)] 26 | enum Format { 27 | Env, 28 | Toml, 29 | Json5, 30 | Yaml, 31 | } 32 | 33 | /// Example custom parser. 34 | fn parse_formats(input: &str) -> Result, Infallible> { 35 | let mut result = Vec::new(); 36 | 37 | if input.contains("toml") { 38 | result.push(Format::Toml); 39 | } 40 | if input.contains("env") { 41 | result.push(Format::Env); 42 | } 43 | if input.contains("yaml") { 44 | result.push(Format::Yaml); 45 | } 46 | if input.contains("json5") { 47 | result.push(Format::Json5); 48 | } 49 | 50 | Ok(result) 51 | } 52 | 53 | fn main() { 54 | std::env::set_var("PATHS", "/bin/ls,/usr/local/bin,/usr/bin/ls"); 55 | std::env::set_var("PORTS", "8080,8888,8000"); 56 | std::env::set_var("NAMES", "Alex|Peter|Mary"); 57 | std::env::set_var("TIMEOUT", "100"); 58 | std::env::set_var("FORMATS", "json5,yaml;.env"); 59 | 60 | println!("{:#?}", Conf::builder().env().load()); 61 | } 62 | -------------------------------------------------------------------------------- /examples/simple.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use confique::Config; 4 | use std::{net::IpAddr, path::PathBuf}; 5 | 6 | #[derive(Debug, Config)] 7 | /// A sample configuration for our app. 8 | struct Conf { 9 | #[config(nested)] 10 | http: Http, 11 | 12 | #[config(nested)] 13 | log: LogConfig, 14 | } 15 | 16 | /// Configuring the HTTP server of our app. 17 | #[derive(Debug, Config)] 18 | #[config(partial_attr(derive(Clone)))] 19 | struct Http { 20 | /// The port the server will listen on. 21 | #[config(env = "PORT")] 22 | port: u16, 23 | 24 | /// The bind address of the server. Can be set to `0.0.0.0` for example, to 25 | /// allow other users of the network to access the server. 26 | #[config(default = "127.0.0.1")] 27 | bind: IpAddr, 28 | } 29 | 30 | #[derive(Debug, Config)] 31 | struct LogConfig { 32 | /// If set to `true`, the app will log to stdout. 33 | #[config(default = true)] 34 | stdout: bool, 35 | 36 | /// If this is set, the app will write logs to the given file. Of course, 37 | /// the app has to have write access to that file. 38 | file: Option, 39 | } 40 | 41 | 42 | fn main() { 43 | let r = Conf::builder() 44 | .env() 45 | .file("examples/files/simple.toml") 46 | .file("examples/files/etc/simple.yaml") 47 | .load(); 48 | 49 | match r { 50 | Ok(conf) => println!("{:#?}", conf), 51 | Err(e) => println!("{e:#}"), 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/validate.rs: -------------------------------------------------------------------------------- 1 | //! This example demonstrates the usage of validators for single fields or whole 2 | //! structs. Try editing `files/validate.toml` to see different errors. Also 3 | //! see the docs. 4 | 5 | use std::time::Duration; 6 | 7 | use confique::Config; 8 | 9 | 10 | #[derive(Debug, Config)] 11 | #[allow(dead_code)] 12 | struct Conf { 13 | // Here, the validator is a function returning `Result<(), impl Display>`. 14 | #[config(validate = validate_name)] 15 | name: String, 16 | 17 | // For simple cases, validation can be written in this `assert!`-like style. 18 | #[config(env = "PORT", validate(*port >= 1024, "port must not require super-user"))] 19 | port: Option, 20 | 21 | #[config(nested)] 22 | watch: WatchConfig, 23 | } 24 | 25 | // You can also add validators for whole structs, which are called later in the 26 | // pipeline, when all layers are already merged. These validators allow you to 27 | // check fields in relationship to one another, e.g. maybe one field only makes 28 | // sense to be set whenever another one has a specific value. 29 | #[derive(Debug, Config)] 30 | #[config(validate = Self::validate)] 31 | struct WatchConfig { 32 | #[config(default = false)] 33 | busy_poll: bool, 34 | 35 | #[config( 36 | deserialize_with = deserialize_duration_ms, 37 | validate(*poll_period > Duration::from_millis(10), "cannot poll faster than 10ms"), 38 | )] 39 | poll_period: Option, 40 | } 41 | 42 | fn validate_name(name: &String) -> Result<(), &'static str> { 43 | if name.is_empty() { 44 | return Err("name must be non-empty"); 45 | } 46 | if !name.is_ascii() { 47 | return Err("name must be ASCII"); 48 | } 49 | Ok(()) 50 | } 51 | 52 | impl WatchConfig { 53 | fn validate(&self) -> Result<(), &'static str> { 54 | if !self.busy_poll && self.poll_period.is_some() { 55 | return Err("'poll_period' set, but busy polling is not enabled"); 56 | } 57 | 58 | Ok(()) 59 | } 60 | } 61 | 62 | 63 | pub(crate) fn deserialize_duration_ms<'de, D>(deserializer: D) -> Result 64 | where 65 | D: serde::Deserializer<'de>, 66 | { 67 | let ms = ::deserialize(deserializer)?; 68 | Ok(Duration::from_millis(ms)) 69 | } 70 | 71 | 72 | fn main() { 73 | let r = Conf::builder() 74 | .env() 75 | .file("examples/files/validate.toml") 76 | .load(); 77 | 78 | match r { 79 | Ok(conf) => println!("{:#?}", conf), 80 | Err(e) => println!("{e:#}"), 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /macro/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "confique-macro" 3 | version = "0.0.11" 4 | authors = ["Lukas Kalbertodt "] 5 | edition = "2021" 6 | 7 | description = "Macro crate for 'confique'. Do not use directly! Semver not guaranteed!" 8 | repository = "https://github.com/LukasKalbertodt/confique/" 9 | license = "MIT/Apache-2.0" 10 | 11 | [lib] 12 | proc-macro = true 13 | 14 | [dependencies] 15 | syn = "2.0" 16 | quote = "1.0" 17 | proc-macro2 = "1.0" 18 | heck = "0.5.0" 19 | -------------------------------------------------------------------------------- /macro/src/gen/meta.rs: -------------------------------------------------------------------------------- 1 | use proc_macro2::{Span, TokenStream}; 2 | use quote::quote; 3 | use syn::Ident; 4 | 5 | use crate::ir::{self, Expr, FieldKind, LeafKind, MapKey}; 6 | 7 | 8 | 9 | /// Generates the whole `const META: ... = ...;` item. 10 | pub(super) fn gen(input: &ir::Input) -> TokenStream { 11 | fn env_tokens(env: &Option) -> TokenStream { 12 | match env { 13 | Some(key) => quote! { std::option::Option::Some(#key) }, 14 | None => quote! { std::option::Option::None }, 15 | } 16 | } 17 | 18 | let name_str = input.name.to_string(); 19 | let doc = &input.doc; 20 | let meta_fields = input.fields.iter().map(|f| { 21 | let name = f.name.to_string(); 22 | let doc = &f.doc; 23 | let kind = match &f.kind { 24 | FieldKind::Nested { ty } => { 25 | quote! { 26 | confique::meta::FieldKind::Nested { meta: &<#ty as confique::Config>::META } 27 | } 28 | } 29 | FieldKind::Leaf { env, kind: LeafKind::Optional { .. }, ..} => { 30 | let env = env_tokens(env); 31 | quote! { 32 | confique::meta::FieldKind::Leaf { 33 | env: #env, 34 | kind: confique::meta::LeafKind::Optional, 35 | } 36 | } 37 | } 38 | FieldKind::Leaf { env, kind: LeafKind::Required { default, ty, .. }, ..} => { 39 | let env = env_tokens(env); 40 | let default_value = match default { 41 | Some(default) => { 42 | let meta = default_value_to_meta_expr(default, Some(&ty)); 43 | quote! { std::option::Option::Some(#meta) } 44 | }, 45 | None => quote! { std::option::Option::None }, 46 | }; 47 | quote! { 48 | confique::meta::FieldKind::Leaf { 49 | env: #env, 50 | kind: confique::meta::LeafKind::Required { 51 | default: #default_value, 52 | }, 53 | } 54 | } 55 | } 56 | }; 57 | 58 | quote! { 59 | confique::meta::Field { 60 | name: #name, 61 | doc: &[ #(#doc),* ], 62 | kind: #kind, 63 | } 64 | } 65 | }); 66 | 67 | quote! { 68 | const META: confique::meta::Meta = confique::meta::Meta { 69 | name: #name_str, 70 | doc: &[ #(#doc),* ], 71 | fields: &[ #( #meta_fields ),* ], 72 | }; 73 | } 74 | } 75 | 76 | /// Helper macro to deduplicate logic for literals. Only used in the function 77 | /// below. 78 | macro_rules! match_literals { 79 | ($v:expr, $ty:expr, $ns:ident, { $($other_arms:tt)* }) => { 80 | match $v { 81 | $ns::Bool(v) => quote! { confique::meta::$ns::Bool(#v) }, 82 | $ns::Str(s) => quote! { confique::meta::$ns::Str(#s) }, 83 | $ns::Int(i) => { 84 | let variant = infer_type(i.suffix(), $ty, "I32", int_type_to_variant); 85 | quote! { confique::meta::$ns::Integer(confique::meta::Integer::#variant(#i)) } 86 | } 87 | $ns::Float(f) => { 88 | let variant = infer_type(f.suffix(), $ty, "F64", float_type_to_variant); 89 | quote! { confique::meta::$ns::Float(confique::meta::Float::#variant(#f)) } 90 | } 91 | $($other_arms)* 92 | } 93 | }; 94 | } 95 | 96 | /// Generates the meta expression of type `meta::Expr` to be used for the 97 | /// `default` field. `ty` is the type of the field that is used to better infer 98 | /// the exact type of the default value. 99 | fn default_value_to_meta_expr(default: &Expr, ty: Option<&syn::Type>) -> TokenStream { 100 | match_literals!(default, ty, Expr, { 101 | Expr::Array(items) => { 102 | let item_type = ty.and_then(get_array_item_type); 103 | let items = items.iter().map(|item| default_value_to_meta_expr(item, item_type)); 104 | quote! { confique::meta::Expr::Array(&[#( #items ),*]) } 105 | } 106 | Expr::Map(entries) => { 107 | // TODO: use `Option::unzip` once stable 108 | let types = ty.and_then(get_map_entry_types); 109 | let key_type = types.map(|(t, _)| t); 110 | let value_type = types.map(|(_, v)| v); 111 | 112 | let pairs = entries.iter().map(|e| { 113 | let key = match_literals!(&e.key, key_type, MapKey, {}); 114 | let value = default_value_to_meta_expr(&e.value, value_type); 115 | quote! { confique::meta::MapEntry { key: #key, value: #value } } 116 | }); 117 | quote! { confique::meta::Expr::Map(&[#( #pairs ),*]) } 118 | } 119 | }) 120 | } 121 | 122 | /// Maps an integer type to the `meta::Expr` variant (e.g. `u32` -> `U32`). 123 | fn int_type_to_variant(suffix: &str) -> Option<&'static str> { 124 | match suffix { 125 | "u8" => Some("U8"), 126 | "u16" => Some("U16"), 127 | "u32" => Some("U32"), 128 | "u64" => Some("U64"), 129 | "u128" => Some("U128"), 130 | "usize" => Some("Usize"), 131 | "i8" => Some("I8"), 132 | "i16" => Some("I16"), 133 | "i32" => Some("I32"), 134 | "i64" => Some("I64"), 135 | "i128" => Some("I128"), 136 | "isize" => Some("Isize"), 137 | _ => None, 138 | } 139 | } 140 | 141 | /// Maps a float type to the `meta::Expr` variant (e.g. `f32` -> `F32`). 142 | fn float_type_to_variant(suffix: &str) -> Option<&'static str> { 143 | match suffix { 144 | "f32" => Some("F32"), 145 | "f64" => Some("F64"), 146 | _ => None, 147 | } 148 | } 149 | 150 | /// Tries to infer the type of an int or float default value. 151 | /// 152 | /// To figure out the type of int or float literals, we first look at the type 153 | /// suffix of the literal. If it is specified, we use that. Otherwise we check 154 | /// if the field type is a known float/integer type. If so, we use that. 155 | /// Otherwise we use a default. 156 | fn infer_type( 157 | suffix: &str, 158 | field_ty: Option<&syn::Type>, 159 | default: &str, 160 | map: fn(&str) -> Option<&'static str>, 161 | ) -> Ident { 162 | let variant = map(suffix) 163 | .or_else(|| { 164 | if let Some(syn::Type::Path(syn::TypePath { qself: None, path })) = field_ty { 165 | path.get_ident().and_then(|i| map(&i.to_string())) 166 | } else { 167 | None 168 | } 169 | }) 170 | .unwrap_or(default); 171 | 172 | Ident::new(variant, Span::call_site()) 173 | } 174 | 175 | /// Tries to extract the type of the item of a field with an array default 176 | /// value. Examples: `&[u32]` -> `u32`, `Vec` -> `String`. 177 | fn get_array_item_type(ty: &syn::Type) -> Option<&syn::Type> { 178 | match ty { 179 | // The easy types. 180 | syn::Type::Slice(slice) => Some(&slice.elem), 181 | syn::Type::Array(array) => Some(&*array.elem), 182 | 183 | // This is the least clear case. We certainly want to cover `Vec` but 184 | // ideally some more cases. On the other hand, we just can't really 185 | // know, so some incorrect guesses are definitely expected here. Most 186 | // are likely filtered out by applying `gen_meta_default` to it, but 187 | // some will result in a wrong default value type. But people can 188 | // always just add a prefix to the literal in those cases. 189 | // 190 | // We simply check if the last element in the path has exactly one 191 | // generic type argument, in which case we use that. 192 | syn::Type::Path(p) => { 193 | let args = match &p.path.segments.last().expect("empty type path").arguments { 194 | syn::PathArguments::AngleBracketed(args) => &args.args, 195 | _ => return None, 196 | }; 197 | 198 | if args.len() != 1 { 199 | return None; 200 | } 201 | 202 | match &args[0] { 203 | syn::GenericArgument::Type(t) => Some(t), 204 | _ => None, 205 | } 206 | }, 207 | 208 | // Just recurse on inner type. 209 | syn::Type::Reference(r) => get_array_item_type(&r.elem), 210 | syn::Type::Group(g) => get_array_item_type(&g.elem), 211 | syn::Type::Paren(p) => get_array_item_type(&p.elem), 212 | 213 | _ => None, 214 | } 215 | } 216 | 217 | /// Tries to extract the key and value types from a map value. Examples: 218 | /// `HashMap` -> `(String, u32)`. 219 | fn get_map_entry_types(ty: &syn::Type) -> Option<(&syn::Type, &syn::Type)> { 220 | match ty { 221 | // We simply check if the last element in the path has exactly two 222 | // generic type arguments, in which case we use those. Otherwise we 223 | // can't really know. 224 | syn::Type::Path(p) => { 225 | let args = match &p.path.segments.last().expect("empty type path").arguments { 226 | syn::PathArguments::AngleBracketed(args) => &args.args, 227 | _ => return None, 228 | }; 229 | 230 | if args.len() != 2 { 231 | return None; 232 | } 233 | 234 | match (&args[0], &args[1]) { 235 | (syn::GenericArgument::Type(k), syn::GenericArgument::Type(v)) => Some((k, v)), 236 | _ => None, 237 | } 238 | }, 239 | 240 | // Just recurse on inner type. 241 | syn::Type::Group(g) => get_map_entry_types(&g.elem), 242 | syn::Type::Paren(p) => get_map_entry_types(&p.elem), 243 | 244 | _ => None, 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /macro/src/gen/mod.rs: -------------------------------------------------------------------------------- 1 | use proc_macro2::{Span, TokenStream}; 2 | use quote::{format_ident, quote, quote_spanned}; 3 | use syn::{Ident, spanned::Spanned}; 4 | 5 | use crate::ir::{self, FieldKind, LeafKind}; 6 | 7 | mod meta; 8 | 9 | 10 | /// The main function to generate the output token stream from the parse IR. 11 | pub(crate) fn gen(input: ir::Input) -> TokenStream { 12 | let partial_mod = gen_partial_mod(&input); 13 | let config_impl = gen_config_impl(&input); 14 | 15 | quote! { 16 | #config_impl 17 | #partial_mod 18 | } 19 | } 20 | 21 | /// Generates the `impl Config for ... { ... }`. 22 | fn gen_config_impl(input: &ir::Input) -> TokenStream { 23 | let name = &input.name; 24 | let (partial_mod_name, partial_struct_name) = partial_names(&input.name); 25 | 26 | let field_names = input.fields.iter().map(|f| &f.name); 27 | let from_exprs = input.fields.iter().map(|f| { 28 | let field_name = &f.name; 29 | let path = field_name.to_string(); 30 | match f.kind { 31 | FieldKind::Nested { .. } => { 32 | quote! { 33 | confique::internal::map_err_prefix_path( 34 | confique::Config::from_partial(partial.#field_name), 35 | #path, 36 | )? 37 | } 38 | } 39 | FieldKind::Leaf { kind: LeafKind::Optional { .. }, .. } => { 40 | quote! { partial.#field_name } 41 | } 42 | FieldKind::Leaf { kind: LeafKind::Required { .. }, .. } => { 43 | quote! { 44 | confique::internal::unwrap_or_missing_value_err(partial.#field_name, #path)? 45 | } 46 | } 47 | } 48 | }); 49 | 50 | let validation = input.validate.as_ref().map(|v| { 51 | let struct_name = name.to_string(); 52 | quote! { 53 | confique::internal::validate_struct(&out, &#v, #struct_name)?; 54 | } 55 | }); 56 | 57 | let meta_item = meta::gen(input); 58 | quote! { 59 | #[automatically_derived] 60 | impl confique::Config for #name { 61 | type Partial = #partial_mod_name::#partial_struct_name; 62 | 63 | fn from_partial(partial: Self::Partial) -> std::result::Result { 64 | let out = Self { 65 | #( #field_names: #from_exprs, )* 66 | }; 67 | #validation 68 | std::result::Result::Ok(out) 69 | } 70 | 71 | #meta_item 72 | } 73 | } 74 | } 75 | 76 | /// Generates the whole `mod ... { ... }` that defines the partial type and 77 | /// related items. 78 | fn gen_partial_mod(input: &ir::Input) -> TokenStream { 79 | // Iterate through all fields, collecting field-relevant parts to be sliced 80 | // in the various methods. 81 | let mut parts = Parts::default(); 82 | for f in &input.fields { 83 | gen_parts_for_field(f, input, &mut parts); 84 | } 85 | let Parts { 86 | field_names, 87 | struct_fields, 88 | nested_bounds, 89 | empty_exprs, 90 | default_exprs, 91 | from_env_exprs, 92 | fallback_exprs, 93 | is_empty_exprs, 94 | is_complete_exprs, 95 | extra_items, 96 | } = parts; 97 | 98 | // Prepare some values for interpolation 99 | let (mod_name, struct_name) = partial_names(&input.name); 100 | let visibility = &input.visibility; 101 | let partial_attrs = &input.partial_attrs; 102 | let struct_visibility = inner_visibility(&input.visibility, Span::call_site()); 103 | let module_doc = format!( 104 | "*Generated* by `confique`: helpers to implement `Config` for [`{}`].\n\ 105 | \n\ 106 | Do not use directly! Only use via the `Config` and `Partial` traits \ 107 | and what's explained in the confique documentation. 108 | Any other parts of this module cannot be relied on and are not part \ 109 | of the semver guarantee of `confique`.", 110 | input.name, 111 | ); 112 | 113 | quote! { 114 | #[doc = #module_doc] 115 | #visibility mod #mod_name { 116 | #![allow(missing_docs)] 117 | use super::*; 118 | 119 | #[derive(confique::serde::Deserialize)] 120 | #[serde(crate = "confique::serde")] 121 | #( #[ #partial_attrs ])* 122 | #struct_visibility struct #struct_name { 123 | #( #struct_fields )* 124 | } 125 | 126 | #[automatically_derived] 127 | impl confique::Partial for #struct_name where #( #nested_bounds, )* { 128 | fn empty() -> Self { 129 | Self { 130 | #( #field_names: #empty_exprs, )* 131 | } 132 | } 133 | 134 | fn default_values() -> Self { 135 | Self { 136 | #( #field_names: #default_exprs, )* 137 | } 138 | } 139 | 140 | fn from_env() -> std::result::Result { 141 | std::result::Result::Ok(Self { 142 | #( #field_names: #from_env_exprs, )* 143 | }) 144 | } 145 | 146 | fn with_fallback(self, fallback: Self) -> Self { 147 | Self { 148 | #( #field_names: #fallback_exprs, )* 149 | } 150 | } 151 | 152 | fn is_empty(&self) -> bool { 153 | true #(&& #is_empty_exprs)* 154 | } 155 | 156 | fn is_complete(&self) -> bool { 157 | true #(&& #is_complete_exprs)* 158 | } 159 | } 160 | 161 | #extra_items 162 | } 163 | } 164 | } 165 | 166 | #[derive(Default)] 167 | struct Parts { 168 | field_names: Vec, 169 | struct_fields: Vec, 170 | nested_bounds: Vec, 171 | empty_exprs: Vec, 172 | default_exprs: Vec, 173 | from_env_exprs: Vec, 174 | fallback_exprs: Vec, 175 | is_empty_exprs: Vec, 176 | is_complete_exprs: Vec, 177 | extra_items: TokenStream, 178 | } 179 | 180 | fn gen_parts_for_field(f: &ir::Field, input: &ir::Input, parts: &mut Parts) { 181 | let struct_name = &input.name; 182 | let field_name = &f.name; 183 | parts.field_names.push(field_name.clone()); 184 | let qualified_name = format!("{struct_name}::{field_name}"); 185 | 186 | // We have to use the span of the field's name here so that error 187 | // messages from the `derive(serde::Deserialize)` have the correct span. 188 | let field_visibility = inner_visibility(&input.visibility, field_name.span()); 189 | 190 | 191 | match &f.kind { 192 | // ----- Nested ------------------------------------------------------------- 193 | FieldKind::Nested { ty } => { 194 | let ty_span = ty.span(); 195 | let field_ty = quote_spanned! {ty_span=> <#ty as confique::Config>::Partial }; 196 | parts.struct_fields.push(quote! { 197 | #[serde(default = "confique::Partial::empty")] 198 | #field_visibility #field_name: #field_ty, 199 | }); 200 | 201 | parts.nested_bounds.push(quote! { #ty: confique::Config }); 202 | parts.empty_exprs.push(quote! { confique::Partial::empty() }); 203 | parts.default_exprs.push(quote! { confique::Partial::default_values() }); 204 | parts.from_env_exprs.push(quote! { confique::Partial::from_env()? }); 205 | parts.fallback_exprs.push(quote! { 206 | self.#field_name.with_fallback(fallback.#field_name) 207 | }); 208 | parts.is_empty_exprs.push(quote! { self.#field_name.is_empty() }); 209 | parts.is_complete_exprs.push(quote! { self.#field_name.is_complete() }); 210 | }, 211 | 212 | 213 | // ----- Leaf --------------------------------------------------------------- 214 | FieldKind::Leaf { kind, deserialize_with, validate, env, parse_env } => { 215 | let inner_ty = kind.inner_ty(); 216 | 217 | // This has an ugly name to avoid clashing with imported names. 218 | let validate_fn_name = quote::format_ident!("__confique_validate_{field_name}"); 219 | let deserialize_fn_name 220 | = quote::format_ident!("__confique_deserialize_direct_{field_name}"); 221 | 222 | let default_deserialize_path = quote! { 223 | <#inner_ty as confique::serde::Deserialize>::deserialize 224 | }; 225 | 226 | // We sometimes emit extra helper functions to avoid code duplication. 227 | // Validation should be part of the serialization. `validation_fn` is 228 | // `Some(Ident)` if there is a validator function. `deserialize_fn` is 229 | // a token stream that represents a callable function that deserializes 230 | // `inner_ty`. 231 | let (validate_fn, deserialize_fn) = if let Some(validator) = &validate { 232 | let validate_inner = match validator { 233 | ir::FieldValidator::Fn(f) => quote_spanned! {f.span() => 234 | confique::internal::validate_field(v, &#f) 235 | }, 236 | ir::FieldValidator::Simple(expr, msg) => quote! { 237 | fn is_valid(#field_name: &#inner_ty) -> bool { 238 | #expr 239 | } 240 | confique::internal::validate_field(v, &|v| { 241 | if !is_valid(v) { 242 | Err(#msg) 243 | } else { 244 | Ok(()) 245 | } 246 | }) 247 | }, 248 | }; 249 | 250 | let deser_fn = deserialize_with.as_ref() 251 | .map(|f| quote!( #f )) 252 | .unwrap_or_else(|| default_deserialize_path.clone()); 253 | 254 | parts.extra_items.extend(quote! { 255 | #[inline(never)] 256 | fn #validate_fn_name( 257 | v: &#inner_ty, 258 | ) -> std::result::Result<(), confique::Error> { 259 | #validate_inner 260 | } 261 | 262 | fn #deserialize_fn_name<'de, D>( 263 | deserializer: D, 264 | ) -> std::result::Result<#inner_ty, D::Error> 265 | where 266 | D: confique::serde::Deserializer<'de>, 267 | { 268 | let out = #deser_fn(deserializer)?; 269 | #validate_fn_name(&out) 270 | .map_err(::custom)?; 271 | std::result::Result::Ok(out) 272 | } 273 | }); 274 | 275 | (Some(validate_fn_name), quote! { #deserialize_fn_name }) 276 | } else { 277 | // If there is no validation, we will not create a custom 278 | // deserialization function for this, so we either use `T::deserialize` 279 | // or, if set, the specified deserialization function. 280 | let deser = deserialize_with.as_ref() 281 | .map(|f| quote! { #f }) 282 | .unwrap_or(default_deserialize_path); 283 | (None, deser) 284 | }; 285 | 286 | 287 | // Struct field definition 288 | parts.struct_fields.push({ 289 | // If there is a custom deserializer or a validator, we need to 290 | // set the serde `deserialize_with` attribute. 291 | let attr = if deserialize_with.is_some() || validate.is_some() { 292 | // Since the struct field is `Option`, we need to create 293 | // another wrapper deserialization function, that always 294 | // returns `Some`. 295 | let fn_name = quote::format_ident!("__confique_deserialize_some_{field_name}"); 296 | parts.extra_items.extend(quote! { 297 | fn #fn_name<'de, D>( 298 | deserializer: D, 299 | ) -> std::result::Result, D::Error> 300 | where 301 | D: confique::serde::Deserializer<'de>, 302 | { 303 | #deserialize_fn(deserializer).map(std::option::Option::Some) 304 | } 305 | }); 306 | 307 | let attr_value = fn_name.to_string(); 308 | quote! { 309 | #[serde(default, deserialize_with = #attr_value)] 310 | } 311 | } else { 312 | quote! {} 313 | }; 314 | 315 | let main = quote_spanned! {field_name.span()=> 316 | #field_visibility #field_name: std::option::Option<#inner_ty>, 317 | }; 318 | quote! { #attr #main } 319 | }); 320 | 321 | 322 | // Some simple ones 323 | parts.empty_exprs.push(quote! { std::option::Option::None }); 324 | parts.fallback_exprs.push(quote! { self.#field_name.or(fallback.#field_name) }); 325 | parts.is_empty_exprs.push(quote! { self.#field_name.is_none() }); 326 | if kind.is_required() { 327 | parts.is_complete_exprs.push(quote! { self.#field_name.is_some() }); 328 | } 329 | 330 | // Code for `Partial::default_values()` 331 | parts.default_exprs.push(match kind { 332 | LeafKind::Required { default: Some(default), .. } => { 333 | let msg = format!("default config value for `{qualified_name}` \ 334 | cannot be deserialized"); 335 | let expr = default_value_to_deserializable_expr(&default); 336 | quote! { 337 | std::option::Option::Some( 338 | #deserialize_fn(confique::internal::into_deserializer(#expr)) 339 | .expect(#msg) 340 | ) 341 | } 342 | } 343 | _ => quote! { std::option::Option::None }, 344 | }); 345 | 346 | // Code for `Partial::from_env()` 347 | parts.from_env_exprs.push(match (env, parse_env) { 348 | (None, _) => quote! { std::option::Option::None }, 349 | (Some(key), None) => quote! { 350 | confique::internal::from_env(#key, #qualified_name, #deserialize_fn)? 351 | }, 352 | (Some(key), Some(parse_env)) => { 353 | let validator = match &validate_fn { 354 | Some(f) => quote! { #f }, 355 | None => quote! { |_| std::result::Result::<(), String>::Ok(()) }, 356 | }; 357 | quote! { 358 | confique::internal::from_env_with_parser( 359 | #key, #qualified_name, #parse_env, #validator)? 360 | } 361 | } 362 | }); 363 | } 364 | } 365 | } 366 | 367 | /// Returns the names of the module and struct for the partial type: 368 | /// `(mod_name, struct_name)`. 369 | fn partial_names(original_name: &Ident) -> (Ident, Ident) { 370 | use heck::ToSnakeCase; 371 | ( 372 | format_ident!("confique_partial_{}", original_name.to_string().to_snake_case()), 373 | format_ident!("Partial{original_name}"), 374 | ) 375 | } 376 | 377 | /// Generates a Rust expression from the default value that implemenets 378 | /// `serde::de::IntoDeserializer`. 379 | fn default_value_to_deserializable_expr(expr: &ir::Expr) -> TokenStream { 380 | match expr { 381 | ir::Expr::Str(lit) => quote! { #lit }, 382 | ir::Expr::Int(lit) => quote! { #lit }, 383 | ir::Expr::Float(lit) => quote! { #lit }, 384 | ir::Expr::Bool(lit) => quote! { #lit }, 385 | ir::Expr::Array(arr) => { 386 | let items = arr.iter().map(default_value_to_deserializable_expr); 387 | 388 | // Empty arrays cause "cannot infer type" errors here. However, it 389 | // really doesn't matter what type the array has as there are 0 390 | // elements anyway. So we just pick `()`. 391 | let type_annotation = if arr.is_empty() { 392 | quote! { as [(); 0] } 393 | } else { 394 | quote! {} 395 | }; 396 | quote! { confique::internal::ArrayIntoDeserializer([ #(#items),* ] #type_annotation) } 397 | }, 398 | ir::Expr::Map(entries) => { 399 | let items = entries.iter().map(|e| { 400 | let key = default_value_to_deserializable_expr(&e.key.clone().into()); 401 | let value = default_value_to_deserializable_expr(&e.value); 402 | quote! { (#key, #value) } 403 | }); 404 | 405 | // Empty arrays cause "cannot infer type" errors here. However, it 406 | // really doesn't matter what type the array has as there are 0 407 | // elements anyway. So we just pick `()`. 408 | let type_annotation = if entries.is_empty() { 409 | quote! { as Vec<((), ())> } 410 | } else { 411 | quote! {} 412 | }; 413 | quote! { confique::internal::MapIntoDeserializer(vec![ #(#items),* ] #type_annotation) } 414 | }, 415 | } 416 | } 417 | 418 | /// Returns tokens defining the visibility of the items in the inner module. 419 | fn inner_visibility(outer: &syn::Visibility, span: Span) -> TokenStream { 420 | match outer { 421 | // These visibilities can be used as they are. No adjustment needed. 422 | syn::Visibility::Public(_) => quote_spanned! {span=> #outer }, 423 | syn::Visibility::Restricted(r) if r.path.is_ident("crate") && r.in_token.is_none() => { 424 | quote_spanned! {span=> #outer } 425 | }, 426 | 427 | // The inherited one is relative to the parent module. 428 | syn::Visibility::Inherited => quote_spanned! {span=> pub(super) }, 429 | 430 | // For `pub(crate)` 431 | syn::Visibility::Restricted(r) if r.path.is_ident("crate") && r.in_token.is_none() => { 432 | quote_spanned! {span=> pub(crate) } 433 | }, 434 | 435 | // If the path in the `pub(in )` visibility is absolute, we can 436 | // use it like that as well. 437 | syn::Visibility::Restricted(r) if r.path.leading_colon.is_some() => { 438 | quote_spanned! {span=> #outer } 439 | }, 440 | 441 | // But in the case `pub(in )` with a relative path, we have to 442 | // prefix `super::`. 443 | syn::Visibility::Restricted(r) => { 444 | let path = &r.path; 445 | quote_spanned! {span=> pub(in super::#path) } 446 | } 447 | } 448 | } 449 | -------------------------------------------------------------------------------- /macro/src/ir.rs: -------------------------------------------------------------------------------- 1 | //! Definition of the intermediate representation. 2 | 3 | use proc_macro2::TokenStream; 4 | 5 | 6 | /// The parsed input to the `gen_config` macro. 7 | pub(crate) struct Input { 8 | pub(crate) doc: Vec, 9 | pub(crate) visibility: syn::Visibility, 10 | pub(crate) partial_attrs: Vec, 11 | pub(crate) validate: Option, 12 | pub(crate) name: syn::Ident, 13 | pub(crate) fields: Vec, 14 | } 15 | 16 | pub(crate) struct Field { 17 | pub(crate) doc: Vec, 18 | pub(crate) name: syn::Ident, 19 | pub(crate) kind: FieldKind, 20 | 21 | // TODO: 22 | // - serde attributes 23 | // - attributes 24 | // - example 25 | } 26 | 27 | pub(crate) enum FieldKind { 28 | Leaf { 29 | env: Option, 30 | deserialize_with: Option, 31 | parse_env: Option, 32 | validate: Option, 33 | kind: LeafKind, 34 | }, 35 | 36 | /// A nested configuration. The type is never `Option<_>`. 37 | Nested { 38 | ty: syn::Type, 39 | }, 40 | } 41 | 42 | pub(crate) enum FieldValidator { 43 | Fn(syn::Path), 44 | Simple(TokenStream, String), 45 | } 46 | 47 | pub(crate) enum LeafKind { 48 | /// A non-optional leaf. `ty` is not `Option<_>`. 49 | Required { 50 | default: Option, 51 | ty: syn::Type, 52 | }, 53 | 54 | /// A leaf with type `Option<_>`. 55 | Optional { 56 | inner_ty: syn::Type, 57 | }, 58 | } 59 | 60 | impl LeafKind { 61 | pub(crate) fn is_required(&self) -> bool { 62 | matches!(self, Self::Required { .. }) 63 | } 64 | 65 | pub(crate) fn inner_ty(&self) -> &syn::Type { 66 | match self { 67 | Self::Required { ty, .. } => ty, 68 | Self::Optional { inner_ty } => inner_ty, 69 | } 70 | } 71 | } 72 | 73 | /// The kinds of expressions (just literals) we allow for default or example 74 | /// values. 75 | pub(crate) enum Expr { 76 | Str(syn::LitStr), 77 | Int(syn::LitInt), 78 | Float(syn::LitFloat), 79 | Bool(syn::LitBool), 80 | Array(Vec), 81 | Map(Vec), 82 | } 83 | 84 | pub(crate) struct MapEntry { 85 | pub(crate) key: MapKey, 86 | pub(crate) value: Expr, 87 | } 88 | 89 | #[derive(Clone)] 90 | pub(crate) enum MapKey { 91 | Str(syn::LitStr), 92 | Int(syn::LitInt), 93 | Float(syn::LitFloat), 94 | Bool(syn::LitBool), 95 | } 96 | 97 | impl From for Expr { 98 | fn from(src: MapKey) -> Self { 99 | match src { 100 | MapKey::Str(v) => Self::Str(v), 101 | MapKey::Int(v) => Self::Int(v), 102 | MapKey::Float(v) => Self::Float(v), 103 | MapKey::Bool(v) => Self::Bool(v), 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /macro/src/lib.rs: -------------------------------------------------------------------------------- 1 | use proc_macro::TokenStream as TokenStream1; 2 | 3 | 4 | mod gen; 5 | mod ir; 6 | mod parse; 7 | mod util; 8 | 9 | 10 | #[proc_macro_derive(Config, attributes(config))] 11 | pub fn config(input: TokenStream1) -> TokenStream1 { 12 | syn::parse2::(input.into()) 13 | .and_then(ir::Input::from_ast) 14 | .map(gen::gen) 15 | .unwrap_or_else(|e| e.to_compile_error()) 16 | .into() 17 | } 18 | -------------------------------------------------------------------------------- /macro/src/parse.rs: -------------------------------------------------------------------------------- 1 | use proc_macro2::{Delimiter, Group, Ident, TokenStream, TokenTree}; 2 | use syn::{Error, Token, parse::{Parse, ParseStream}, spanned::Spanned, punctuated::Punctuated}; 3 | 4 | use crate::{ 5 | ir::{Expr, Field, FieldKind, FieldValidator, Input, LeafKind, MapEntry, MapKey}, 6 | util::{is_option, unwrap_option}, 7 | }; 8 | 9 | 10 | impl Input { 11 | pub(crate) fn from_ast(mut input: syn::DeriveInput) -> Result { 12 | let fields = match input.data { 13 | syn::Data::Struct(syn::DataStruct { fields: syn::Fields::Named(f), .. }) => f, 14 | _ => return Err(Error::new( 15 | input.span(), 16 | "`confique::Config` can only be derive for structs with named fields", 17 | )), 18 | }; 19 | 20 | let doc = extract_doc(&mut input.attrs); 21 | let attrs = StructAttrs::extract(&mut input.attrs)?; 22 | let fields = fields.named.into_iter() 23 | .map(Field::from_ast) 24 | .collect::, _>>()?; 25 | 26 | 27 | Ok(Self { 28 | doc, 29 | visibility: input.vis, 30 | partial_attrs: attrs.partial_attrs, 31 | validate: attrs.validate, 32 | name: input.ident, 33 | fields, 34 | }) 35 | } 36 | } 37 | 38 | // ===== Attributes on the struct ===================================================== 39 | 40 | #[derive(Default)] 41 | struct StructAttrs { 42 | partial_attrs: Vec, 43 | validate: Option, 44 | } 45 | 46 | enum StructAttr { 47 | PartialAttrs(TokenStream), 48 | Validate(syn::Path), 49 | } 50 | 51 | impl StructAttrs { 52 | fn extract(attrs: &mut Vec) -> Result { 53 | let attrs = extract_config_attrs(attrs); 54 | 55 | let mut out = Self::default(); 56 | for attr in attrs { 57 | type AttrList = Punctuated; 58 | let parsed_list = attr.parse_args_with(AttrList::parse_terminated)?; 59 | 60 | for parsed in parsed_list { 61 | let keyword = parsed.keyword(); 62 | 63 | macro_rules! duplicate_if { 64 | ($cond:expr) => { 65 | if $cond { 66 | let msg = format!("duplicate '{keyword}' confique attribute"); 67 | return Err(Error::new(attr.path().span(), msg)); 68 | } 69 | }; 70 | } 71 | 72 | match parsed { 73 | StructAttr::PartialAttrs(tokens) => out.partial_attrs.push(tokens), 74 | StructAttr::Validate(path) => { 75 | duplicate_if!(out.validate.is_some()); 76 | out.validate = Some(path); 77 | } 78 | } 79 | } 80 | } 81 | 82 | Ok(out) 83 | } 84 | } 85 | 86 | impl StructAttr { 87 | fn keyword(&self) -> &'static str { 88 | match self { 89 | Self::PartialAttrs(_) => "partial_attr", 90 | Self::Validate(_) => "validate", 91 | } 92 | } 93 | } 94 | 95 | impl Parse for StructAttr { 96 | fn parse(input: ParseStream) -> syn::Result { 97 | let ident: Ident = input.parse()?; 98 | match &*ident.to_string() { 99 | "partial_attr" => { 100 | let g: Group = input.parse()?; 101 | if g.delimiter() != Delimiter::Parenthesis { 102 | return Err(Error::new_spanned(g, 103 | "expected `(...)` but found different delimiter")); 104 | } 105 | assert_empty_or_comma(&input)?; 106 | Ok(Self::PartialAttrs(g.stream())) 107 | } 108 | "validate" => parse_eq_value(input).map(Self::Validate), 109 | _ => Err(syn::Error::new(ident.span(), "unknown confique attribute")), 110 | } 111 | } 112 | } 113 | 114 | 115 | // ===== Struct fields ============================================================= 116 | 117 | impl Field { 118 | fn from_ast(mut field: syn::Field) -> Result { 119 | let doc = extract_doc(&mut field.attrs); 120 | let attrs = FieldAttrs::extract(&mut field.attrs)?; 121 | 122 | let err = |msg| Err(Error::new(field.ident.span(), msg)); 123 | 124 | // TODO: check no other attributes are here 125 | let kind = if attrs.nested { 126 | if is_option(&field.ty) { 127 | return err("nested configurations cannot be optional (type `Option<_>`)"); 128 | } 129 | 130 | let conflicting_attrs = [ 131 | ("default", attrs.default.is_some()), 132 | ("env", attrs.env.is_some()), 133 | ("deserialize_with", attrs.deserialize_with.is_some()), 134 | ("validate", attrs.validate.is_some()), 135 | ]; 136 | 137 | for (keyword, is_set) in conflicting_attrs { 138 | if is_set { 139 | return Err(Error::new( 140 | field.ident.span(), 141 | format!("cannot specify `nested` and `{keyword}` \ 142 | attributes at the same time") 143 | )); 144 | } 145 | } 146 | 147 | FieldKind::Nested { ty: field.ty } 148 | } else { 149 | if attrs.env.is_none() && attrs.parse_env.is_some() { 150 | return err("cannot specify `parse_env` attribute without the `env` attribute"); 151 | } 152 | 153 | let kind = match unwrap_option(&field.ty) { 154 | Some(_) if attrs.default.is_some() => { 155 | return err("optional fields (type `Option<_>`) cannot have default \ 156 | values (`#[config(default = ...)]`)"); 157 | }, 158 | Some(inner) => LeafKind::Optional { inner_ty: inner.clone() }, 159 | None => LeafKind::Required { default: attrs.default, ty: field.ty }, 160 | }; 161 | 162 | FieldKind::Leaf { 163 | env: attrs.env, 164 | deserialize_with: attrs.deserialize_with, 165 | parse_env: attrs.parse_env, 166 | validate: attrs.validate, 167 | kind, 168 | } 169 | }; 170 | 171 | Ok(Self { 172 | doc, 173 | name: field.ident.expect("bug: expected named field"), 174 | kind, 175 | }) 176 | } 177 | } 178 | 179 | 180 | // ===== Attributes on fields ===================================================== 181 | 182 | #[derive(Default)] 183 | struct FieldAttrs { 184 | nested: bool, 185 | default: Option, 186 | env: Option, 187 | deserialize_with: Option, 188 | parse_env: Option, 189 | validate: Option, 190 | } 191 | 192 | enum FieldAttr { 193 | Nested, 194 | Default(Expr), 195 | Env(String), 196 | DeserializeWith(syn::Path), 197 | ParseEnv(syn::Path), 198 | Validate(FieldValidator), 199 | } 200 | 201 | impl FieldAttrs { 202 | fn extract(attrs: &mut Vec) -> Result { 203 | let attrs = extract_config_attrs(attrs); 204 | 205 | let mut out = FieldAttrs::default(); 206 | for attr in attrs { 207 | type AttrList = Punctuated; 208 | let parsed_list = attr.parse_args_with(AttrList::parse_terminated)?; 209 | 210 | for parsed in parsed_list { 211 | let keyword = parsed.keyword(); 212 | 213 | macro_rules! duplicate_if { 214 | ($cond:expr) => { 215 | if $cond { 216 | let msg = format!("duplicate '{keyword}' confique attribute"); 217 | return Err(Error::new(attr.path().span(), msg)); 218 | } 219 | }; 220 | } 221 | 222 | match parsed { 223 | FieldAttr::Default(expr) => { 224 | duplicate_if!(out.default.is_some()); 225 | out.default = Some(expr); 226 | } 227 | FieldAttr::Nested => { 228 | duplicate_if!(out.nested); 229 | out.nested = true; 230 | } 231 | FieldAttr::Env(key) => { 232 | duplicate_if!(out.env.is_some()); 233 | out.env = Some(key); 234 | } 235 | FieldAttr::ParseEnv(path) => { 236 | duplicate_if!(out.parse_env.is_some()); 237 | out.parse_env = Some(path); 238 | } 239 | FieldAttr::DeserializeWith(path) => { 240 | duplicate_if!(out.deserialize_with.is_some()); 241 | out.deserialize_with = Some(path); 242 | } 243 | FieldAttr::Validate(path) => { 244 | duplicate_if!(out.validate.is_some()); 245 | out.validate = Some(path); 246 | } 247 | } 248 | } 249 | } 250 | 251 | Ok(out) 252 | } 253 | } 254 | 255 | impl FieldAttr { 256 | fn keyword(&self) -> &'static str { 257 | match self { 258 | Self::Nested => "nested", 259 | Self::Default(_) => "default", 260 | Self::Env(_) => "env", 261 | Self::ParseEnv(_) => "parse_env", 262 | Self::DeserializeWith(_) => "deserialize_with", 263 | Self::Validate(_) => "validate", 264 | } 265 | } 266 | } 267 | 268 | impl Parse for FieldAttr { 269 | fn parse(input: ParseStream) -> Result { 270 | let ident: syn::Ident = input.parse()?; 271 | match &*ident.to_string() { 272 | "nested" => { 273 | assert_empty_or_comma(input)?; 274 | Ok(Self::Nested) 275 | } 276 | 277 | "default" => parse_eq_value(input).map(Self::Default), 278 | 279 | "env" => { 280 | let key: syn::LitStr = parse_eq_value(input)?; 281 | let value = key.value(); 282 | if value.contains('=') || value.contains('\0') { 283 | return Err(syn::Error::new( 284 | key.span(), 285 | "environment variable key must not contain '=' or null bytes", 286 | )); 287 | } 288 | 289 | Ok(Self::Env(value)) 290 | } 291 | 292 | "parse_env" => parse_eq_value(input).map(Self::ParseEnv), 293 | "deserialize_with" => parse_eq_value(input).map(Self::DeserializeWith), 294 | "validate" => { 295 | if input.peek(Token![=]) { 296 | parse_eq_value(input).map(|path| Self::Validate(FieldValidator::Fn(path))) 297 | } else if input.peek(syn::token::Paren) { 298 | let g: Group = input.parse()?; 299 | 300 | // Instead of properly parsing an expression, which would 301 | // require the `full` feature of syn, increasing compile 302 | // time, we just validate the last two/three tokens and 303 | // just assume the tokens before are a valid expression. 304 | let mut tokens = g.stream().into_iter().collect::>(); 305 | if tokens.len() < 3 { 306 | return Err(syn::Error::new( 307 | g.span(), 308 | "expected at least three tokens, found fewer", 309 | )); 310 | } 311 | 312 | // Ignore trailing comma 313 | if is_comma(tokens.last().unwrap()) { 314 | let _ = tokens.pop(); 315 | } 316 | 317 | let msg = as_string_lit(tokens.pop().unwrap())?; 318 | let sep_comma = tokens.pop().unwrap(); 319 | if !is_comma(&sep_comma) { 320 | return Err(syn::Error::new(sep_comma.span(), "expected comma")); 321 | } 322 | 323 | Ok(Self::Validate(FieldValidator::Simple(tokens.into_iter().collect(), msg))) 324 | } else { 325 | Err(syn::Error::new( 326 | ident.span(), 327 | "expected `validate = path::to::fun` or `validate(, \"error msg\")`, \ 328 | but found different token", 329 | )) 330 | } 331 | } 332 | 333 | _ => Err(syn::Error::new(ident.span(), "unknown confique attribute")), 334 | } 335 | } 336 | } 337 | 338 | 339 | // ===== Expr ===================================================================== 340 | 341 | impl Parse for Expr { 342 | fn parse(input: ParseStream) -> Result { 343 | let msg = "invalid default value. Allowed are only: certain literals \ 344 | (string, integer, float, bool), and arrays"; 345 | 346 | if input.peek(syn::token::Bracket) { 347 | // ----- Array ----- 348 | let content; 349 | syn::bracketed!(content in input); 350 | 351 | let items = >::parse_terminated(&content)?; 352 | Ok(Self::Array(items.into_iter().collect())) 353 | } else if input.peek(syn::token::Brace) { 354 | // ----- Map ----- 355 | let content; 356 | syn::braced!(content in input); 357 | 358 | let items = >::parse_terminated(&content)?; 359 | Ok(Self::Map(items.into_iter().collect())) 360 | } else { 361 | // ----- Literal ----- 362 | 363 | // We just use `MapKey` here as it's exactly what we want, despite 364 | // this not having anything to do with maps. 365 | input.parse::() 366 | .map_err(|_| Error::new(input.span(), msg)) 367 | .map(Into::into) 368 | } 369 | } 370 | } 371 | 372 | impl Parse for MapEntry { 373 | fn parse(input: ParseStream) -> Result { 374 | let key: MapKey = input.parse()?; 375 | let _: syn::Token![:] = input.parse()?; 376 | let value: Expr = input.parse()?; 377 | Ok(Self { key, value }) 378 | } 379 | } 380 | 381 | impl Parse for MapKey { 382 | fn parse(input: ParseStream) -> Result { 383 | let lit: syn::Lit = input.parse()?; 384 | match lit { 385 | syn::Lit::Str(l) => Ok(Self::Str(l)), 386 | syn::Lit::Int(l) => Ok(Self::Int(l)), 387 | syn::Lit::Float(l) => Ok(Self::Float(l)), 388 | syn::Lit::Bool(l) => Ok(Self::Bool(l)), 389 | _ => Err(Error::new( 390 | lit.span(), 391 | "only string, integer, float, and Boolean literals allowed as map key", 392 | )), 393 | } 394 | } 395 | } 396 | 397 | 398 | // ===== Util ===================================================================== 399 | 400 | fn assert_empty_or_comma(input: ParseStream) -> Result<(), Error> { 401 | if input.is_empty() || input.peek(Token![,]) { 402 | Ok(()) 403 | } else { 404 | Err(Error::new(input.span(), "unexpected tokens, expected no more tokens in this context")) 405 | } 406 | } 407 | 408 | fn is_comma(tt: &TokenTree) -> bool { 409 | matches!(tt, TokenTree::Punct(p) if p.as_char() == ',') 410 | } 411 | 412 | fn as_string_lit(tt: TokenTree) -> Result { 413 | let lit = match tt { 414 | TokenTree::Literal(lit) => syn::Lit::new(lit), 415 | t => return Err(syn::Error::new(t.span(), "expected string literal")), 416 | }; 417 | match lit { 418 | syn::Lit::Str(s) => Ok(s.value()), 419 | l => return Err(syn::Error::new(l.span(), "expected string literal")), 420 | } 421 | } 422 | 423 | /// Parses a `=` followed by `T`, and asserts that the input is either empty or 424 | /// a comma follows. 425 | fn parse_eq_value(input: ParseStream) -> Result { 426 | let _: Token![=] = input.parse()?; 427 | let out: T = input.parse()?; 428 | assert_empty_or_comma(&input)?; 429 | Ok(out) 430 | } 431 | 432 | /// Extracts all doc string attributes from the list and returns them as list of 433 | /// strings (in order). 434 | fn extract_doc(attrs: &mut Vec) -> Vec { 435 | extract_attrs(attrs, |attr| { 436 | match &attr.meta { 437 | syn::Meta::NameValue(syn::MetaNameValue { 438 | value: syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(s), .. }), 439 | path, 440 | .. 441 | }) if path.is_ident("doc") => Some(s.value()), 442 | _ => None, 443 | } 444 | }) 445 | } 446 | 447 | 448 | fn extract_config_attrs(attrs: &mut Vec) -> Vec { 449 | extract_attrs(attrs, |attr| { 450 | if attr.path().is_ident("config") { 451 | // TODO: clone not necessary once we use drain_filter 452 | Some(attr.clone()) 453 | } else { 454 | None 455 | } 456 | }) 457 | } 458 | 459 | fn extract_attrs(attrs: &mut Vec, mut pred: P) -> Vec 460 | where 461 | P: FnMut(&syn::Attribute) -> Option, 462 | { 463 | // TODO: use `Vec::drain_filter` once stabilized. The current impl is O(n²). 464 | let mut i = 0; 465 | let mut out = Vec::new(); 466 | while i < attrs.len() { 467 | match pred(&attrs[i]) { 468 | Some(v) => { 469 | out.push(v); 470 | attrs.remove(i); 471 | } 472 | None => i += 1, 473 | } 474 | } 475 | 476 | out 477 | } 478 | -------------------------------------------------------------------------------- /macro/src/util.rs: -------------------------------------------------------------------------------- 1 | 2 | /// Checks if the given type is an `Option` and if so, return the inner type. 3 | /// 4 | /// Note: this function clearly shows one of the major shortcomings of proc 5 | /// macros right now: we do not have access to the compiler's type tables and 6 | /// can only check if it "looks" like an `Option`. Of course, stuff can go 7 | /// wrong. But that's the best we can do and it's highly unlikely that someone 8 | /// shadows `Option`. 9 | pub(crate) fn unwrap_option(ty: &syn::Type) -> Option<&syn::Type> { 10 | let ty = match ty { 11 | syn::Type::Path(path) => path, 12 | _ => return None, 13 | }; 14 | 15 | if ty.qself.is_some() || ty.path.leading_colon.is_some() { 16 | return None; 17 | } 18 | 19 | let valid_paths = [ 20 | &["Option"] as &[_], 21 | &["std", "option", "Option"], 22 | &["core", "option", "Option"], 23 | ]; 24 | if !valid_paths.iter().any(|vp| ty.path.segments.iter().map(|s| &s.ident).eq(*vp)) { 25 | return None; 26 | } 27 | 28 | let args = match &ty.path.segments.last().unwrap().arguments { 29 | syn::PathArguments::AngleBracketed(args) => args, 30 | _ => return None, 31 | }; 32 | 33 | if args.args.len() != 1 { 34 | return None; 35 | } 36 | 37 | match &args.args[0] { 38 | syn::GenericArgument::Type(t) => Some(t), 39 | _ => None, 40 | } 41 | } 42 | 43 | /// Returns `true` if the given type is `Option<_>`. 44 | pub(crate) fn is_option(ty: &syn::Type) -> bool { 45 | unwrap_option(ty).is_some() 46 | } 47 | -------------------------------------------------------------------------------- /src/builder.rs: -------------------------------------------------------------------------------- 1 | #[cfg(any(feature = "toml", feature = "yaml", feature = "json5"))] 2 | use std::path::PathBuf; 3 | 4 | use crate::{Config, Error, Partial}; 5 | 6 | #[cfg(any(feature = "toml", feature = "yaml", feature = "json5"))] 7 | use crate::File; 8 | 9 | 10 | 11 | /// Convenience builder to configure, load and merge multiple configuration 12 | /// sources. 13 | /// 14 | /// **Sources specified earlier have a higher priority**. Obtained via 15 | /// [`Config::builder`]. 16 | pub struct Builder { 17 | sources: Vec>, 18 | } 19 | 20 | impl Builder { 21 | pub(crate) fn new() -> Self { 22 | Self { sources: vec![] } 23 | } 24 | 25 | /// Adds a configuration file as source. Infers the format from the file 26 | /// extension. If the path has no file extension or the extension is 27 | /// unknown, [`Builder::load`] will return an error. 28 | /// 29 | /// The file is not considered required: if the file does not exist, an 30 | /// empty configuration (`C::Partial::empty()`) is used for this layer. 31 | #[cfg(any(feature = "toml", feature = "yaml", feature = "json5"))] 32 | pub fn file(mut self, path: impl Into) -> Self { 33 | self.sources.push(Source::File(path.into())); 34 | self 35 | } 36 | 37 | /// Adds the environment variables as a source. 38 | pub fn env(mut self) -> Self { 39 | self.sources.push(Source::Env); 40 | self 41 | } 42 | 43 | /// Adds an already loaded partial configuration as source. 44 | pub fn preloaded(mut self, partial: C::Partial) -> Self { 45 | self.sources.push(Source::Preloaded(partial)); 46 | self 47 | } 48 | 49 | /// Loads all configured sources in order. Earlier sources have a higher 50 | /// priority, later sources only fill potential gaps. 51 | /// 52 | /// Will return an error if loading the sources fails or if the merged 53 | /// configuration does not specify all required values. 54 | pub fn load(self) -> Result { 55 | let mut partial = C::Partial::empty(); 56 | for source in self.sources { 57 | let layer = match source { 58 | #[cfg(any(feature = "toml", feature = "yaml", feature = "json5"))] 59 | Source::File(path) => File::new(path)?.load()?, 60 | Source::Env => C::Partial::from_env()?, 61 | Source::Preloaded(p) => p, 62 | }; 63 | 64 | partial = partial.with_fallback(layer); 65 | } 66 | 67 | C::from_partial(partial.with_fallback(C::Partial::default_values())) 68 | } 69 | } 70 | 71 | enum Source { 72 | #[cfg(any(feature = "toml", feature = "yaml", feature = "json5"))] 73 | File(PathBuf), 74 | Env, 75 | Preloaded(C::Partial), 76 | } 77 | -------------------------------------------------------------------------------- /src/env/mod.rs: -------------------------------------------------------------------------------- 1 | //! Deserialize values from environment variables. 2 | 3 | use std::fmt; 4 | 5 | use serde::de::IntoDeserializer; 6 | 7 | 8 | pub mod parse; 9 | 10 | 11 | /// Error type only for deserialization of env values. 12 | /// 13 | /// Semantically private, only public as it's used in the API of the `internal` 14 | /// module. Gets converted into `ErrorKind::EnvDeserialization` before reaching 15 | /// the real public API. 16 | #[derive(PartialEq, Eq)] 17 | #[doc(hidden)] 18 | pub struct DeError(pub(crate) String); 19 | 20 | impl std::error::Error for DeError {} 21 | 22 | impl fmt::Debug for DeError { 23 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 24 | fmt::Display::fmt(self, f) 25 | } 26 | } 27 | 28 | impl fmt::Display for DeError { 29 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 30 | self.0.fmt(f) 31 | } 32 | } 33 | 34 | impl serde::de::Error for DeError { 35 | fn custom(msg: T) -> Self 36 | where 37 | T: fmt::Display, 38 | { 39 | Self(msg.to_string()) 40 | } 41 | } 42 | 43 | 44 | /// Deserializer type. Semantically private (see `DeError`). 45 | #[doc(hidden)] 46 | pub struct Deserializer { 47 | value: String, 48 | } 49 | 50 | impl Deserializer { 51 | pub(crate) fn new(value: String) -> Self { 52 | Self { value } 53 | } 54 | } 55 | 56 | macro_rules! deserialize_via_parse { 57 | ($method:ident, $visit_method:ident, $int:ident) => { 58 | fn $method(self, visitor: V) -> Result 59 | where 60 | V: serde::de::Visitor<'de>, 61 | { 62 | let s = self.value.trim(); 63 | let v = s.parse().map_err(|e| { 64 | DeError(format!( 65 | concat!("invalid value '{}' for type ", stringify!($int), ": {}"), 66 | s, e, 67 | )) 68 | })?; 69 | visitor.$visit_method(v) 70 | } 71 | }; 72 | } 73 | 74 | impl<'de> serde::Deserializer<'de> for Deserializer { 75 | type Error = DeError; 76 | 77 | fn deserialize_any(self, visitor: V) -> Result 78 | where 79 | V: serde::de::Visitor<'de>, 80 | { 81 | self.value.into_deserializer().deserialize_any(visitor) 82 | } 83 | 84 | fn deserialize_bool(self, visitor: V) -> Result 85 | where 86 | V: serde::de::Visitor<'de>, 87 | { 88 | let s = self.value.trim(); 89 | let v = match () { 90 | () if s == "1" 91 | || s.eq_ignore_ascii_case("true") 92 | || s.eq_ignore_ascii_case("yes") => true, 93 | 94 | () if s == "0" 95 | || s.eq_ignore_ascii_case("false") 96 | || s.eq_ignore_ascii_case("no") => false, 97 | _ => return Err(DeError(format!("invalid value for bool: '{s}'"))), 98 | }; 99 | 100 | visitor.visit_bool(v) 101 | } 102 | 103 | deserialize_via_parse!(deserialize_i8, visit_i8, i8); 104 | deserialize_via_parse!(deserialize_i16, visit_i16, i16); 105 | deserialize_via_parse!(deserialize_i32, visit_i32, i32); 106 | deserialize_via_parse!(deserialize_i64, visit_i64, i64); 107 | deserialize_via_parse!(deserialize_u8, visit_u8, u8); 108 | deserialize_via_parse!(deserialize_u16, visit_u16, u16); 109 | deserialize_via_parse!(deserialize_u32, visit_u32, u32); 110 | deserialize_via_parse!(deserialize_u64, visit_u64, u64); 111 | deserialize_via_parse!(deserialize_f32, visit_f32, f32); 112 | deserialize_via_parse!(deserialize_f64, visit_f64, f64); 113 | 114 | fn deserialize_newtype_struct( 115 | self, 116 | _: &'static str, 117 | visitor: V, 118 | ) -> Result 119 | where 120 | V: serde::de::Visitor<'de>, 121 | { 122 | visitor.visit_newtype_struct(self) 123 | } 124 | 125 | fn deserialize_enum( 126 | self, 127 | _name: &str, 128 | _variants: &'static [&'static str], 129 | visitor: V, 130 | ) -> Result 131 | where 132 | V: serde::de::Visitor<'de>, 133 | { 134 | visitor.visit_enum(self.value.into_deserializer()) 135 | } 136 | 137 | serde::forward_to_deserialize_any! { 138 | char str string 139 | bytes byte_buf 140 | unit unit_struct 141 | map 142 | option 143 | struct 144 | identifier 145 | ignored_any 146 | 147 | // TODO: think about manually implementing these 148 | seq 149 | tuple tuple_struct 150 | } 151 | } 152 | 153 | 154 | #[cfg(test)] 155 | mod tests; 156 | -------------------------------------------------------------------------------- /src/env/parse.rs: -------------------------------------------------------------------------------- 1 | //! Functions for the `#[config(parse_env = ...)]` attribute. 2 | 3 | use std::str::FromStr; 4 | 5 | /// Splits the environment variable by separator `SEP`, parses each element 6 | /// with [`FromStr`] and collects everything via [`FromIterator`]. 7 | /// 8 | /// To avoid having to specify the separator via `::<>` syntax, see the 9 | /// other functions in this module. 10 | /// 11 | /// [`FromStr`]: std::str::FromStr 12 | /// [`FromIterator`]: std::iter::FromIterator 13 | /// 14 | /// 15 | /// # Example 16 | /// 17 | /// ``` 18 | /// use confique::Config; 19 | /// 20 | /// #[derive(Debug, confique::Config)] 21 | /// struct Conf { 22 | /// #[config(env = "PORTS", parse_env = confique::env::parse::list_by_sep::<',', _, _>)] 23 | /// ports: Vec, 24 | /// } 25 | /// 26 | /// std::env::set_var("PORTS", "8080,8000,8888"); 27 | /// let conf = Conf::builder().env().load()?; 28 | /// assert_eq!(conf.ports, vec![8080, 8000, 8888]); 29 | /// # Ok::<_, confique::Error>(()) 30 | /// ``` 31 | pub fn list_by_sep(input: &str) -> Result::Err> 32 | where 33 | T: FromStr, 34 | C: FromIterator, 35 | { 36 | input.split(SEP).map(T::from_str).collect() 37 | } 38 | 39 | 40 | macro_rules! specify_fn_wrapper { 41 | ($fn_name:ident, $sep:literal) => { 42 | #[doc = concat!("Like [`list_by_sep`] with `", $sep, "` separator.")] 43 | pub fn $fn_name(input: &str) -> Result::Err> 44 | where 45 | T: FromStr, 46 | C: FromIterator, 47 | { 48 | list_by_sep::<$sep, _, _>(input) 49 | } 50 | } 51 | } 52 | 53 | specify_fn_wrapper!(list_by_comma, ','); 54 | specify_fn_wrapper!(list_by_semicolon, ';'); 55 | specify_fn_wrapper!(list_by_colon, ':'); 56 | specify_fn_wrapper!(list_by_space, ' '); 57 | -------------------------------------------------------------------------------- /src/env/tests.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | fn de<'de, T: serde::Deserialize<'de>>(v: &'static str) -> Result { 4 | T::deserialize(Deserializer { value: v.into() }) 5 | } 6 | 7 | 8 | #[test] 9 | fn boolean() { 10 | assert_eq!(de("1"), Ok(true)); 11 | assert_eq!(de("true "), Ok(true)); 12 | assert_eq!(de(" True "), Ok(true)); 13 | assert_eq!(de(" TRUE"), Ok(true)); 14 | assert_eq!(de("yes"), Ok(true)); 15 | assert_eq!(de(" Yes"), Ok(true)); 16 | assert_eq!(de("YES "), Ok(true)); 17 | 18 | assert_eq!(de("0 "), Ok(false)); 19 | assert_eq!(de(" false"), Ok(false)); 20 | assert_eq!(de(" False "), Ok(false)); 21 | assert_eq!(de("FALSE "), Ok(false)); 22 | assert_eq!(de("no"), Ok(false)); 23 | assert_eq!(de(" No"), Ok(false)); 24 | assert_eq!(de("NO "), Ok(false)); 25 | } 26 | 27 | #[test] 28 | fn ints() { 29 | assert_eq!(de("0"), Ok(0u8)); 30 | assert_eq!(de("-1 "), Ok(-1i8)); 31 | assert_eq!(de(" 27"), Ok(27u16)); 32 | assert_eq!(de("-27"), Ok(-27i16)); 33 | assert_eq!(de(" 4301"), Ok(4301u32)); 34 | assert_eq!(de(" -123456"), Ok(-123456i32)); 35 | assert_eq!(de(" 986543210 "), Ok(986543210u64)); 36 | assert_eq!(de("-986543210"), Ok(-986543210i64)); 37 | } 38 | 39 | #[test] 40 | fn floats() { 41 | assert_eq!(de("3.1415"), Ok(3.1415f32)); 42 | assert_eq!(de("-123.456"), Ok(-123.456f64)); 43 | } 44 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use std::path::PathBuf; 4 | 5 | 6 | 7 | /// Type describing all errors that can occur in this library. 8 | /// 9 | /// *Note*: the `Display` and `Debug` impls of this type do not include 10 | /// information about `Error::source` by default. When showing this error to 11 | /// end users, you should traverse the `source`-chain and print each error. 12 | /// Crates like `anyhow` and `eyre` do this for you. As a convenience feature, 13 | /// you can use the "alternate" flag `#` when printing this error to include 14 | /// the source, e.g. `println!("{:#}", err)`. This will only print the direct 15 | /// source though, so a proper traversal is still preferred! 16 | pub struct Error { 17 | pub(crate) inner: Box, 18 | } 19 | 20 | impl Error { 21 | pub(crate) fn field_validation(msg: impl fmt::Display) -> Self { 22 | ErrorInner::FieldValidation { msg: msg.to_string() }.into() 23 | } 24 | } 25 | 26 | // If all these features are disabled, lots of these errors are unused. But 27 | // instead of repeating this cfg-attribute a lot in the rest of the file, we 28 | // just live with these unused variants. It's not like we need to optimize the 29 | // size of `ErrorInner`. 30 | #[cfg_attr( 31 | not(any(feature = "toml", feature = "yaml", feature = "json5")), 32 | allow(dead_code) 33 | )] 34 | pub(crate) enum ErrorInner { 35 | /// Returned by `Config::from_partial` when the partial does not contain 36 | /// values for all required configuration values. The string is a 37 | /// human-readable path to the value, e.g. `http.port`. 38 | MissingValue(String), 39 | 40 | /// An IO error occured, e.g. when reading a file. 41 | Io { 42 | path: Option, 43 | err: std::io::Error, 44 | }, 45 | 46 | /// Returned by `Source::load` implementations when deserialization fails. 47 | Deserialization { 48 | /// A human readable description for the error message, describing from 49 | /// what source it was attempted to deserialize. Completes the sentence 50 | /// "failed to deserialize configuration from ". E.g. "file 'foo.toml'" 51 | /// or "environment variable 'FOO_PORT'". 52 | source: Option, 53 | err: Box, 54 | }, 55 | 56 | /// When the env variable `key` is not Unicode. 57 | EnvNotUnicode { field: String, key: String }, 58 | 59 | /// When deserialization via `env` fails. The string is what is passed to 60 | /// `serde::de::Error::custom`. 61 | EnvDeserialization { 62 | field: String, 63 | key: String, 64 | msg: String, 65 | }, 66 | 67 | /// When a custom `parse_env` function fails. 68 | EnvParseError { 69 | field: String, 70 | key: String, 71 | err: Box, 72 | }, 73 | 74 | /// Returned by the [`Source`] impls for `Path` and `PathBuf` if the file 75 | /// extension is not supported by confique or if the corresponding Cargo 76 | /// feature of confique was not enabled. 77 | UnsupportedFileFormat { path: PathBuf }, 78 | 79 | /// Returned by the [`Source`] impls for `Path` and `PathBuf` if the path 80 | /// does not contain a file extension. 81 | MissingFileExtension { path: PathBuf }, 82 | 83 | /// A file source was marked as required but the file does not exist. 84 | MissingRequiredFile { path: PathBuf }, 85 | 86 | /// When a field validation function fails. 87 | FieldValidation { msg: String }, 88 | 89 | /// When a struct validation function fails. 90 | StructValidation { name: String, msg: String }, 91 | } 92 | 93 | impl std::error::Error for Error { 94 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 95 | match &*self.inner { 96 | ErrorInner::Io { err, .. } => Some(err), 97 | ErrorInner::Deserialization { err, .. } => Some(&**err), 98 | ErrorInner::MissingValue(_) => None, 99 | ErrorInner::EnvNotUnicode { .. } => None, 100 | ErrorInner::EnvDeserialization { .. } => None, 101 | ErrorInner::EnvParseError { err, .. } => Some(&**err), 102 | ErrorInner::UnsupportedFileFormat { .. } => None, 103 | ErrorInner::MissingFileExtension { .. } => None, 104 | ErrorInner::MissingRequiredFile { .. } => None, 105 | ErrorInner::FieldValidation { .. } => None, 106 | ErrorInner::StructValidation { .. } => None, 107 | } 108 | } 109 | } 110 | 111 | impl fmt::Display for Error { 112 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 113 | match &*self.inner { 114 | ErrorInner::MissingValue(path) => { 115 | std::write!(f, "required configuration value is missing: '{path}'") 116 | } 117 | ErrorInner::Io { path: Some(path), .. } => { 118 | std::write!(f, 119 | "IO error occured while reading configuration file '{}'", 120 | path.display(), 121 | ) 122 | } 123 | ErrorInner::Io { path: None, .. } => { 124 | std::write!(f, "IO error occured while loading configuration") 125 | } 126 | ErrorInner::Deserialization { source: Some(source), err } => { 127 | std::write!(f, "failed to deserialize configuration from {source}")?; 128 | if f.alternate() { 129 | f.write_str(": ")?; 130 | fmt::Display::fmt(&err, f)?; 131 | } 132 | Ok(()) 133 | } 134 | ErrorInner::Deserialization { source: None, err } => { 135 | std::write!(f, "failed to deserialize configuration")?; 136 | if f.alternate() { 137 | f.write_str(": ")?; 138 | fmt::Display::fmt(&err, f)?; 139 | } 140 | Ok(()) 141 | } 142 | ErrorInner::EnvNotUnicode { field, key } => { 143 | std::write!(f, "failed to load value `{field}` from \ 144 | environment variable `{key}`: value is not valid unicode") 145 | } 146 | ErrorInner::EnvDeserialization { field, key, msg } => { 147 | std::write!(f, "failed to deserialize value `{field}` from \ 148 | environment variable `{key}`: {msg}") 149 | } 150 | ErrorInner::EnvParseError { field, key, err } => { 151 | std::write!(f, "failed to parse environment variable `{key}` into \ 152 | field `{field}`")?; 153 | if f.alternate() { 154 | f.write_str(": ")?; 155 | fmt::Display::fmt(&err, f)?; 156 | } 157 | Ok(()) 158 | } 159 | ErrorInner::UnsupportedFileFormat { path } => { 160 | std::write!(f, 161 | "unknown configuration file format/extension: '{}'", 162 | path.display(), 163 | ) 164 | } 165 | ErrorInner::MissingFileExtension { path } => { 166 | std::write!(f, 167 | "cannot guess configuration file format due to missing file extension in '{}'", 168 | path.display(), 169 | ) 170 | } 171 | ErrorInner::MissingRequiredFile { path } => { 172 | std::write!(f, 173 | "required configuration file does not exist: '{}'", 174 | path.display(), 175 | ) 176 | } 177 | ErrorInner::FieldValidation { msg } => { 178 | std::write!(f, "validation failed: {msg}") 179 | } 180 | ErrorInner::StructValidation { name, msg } => { 181 | std::write!(f, "config validation of `{name}` failed: {msg}") 182 | } 183 | } 184 | } 185 | } 186 | 187 | impl fmt::Debug for Error { 188 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 189 | fmt::Display::fmt(self, f) 190 | } 191 | } 192 | 193 | impl From for Error { 194 | fn from(inner: ErrorInner) -> Self { 195 | Self { inner: Box::new(inner) } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/file.rs: -------------------------------------------------------------------------------- 1 | use std::{ffi::OsStr, fs, io, path::PathBuf}; 2 | 3 | use crate::{error::ErrorInner, Error, Partial}; 4 | 5 | 6 | /// A file as source for configuration. 7 | /// 8 | /// By default, the file is considered optional, meaning that on [`File::load`], 9 | /// if the file does not exist, [`Partial::empty()`][crate::Partial::empty] is 10 | /// returned. 11 | pub struct File { 12 | path: PathBuf, 13 | format: FileFormat, 14 | required: bool, 15 | } 16 | 17 | impl File { 18 | /// Configuration file with the given path. The format is inferred from the 19 | /// file extension. If the path does not have an extension or it is 20 | /// unknown, an error is returned. 21 | pub fn new(path: impl Into) -> Result { 22 | let path = path.into(); 23 | let ext = path 24 | .extension() 25 | .ok_or_else(|| ErrorInner::MissingFileExtension { path: path.clone() })?; 26 | let format = FileFormat::from_extension(ext) 27 | .ok_or_else(|| ErrorInner::UnsupportedFileFormat { path: path.clone() })?; 28 | 29 | Ok(Self::with_format(path, format)) 30 | } 31 | 32 | /// Config file with specified file format. 33 | pub fn with_format(path: impl Into, format: FileFormat) -> Self { 34 | Self { 35 | path: path.into(), 36 | format, 37 | required: false, 38 | } 39 | } 40 | 41 | /// Marks this file as required, meaning that [`File::load`] will return an 42 | /// error if the file does not exist. Otherwise, an empty layer (all values 43 | /// are `None`) is returned. 44 | pub fn required(mut self) -> Self { 45 | self.required = true; 46 | self 47 | } 48 | 49 | /// Attempts to load the file into the partial configuration `P`. 50 | pub fn load(&self) -> Result { 51 | // Load file contents. If the file does not exist and was not marked as 52 | // required, we just return an empty layer. 53 | let file_content = match fs::read(&self.path) { 54 | Ok(v) => v, 55 | Err(e) if e.kind() == io::ErrorKind::NotFound => { 56 | if self.required { 57 | return Err(ErrorInner::MissingRequiredFile { path: self.path.clone() }.into()); 58 | } else { 59 | return Ok(P::empty()); 60 | } 61 | } 62 | Err(e) => { 63 | return Err(ErrorInner::Io { 64 | path: Some(self.path.clone()), 65 | err: e, 66 | }.into()); 67 | } 68 | }; 69 | 70 | // Helper closure to create an error. 71 | let error = |err| { 72 | Error::from(ErrorInner::Deserialization { 73 | err, 74 | source: Some(format!("file '{}'", self.path.display())), 75 | }) 76 | }; 77 | 78 | match self.format { 79 | #[cfg(feature = "toml")] 80 | FileFormat::Toml => { 81 | let s = std::str::from_utf8(&file_content).map_err(|e| error(Box::new(e)))?; 82 | toml::from_str(s).map_err(|e| error(Box::new(e))) 83 | } 84 | 85 | #[cfg(feature = "yaml")] 86 | FileFormat::Yaml => serde_yaml::from_slice(&file_content) 87 | .map_err(|e| error(Box::new(e))), 88 | 89 | #[cfg(feature = "json5")] 90 | FileFormat::Json5 => { 91 | let s = std::str::from_utf8(&file_content).map_err(|e| error(Box::new(e)))?; 92 | json5::from_str(s).map_err(|e| error(Box::new(e))) 93 | } 94 | } 95 | } 96 | } 97 | 98 | /// All file formats supported by confique. 99 | /// 100 | /// All enum variants are `#[cfg]` guarded with the respective crate feature. 101 | pub enum FileFormat { 102 | #[cfg(feature = "toml")] 103 | Toml, 104 | #[cfg(feature = "yaml")] 105 | Yaml, 106 | #[cfg(feature = "json5")] 107 | Json5, 108 | } 109 | 110 | impl FileFormat { 111 | /// Guesses the file format from a file extension, returning `None` if the 112 | /// extension is unknown or if the respective crate feature is not enabled. 113 | pub fn from_extension(ext: impl AsRef) -> Option { 114 | match ext.as_ref().to_str()? { 115 | #[cfg(feature = "toml")] 116 | "toml" => Some(Self::Toml), 117 | 118 | #[cfg(feature = "yaml")] 119 | "yaml" | "yml" => Some(Self::Yaml), 120 | 121 | #[cfg(feature = "json5")] 122 | "json5" | "json" => Some(Self::Json5), 123 | 124 | _ => None, 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/internal.rs: -------------------------------------------------------------------------------- 1 | //! These functions are used by the code generated by the macro, but are not 2 | //! intended to be used directly. None of this is covered by semver! Do not use 3 | //! any of this directly. 4 | 5 | use std::fmt::Display; 6 | 7 | use crate::{error::ErrorInner, Error}; 8 | 9 | 10 | pub fn into_deserializer<'de, T>(src: T) -> >::Deserializer 11 | where 12 | T: serde::de::IntoDeserializer<'de>, 13 | { 14 | src.into_deserializer() 15 | } 16 | 17 | pub fn unwrap_or_missing_value_err(value: Option, path: &str) -> Result { 18 | match value { 19 | Some(v) => Ok(v), 20 | None => Err(ErrorInner::MissingValue(path.into()).into()), 21 | } 22 | } 23 | 24 | pub fn map_err_prefix_path(res: Result, prefix: &str) -> Result { 25 | res.map_err(|e| { 26 | if let ErrorInner::MissingValue(path) = &*e.inner { 27 | ErrorInner::MissingValue(format!("{prefix}.{path}")).into() 28 | } else { 29 | e 30 | } 31 | }) 32 | } 33 | 34 | pub fn validate_field( 35 | t: &T, 36 | validate: &dyn Fn(&T) -> Result<(), E>, 37 | ) -> Result<(), Error> { 38 | validate(t).map_err(Error::field_validation) 39 | } 40 | 41 | pub fn validate_struct( 42 | t: &T, 43 | validate: &dyn Fn(&T) -> Result<(), E>, 44 | struct_name: &'static str, 45 | ) -> Result<(), Error> { 46 | validate(t).map_err(|msg| ErrorInner::StructValidation { 47 | name: struct_name.into(), 48 | msg: msg.to_string() 49 | }.into()) 50 | } 51 | 52 | macro_rules! get_env_var { 53 | ($key:expr, $field:expr) => { 54 | match std::env::var($key) { 55 | Err(std::env::VarError::NotPresent) => return Ok(None), 56 | Err(std::env::VarError::NotUnicode(_)) => { 57 | let err = ErrorInner::EnvNotUnicode { 58 | key: $key.into(), 59 | field: $field.into(), 60 | }; 61 | return Err(err.into()); 62 | } 63 | Ok(s) => s, 64 | } 65 | }; 66 | } 67 | 68 | pub fn from_env( 69 | key: &str, 70 | field: &str, 71 | deserialize: fn(crate::env::Deserializer) -> Result, 72 | ) -> Result, Error> { 73 | let s = get_env_var!(key, field); 74 | let is_empty = s.is_empty(); 75 | 76 | match deserialize(crate::env::Deserializer::new(s)) { 77 | Ok(v) => Ok(Some(v)), 78 | Err(_) if is_empty => Ok(None), 79 | Err(e) => Err(ErrorInner::EnvDeserialization { 80 | key: key.into(), 81 | field: field.into(), 82 | msg: e.0, 83 | }.into()), 84 | } 85 | } 86 | 87 | pub fn from_env_with_parser( 88 | key: &str, 89 | field: &str, 90 | parse: fn(&str) -> Result, 91 | validate: fn(&T) -> Result<(), E2>, 92 | ) -> Result, Error> { 93 | let v = get_env_var!(key, field); 94 | let is_empty = v.is_empty(); 95 | match parse(&v) { 96 | Ok(v) => { 97 | match validate(&v).map_err(Error::field_validation) { 98 | Ok(()) => Ok(Some(v)), 99 | Err(_) if is_empty => Ok(None), 100 | Err(e) => Err(e), 101 | } 102 | }, 103 | Err(_) if is_empty => Ok(None), 104 | Err(err) => Err( 105 | ErrorInner::EnvParseError { 106 | field: field.to_owned(), 107 | key: key.to_owned(), 108 | err: Box::new(err), 109 | }.into() 110 | ), 111 | } 112 | } 113 | 114 | /// `serde` does not implement `IntoDeserializer` for fixed size arrays. This 115 | /// helper type is just used for this purpose. 116 | pub struct ArrayIntoDeserializer(pub [T; N]); 117 | 118 | impl<'de, T, E, const N: usize> serde::de::IntoDeserializer<'de, E> for ArrayIntoDeserializer 119 | where 120 | T: serde::de::IntoDeserializer<'de, E>, 121 | E: serde::de::Error, 122 | { 123 | type Deserializer = serde::de::value::SeqDeserializer, E>; 124 | 125 | fn into_deserializer(self) -> Self::Deserializer { 126 | serde::de::value::SeqDeserializer::new(self.0.into_iter()) 127 | } 128 | } 129 | 130 | /// `serde` does implement `IntoDeserializer` for `HashMap` and `BTreeMap` but 131 | /// we want to keep the exact source code order of entries, so we need our own 132 | /// type. 133 | pub struct MapIntoDeserializer(pub Vec<(K, V)>); 134 | 135 | impl<'de, K, V, E> serde::de::IntoDeserializer<'de, E> for MapIntoDeserializer 136 | where 137 | K: serde::de::IntoDeserializer<'de, E>, 138 | V: serde::de::IntoDeserializer<'de, E>, 139 | E: serde::de::Error, 140 | { 141 | type Deserializer = serde::de::value::MapDeserializer<'de, std::vec::IntoIter<(K, V)>, E>; 142 | 143 | fn into_deserializer(self) -> Self::Deserializer { 144 | serde::de::value::MapDeserializer::new(self.0.into_iter()) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/json5.rs: -------------------------------------------------------------------------------- 1 | //! JSON5 specific features. This module only exists if the Cargo feature 2 | //! `json5` is enabled. 3 | 4 | use std::fmt::{self, Write}; 5 | 6 | use crate::{ 7 | Config, 8 | template::{self, Formatter}, 9 | meta::Expr, 10 | }; 11 | 12 | 13 | 14 | /// Options for generating a JSON5 template. 15 | #[non_exhaustive] 16 | pub struct FormatOptions { 17 | /// Indentation per level. Default: 2. 18 | pub indent: u8, 19 | 20 | /// Non JSON5-specific options. 21 | pub general: template::FormatOptions, 22 | } 23 | 24 | impl Default for FormatOptions { 25 | fn default() -> Self { 26 | Self { 27 | indent: 2, 28 | general: Default::default(), 29 | } 30 | } 31 | } 32 | 33 | /// Formats the configuration description as a JSON5 file. 34 | /// 35 | /// This can be used to generate a template file that you can give to the users 36 | /// of your application. It usually is a convenient to start with a correctly 37 | /// formatted file with all possible options inside. 38 | /// 39 | /// # Example 40 | /// 41 | /// ``` 42 | /// # use pretty_assertions::assert_eq; 43 | /// use std::path::PathBuf; 44 | /// use confique::{Config, json5::FormatOptions}; 45 | /// 46 | /// /// App configuration. 47 | /// #[derive(Config)] 48 | /// struct Conf { 49 | /// /// The color of the app. 50 | /// color: String, 51 | /// 52 | /// #[config(nested)] 53 | /// log: LogConfig, 54 | /// } 55 | /// 56 | /// #[derive(Config)] 57 | /// struct LogConfig { 58 | /// /// If set to `true`, the app will log to stdout. 59 | /// #[config(default = true)] 60 | /// stdout: bool, 61 | /// 62 | /// /// If this is set, the app will write logs to the given file. Of course, 63 | /// /// the app has to have write access to that file. 64 | /// #[config(env = "LOG_FILE")] 65 | /// file: Option, 66 | /// } 67 | /// 68 | /// const EXPECTED: &str = "\ 69 | /// // App configuration. 70 | /// { 71 | /// // The color of the app. 72 | /// // 73 | /// // Required! This value must be specified. 74 | /// //color: , 75 | /// 76 | /// log: { 77 | /// // If set to `true`, the app will log to stdout. 78 | /// // 79 | /// // Default value: true 80 | /// //stdout: true, 81 | /// 82 | /// // If this is set, the app will write logs to the given file. Of course, 83 | /// // the app has to have write access to that file. 84 | /// // 85 | /// // Can also be specified via environment variable `LOG_FILE`. 86 | /// //file: , 87 | /// }, 88 | /// } 89 | /// "; 90 | /// 91 | /// fn main() { 92 | /// let json5 = confique::json5::template::(FormatOptions::default()); 93 | /// assert_eq!(json5, EXPECTED); 94 | /// } 95 | /// ``` 96 | pub fn template(options: FormatOptions) -> String { 97 | let mut out = Json5Formatter::new(&options); 98 | template::format(&C::META, &mut out, options.general); 99 | out.finish() 100 | } 101 | 102 | struct Json5Formatter { 103 | indent: u8, 104 | buffer: String, 105 | depth: u8, 106 | } 107 | 108 | impl Json5Formatter { 109 | fn new(options: &FormatOptions) -> Self { 110 | Self { 111 | indent: options.indent, 112 | buffer: String::new(), 113 | depth: 0, 114 | } 115 | } 116 | 117 | fn emit_indentation(&mut self) { 118 | let num_spaces = self.depth as usize * self.indent as usize; 119 | write!(self.buffer, "{: <1$}", "", num_spaces).unwrap(); 120 | } 121 | 122 | fn dec_depth(&mut self) { 123 | self.depth = self 124 | .depth 125 | .checked_sub(1) 126 | .expect("formatter bug: ended too many nested"); 127 | } 128 | } 129 | 130 | impl Formatter for Json5Formatter { 131 | type ExprPrinter = PrintExpr; 132 | 133 | fn buffer(&mut self) -> &mut String { 134 | &mut self.buffer 135 | } 136 | 137 | fn comment(&mut self, comment: impl fmt::Display) { 138 | self.emit_indentation(); 139 | writeln!(self.buffer, "//{comment}").unwrap(); 140 | } 141 | 142 | fn disabled_field(&mut self, name: &str, value: Option<&'static Expr>) { 143 | match value.map(PrintExpr) { 144 | None => self.comment(format_args!("{name}: ,")), 145 | Some(v) => self.comment(format_args!("{name}: {v},")), 146 | }; 147 | } 148 | 149 | fn start_nested(&mut self, name: &'static str, doc: &[&'static str]) { 150 | doc.iter().for_each(|doc| self.comment(doc)); 151 | self.emit_indentation(); 152 | writeln!(self.buffer, "{name}: {{").unwrap(); 153 | self.depth += 1; 154 | } 155 | 156 | fn end_nested(&mut self) { 157 | self.dec_depth(); 158 | self.emit_indentation(); 159 | self.buffer.push_str("},\n"); 160 | } 161 | 162 | fn start_main(&mut self) { 163 | self.buffer.push_str("{\n"); 164 | self.depth += 1; 165 | } 166 | 167 | fn end_main(&mut self) { 168 | self.dec_depth(); 169 | self.buffer.push_str("}\n"); 170 | } 171 | 172 | fn finish(self) -> String { 173 | assert_eq!(self.depth, 0, "formatter bug: lingering nested objects"); 174 | self.buffer 175 | } 176 | } 177 | 178 | /// Helper to emit `meta::Expr` into JSON5. 179 | struct PrintExpr(&'static Expr); 180 | 181 | impl From<&'static Expr> for PrintExpr { 182 | fn from(expr: &'static Expr) -> Self { 183 | Self(expr) 184 | } 185 | } 186 | 187 | impl fmt::Display for PrintExpr { 188 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 189 | json5::to_string(&self.0) 190 | .expect("string serialization to JSON5 failed") 191 | .fmt(f) 192 | } 193 | } 194 | 195 | #[cfg(test)] 196 | mod tests { 197 | use super::{template, FormatOptions}; 198 | use crate::test_utils::{self, include_format_output}; 199 | use pretty_assertions::assert_str_eq; 200 | 201 | #[test] 202 | fn default() { 203 | let out = template::(FormatOptions::default()); 204 | assert_str_eq!(&out, include_format_output!("1-default.json5")); 205 | } 206 | 207 | #[test] 208 | fn no_comments() { 209 | let mut options = FormatOptions::default(); 210 | options.general.comments = false; 211 | let out = template::(options); 212 | assert_str_eq!(&out, include_format_output!("1-no-comments.json5")); 213 | } 214 | 215 | #[test] 216 | fn immediately_nested() { 217 | let out = template::(Default::default()); 218 | assert_str_eq!(&out, include_format_output!("2-default.json5")); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/meta.rs: -------------------------------------------------------------------------------- 1 | //! Types for [`Config::META`][super::Config::META]. Represent information about 2 | //! a configuration type. 3 | 4 | use core::fmt; 5 | 6 | // TODO: having all these fields public make me uncomfortable. For now it's 7 | // fine, but before reaching 1.0 I need to figure out how to allow future 8 | // additions without breaking stuff. 9 | 10 | /// Root type. 11 | #[derive(Clone, Copy, Debug, PartialEq)] 12 | pub struct Meta { 13 | /// The type (struct) name. 14 | pub name: &'static str, 15 | 16 | /// Doc comments. 17 | pub doc: &'static [&'static str], 18 | 19 | pub fields: &'static [Field], 20 | } 21 | 22 | #[derive(Clone, Copy, Debug, PartialEq)] 23 | pub struct Field { 24 | pub name: &'static str, 25 | pub doc: &'static [&'static str], 26 | pub kind: FieldKind, 27 | } 28 | 29 | #[derive(Clone, Copy, Debug, PartialEq)] 30 | pub enum FieldKind { 31 | Leaf { 32 | env: Option<&'static str>, 33 | kind: LeafKind, 34 | }, 35 | Nested { 36 | meta: &'static Meta, 37 | }, 38 | } 39 | 40 | #[derive(Clone, Copy, Debug, PartialEq)] 41 | pub enum LeafKind { 42 | /// A leaf field with a non `Option<_>` type. 43 | Required { default: Option }, 44 | /// A leaf field with an `Option<_>` type. 45 | Optional, 46 | } 47 | 48 | #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)] 49 | #[serde(untagged)] 50 | #[non_exhaustive] 51 | pub enum Expr { 52 | Str(&'static str), 53 | Float(Float), 54 | Integer(Integer), 55 | Bool(bool), 56 | Array(&'static [Expr]), 57 | 58 | /// A key value map, stored as slice in source code order. 59 | #[serde(serialize_with = "serialize_map")] 60 | Map(&'static [MapEntry]), 61 | } 62 | 63 | #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)] 64 | #[serde(untagged)] 65 | #[non_exhaustive] 66 | pub enum MapKey { 67 | Str(&'static str), 68 | Float(Float), 69 | Integer(Integer), 70 | Bool(bool), 71 | } 72 | 73 | #[derive(Debug, Clone, Copy, PartialEq)] 74 | pub struct MapEntry { 75 | pub key: MapKey, 76 | pub value: Expr, 77 | } 78 | 79 | #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)] 80 | #[serde(untagged)] 81 | pub enum Float { 82 | F32(f32), 83 | F64(f64), 84 | } 85 | 86 | #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] 87 | #[serde(untagged)] 88 | pub enum Integer { 89 | U8(u8), 90 | U16(u16), 91 | U32(u32), 92 | U64(u64), 93 | U128(u128), 94 | Usize(usize), 95 | I8(i8), 96 | I16(i16), 97 | I32(i32), 98 | I64(i64), 99 | I128(i128), 100 | Isize(isize), 101 | } 102 | 103 | 104 | impl fmt::Display for Float { 105 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 106 | match self { 107 | Self::F32(v) => v.fmt(f), 108 | Self::F64(v) => v.fmt(f), 109 | } 110 | } 111 | } 112 | 113 | impl fmt::Display for Integer { 114 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 115 | match self { 116 | Self::U8(i) => i.fmt(f), 117 | Self::U16(i) => i.fmt(f), 118 | Self::U32(i) => i.fmt(f), 119 | Self::U64(i) => i.fmt(f), 120 | Self::U128(i) => i.fmt(f), 121 | Self::Usize(i) => i.fmt(f), 122 | Self::I8(i) => i.fmt(f), 123 | Self::I16(i) => i.fmt(f), 124 | Self::I32(i) => i.fmt(f), 125 | Self::I64(i) => i.fmt(f), 126 | Self::I128(i) => i.fmt(f), 127 | Self::Isize(i) => i.fmt(f), 128 | } 129 | } 130 | } 131 | 132 | impl From for Expr { 133 | fn from(src: MapKey) -> Self { 134 | match src { 135 | MapKey::Str(v) => Self::Str(v), 136 | MapKey::Integer(v) => Self::Integer(v), 137 | MapKey::Float(v) => Self::Float(v), 138 | MapKey::Bool(v) => Self::Bool(v), 139 | } 140 | } 141 | } 142 | 143 | impl Float { 144 | #[cfg(feature = "toml")] 145 | pub(crate) fn is_nan(&self) -> bool { 146 | match self { 147 | Float::F32(f) => f.is_nan(), 148 | Float::F64(f) => f.is_nan(), 149 | } 150 | } 151 | } 152 | 153 | fn serialize_map(map: &&'static [MapEntry], serializer: S) -> Result 154 | where 155 | S: serde::Serializer, 156 | { 157 | use serde::ser::SerializeMap; 158 | 159 | let mut s = serializer.serialize_map(Some(map.len()))?; 160 | for entry in *map { 161 | s.serialize_entry(&entry.key, &entry.value)?; 162 | } 163 | s.end() 164 | } 165 | -------------------------------------------------------------------------------- /src/template.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for creating a "configuration template". 2 | //! 3 | //! A config template is a description of all possible configuration values with 4 | //! their default values and other information. This is super useful to give to 5 | //! the users of your application as a starting point. 6 | 7 | use std::fmt; 8 | 9 | use crate::meta::{Meta, FieldKind, LeafKind, Expr}; 10 | 11 | 12 | /// Trait abstracting over the format differences when it comes to formatting a 13 | /// configuration template. 14 | /// 15 | /// To implement this yourself, take a look at the existing impls for guidance. 16 | pub(crate) trait Formatter { 17 | /// A type that is used to print expressions. 18 | type ExprPrinter: fmt::Display + From<&'static Expr>; 19 | 20 | /// Internal buffer, mainly used for `make_gap` and similar methods. 21 | fn buffer(&mut self) -> &mut String; 22 | 23 | /// Returns internal buffer by value. 24 | fn finish(self) -> String; 25 | 26 | /// Write a comment, e.g. `format!("#{comment}")`. Don't add a space after 27 | /// your comment token. 28 | fn comment(&mut self, comment: impl fmt::Display); 29 | 30 | /// Write a commented-out field with optional value, e.g. `format!("#{name} = {value}")`. 31 | fn disabled_field(&mut self, name: &'static str, value: Option<&'static Expr>); 32 | 33 | /// Start a nested configuration section with the given name. 34 | fn start_nested(&mut self, name: &'static str, doc: &[&'static str]); 35 | 36 | /// End a nested configuration section. 37 | fn end_nested(&mut self); 38 | 39 | /// Called after the global docs are written and before and fields are 40 | /// emitted. Default impl does nothing. 41 | fn start_main(&mut self) {} 42 | 43 | /// Called after all fields have been emitted (basically the very end). 44 | /// Default impl does nothing. 45 | fn end_main(&mut self) {} 46 | 47 | /// Emits a comment describing that this field can be loaded from the given 48 | /// env var. Default impl is likely sufficient. 49 | fn env_comment(&mut self, env_key: &'static str) { 50 | self.comment(format_args!(" Can also be specified via environment variable `{env_key}`.")); 51 | } 52 | 53 | /// Emits a comment either stating that this field is required, or 54 | /// specifying the default value. Default impl is likely sufficient. 55 | fn default_or_required_comment(&mut self, default_value: Option<&'static Expr>) { 56 | match default_value { 57 | None => self.comment(format_args!(" Required! This value must be specified.")), 58 | Some(v) => self.comment(format_args!(" Default value: {}", Self::ExprPrinter::from(v))), 59 | } 60 | } 61 | 62 | /// Makes sure that there is a gap of at least `size` many empty lines at 63 | /// the end of the buffer. Does nothing when the buffer is empty. 64 | fn make_gap(&mut self, size: u8) { 65 | if !self.buffer().is_empty() { 66 | let num_trailing_newlines = self.buffer().chars() 67 | .rev() 68 | .take_while(|c| *c == '\n') 69 | .count(); 70 | 71 | let newlines_needed = (size as usize + 1).saturating_sub(num_trailing_newlines); 72 | let buffer = self.buffer(); 73 | for _ in 0..newlines_needed { 74 | buffer.push('\n'); 75 | } 76 | } 77 | } 78 | 79 | /// Makes sure the buffer ends with a single trailing newline. 80 | fn assert_single_trailing_newline(&mut self) { 81 | let buffer = self.buffer(); 82 | if buffer.ends_with('\n') { 83 | while buffer.ends_with("\n\n") { 84 | buffer.pop(); 85 | } 86 | } else { 87 | buffer.push('\n'); 88 | } 89 | } 90 | } 91 | 92 | /// General (non format-dependent) template-formatting options. 93 | #[non_exhaustive] 94 | pub struct FormatOptions { 95 | /// Whether to include doc comments (with your own text and information 96 | /// about whether a value is required and/or has a default). Default: 97 | /// `true`. 98 | pub comments: bool, 99 | 100 | /// If `comments` and this field are `true`, leaf fields with `env = "FOO"` 101 | /// attribute will have a line like this added: 102 | /// 103 | /// ```text 104 | /// ## Can also be specified via environment variable `FOO`. 105 | /// ``` 106 | /// 107 | /// Default: `true`. 108 | pub env_keys: bool, 109 | 110 | /// Number of lines between leaf fields. Gap between leaf and nested fields 111 | /// is the bigger of this and `nested_field_gap`. 112 | /// 113 | /// Default: `if self.comments { 1 } else { 0 }`. 114 | pub leaf_field_gap: Option, 115 | 116 | /// Number of lines between nested fields. Gap between leaf and nested 117 | /// fields is the bigger of this and `leaf_field_gap`. 118 | /// 119 | /// Default: 1. 120 | pub nested_field_gap: u8, 121 | 122 | // Potential future options: 123 | // - Comment out default values (`#foo = 3` vs `foo = 3`) 124 | // - Which docs to include from nested objects 125 | } 126 | 127 | impl FormatOptions { 128 | fn leaf_field_gap(&self) -> u8 { 129 | self.leaf_field_gap.unwrap_or(self.comments as u8) 130 | } 131 | } 132 | 133 | impl Default for FormatOptions { 134 | fn default() -> Self { 135 | Self { 136 | comments: true, 137 | env_keys: true, 138 | leaf_field_gap: None, 139 | nested_field_gap: 1, 140 | } 141 | } 142 | } 143 | 144 | /// Formats a configuration template with the given formatter. 145 | /// 146 | /// If you don't need to use a custom formatter, rather look at the `format` 147 | /// functions in the format-specific modules (e.g. `toml::format`, 148 | /// `yaml::format`). 149 | pub(crate) fn format(meta: &Meta, out: &mut impl Formatter, options: FormatOptions) { 150 | // Print root docs. 151 | if options.comments { 152 | meta.doc.iter().for_each(|doc| out.comment(doc)); 153 | } 154 | 155 | // Recursively format all nested objects and fields 156 | out.start_main(); 157 | format_impl(out, meta, &options); 158 | out.end_main(); 159 | out.assert_single_trailing_newline(); 160 | } 161 | 162 | 163 | fn format_impl(out: &mut impl Formatter, meta: &Meta, options: &FormatOptions) { 164 | // Output all leaf fields first 165 | let leaf_fields = meta.fields.iter().filter_map(|f| match &f.kind { 166 | FieldKind::Leaf { kind, env } => Some((f, kind, env)), 167 | _ => None, 168 | }); 169 | let mut emitted_anything = false; 170 | for (i, (field, kind, env)) in leaf_fields.enumerate() { 171 | emitted_anything = true; 172 | 173 | if i > 0 { 174 | out.make_gap(options.leaf_field_gap()); 175 | } 176 | 177 | let mut emitted_something = false; 178 | macro_rules! empty_sep_doc_line { 179 | () => { 180 | if emitted_something { 181 | out.comment(""); 182 | } 183 | }; 184 | } 185 | 186 | if options.comments { 187 | field.doc.iter().for_each(|doc| out.comment(doc)); 188 | emitted_something = !field.doc.is_empty(); 189 | 190 | if let Some(env) = env { 191 | empty_sep_doc_line!(); 192 | out.env_comment(env); 193 | } 194 | } 195 | 196 | match kind { 197 | LeafKind::Optional => out.disabled_field(field.name, None), 198 | LeafKind::Required { default } => { 199 | // Emit comment about default value or the value being required. 200 | if options.comments { 201 | empty_sep_doc_line!(); 202 | out.default_or_required_comment(default.as_ref()) 203 | } 204 | 205 | // Emit the actual line with the name and optional value 206 | out.disabled_field(field.name, default.as_ref()); 207 | } 208 | } 209 | } 210 | 211 | // Then all nested fields recursively 212 | let nested_fields = meta.fields.iter().filter_map(|f| match &f.kind { 213 | FieldKind::Nested { meta } => Some((f, meta)), 214 | _ => None, 215 | }); 216 | for (field, meta) in nested_fields { 217 | if emitted_anything { 218 | out.make_gap(options.nested_field_gap); 219 | } 220 | emitted_anything = true; 221 | 222 | let comments = if options.comments { field.doc } else { &[] }; 223 | out.start_nested(field.name, comments); 224 | format_impl(out, meta, options); 225 | out.end_nested(); 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/test_utils/example1.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, net::IpAddr, path::PathBuf}; 2 | 3 | use crate as confique; 4 | use crate::Config; 5 | 6 | #[derive(Debug, Config)] 7 | /// A sample configuration for our app. 8 | #[allow(dead_code)] 9 | pub struct Conf { 10 | /// Name of the website. 11 | pub site_name: String, 12 | 13 | /// Configurations related to the HTTP communication. 14 | #[config(nested)] 15 | pub http: Http, 16 | 17 | /// Configuring the logging. 18 | #[config(nested)] 19 | pub log: LogConfig, 20 | } 21 | 22 | /// Configuring the HTTP server of our app. 23 | #[derive(Debug, Config)] 24 | #[allow(dead_code)] 25 | pub struct Http { 26 | /// The port the server will listen on. 27 | #[config(env = "PORT")] 28 | pub port: u16, 29 | 30 | #[config(nested)] 31 | pub headers: Headers, 32 | 33 | /// The bind address of the server. Can be set to `0.0.0.0` for example, to 34 | /// allow other users of the network to access the server. 35 | #[config(default = "127.0.0.1")] 36 | pub bind: IpAddr, 37 | } 38 | 39 | #[derive(Debug, Config)] 40 | #[allow(dead_code)] 41 | pub struct Headers { 42 | /// The header in which the reverse proxy specifies the username. 43 | #[config(default = "x-username")] 44 | pub username: String, 45 | 46 | /// The header in which the reverse proxy specifies the display name. 47 | #[config(default = "x-display-name")] 48 | pub display_name: String, 49 | 50 | /// Headers that are allowed. 51 | #[config(default = ["content-type", "content-encoding"])] 52 | pub allowed: Vec, 53 | 54 | /// Assigns a score to some headers. 55 | #[config(default = { "cookie": 1.5, "server": 12.7 })] 56 | pub score: HashMap, 57 | } 58 | 59 | 60 | #[derive(Debug, Config)] 61 | #[allow(dead_code)] 62 | pub struct LogConfig { 63 | /// If set to `true`, the app will log to stdout. 64 | #[config(default = true)] 65 | pub stdout: bool, 66 | 67 | /// If this is set, the app will write logs to the given file. Of course, 68 | /// the app has to have write access to that file. 69 | pub file: Option, 70 | } 71 | -------------------------------------------------------------------------------- /src/test_utils/example2.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate as confique; 4 | use crate::Config; 5 | 6 | #[derive(Debug, Config)] 7 | /// A sample configuration for our app. 8 | #[allow(dead_code)] 9 | pub struct Conf { 10 | #[config(nested)] 11 | pub http: Http, 12 | } 13 | 14 | /// Configuring the HTTP server of our app. 15 | #[derive(Debug, Config)] 16 | #[allow(dead_code)] 17 | pub struct Http { 18 | #[config(nested)] 19 | pub headers: Headers, 20 | 21 | #[config(nested)] 22 | pub log: LogConfig, 23 | } 24 | 25 | #[derive(Debug, Config)] 26 | #[allow(dead_code)] 27 | pub struct Headers { 28 | /// The header in which the reverse proxy specifies the username. 29 | #[config(default = "x-username")] 30 | pub username: String, 31 | 32 | /// The header in which the reverse proxy specifies the display name. 33 | #[config(default = "x-display-name")] 34 | pub display_name: String, 35 | } 36 | 37 | 38 | #[derive(Debug, Config)] 39 | #[allow(dead_code)] 40 | pub struct LogConfig { 41 | /// If set to `true`, the app will log to stdout. 42 | #[config(default = true)] 43 | pub stdout: bool, 44 | 45 | /// If this is set, the app will write logs to the given file. Of course, 46 | /// the app has to have write access to that file. 47 | pub file: Option, 48 | } 49 | -------------------------------------------------------------------------------- /src/test_utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod example1; 2 | pub(crate) mod example2; 3 | 4 | 5 | #[allow(unused_macros)] 6 | macro_rules! include_format_output { 7 | ($file:expr) => { 8 | include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/format-output/", $file)) 9 | }; 10 | } 11 | 12 | #[allow(unused_imports)] 13 | pub(crate) use include_format_output; 14 | -------------------------------------------------------------------------------- /src/toml.rs: -------------------------------------------------------------------------------- 1 | //! TOML specific features. This module only exists if the Cargo feature `toml` 2 | //! is enabled. 3 | 4 | use std::fmt::{self, Write}; 5 | 6 | use crate::{ 7 | meta::{Expr, MapKey}, 8 | template::{self, Formatter}, 9 | Config, 10 | }; 11 | 12 | 13 | 14 | /// Options for generating a TOML template. 15 | #[non_exhaustive] 16 | pub struct FormatOptions { 17 | /// Indentation for nested tables. Default: 0. 18 | pub indent: u8, 19 | 20 | /// Non TOML-specific options. 21 | pub general: template::FormatOptions, 22 | } 23 | 24 | impl Default for FormatOptions { 25 | fn default() -> Self { 26 | Self { 27 | indent: 0, 28 | general: Default::default(), 29 | } 30 | } 31 | } 32 | 33 | /// Formats the configuration description as a TOML file. 34 | /// 35 | /// This can be used to generate a template file that you can give to the users 36 | /// of your application. It usually is a convenient to start with a correctly 37 | /// formatted file with all possible options inside. 38 | /// 39 | /// # Example 40 | /// 41 | /// ``` 42 | /// # use pretty_assertions::assert_eq; 43 | /// use std::path::PathBuf; 44 | /// use confique::{Config, toml::FormatOptions}; 45 | /// 46 | /// /// App configuration. 47 | /// #[derive(Config)] 48 | /// struct Conf { 49 | /// /// The color of the app. 50 | /// color: String, 51 | /// 52 | /// #[config(nested)] 53 | /// log: LogConfig, 54 | /// } 55 | /// 56 | /// #[derive(Config)] 57 | /// struct LogConfig { 58 | /// /// If set to `true`, the app will log to stdout. 59 | /// #[config(default = true)] 60 | /// stdout: bool, 61 | /// 62 | /// /// If this is set, the app will write logs to the given file. Of course, 63 | /// /// the app has to have write access to that file. 64 | /// #[config(env = "LOG_FILE")] 65 | /// file: Option, 66 | /// } 67 | /// 68 | /// const EXPECTED: &str = "\ 69 | /// ## App configuration. 70 | /// 71 | /// ## The color of the app. 72 | /// ## 73 | /// ## Required! This value must be specified. 74 | /// ##color = 75 | /// 76 | /// [log] 77 | /// ## If set to `true`, the app will log to stdout. 78 | /// ## 79 | /// ## Default value: true 80 | /// ##stdout = true 81 | /// 82 | /// ## If this is set, the app will write logs to the given file. Of course, 83 | /// ## the app has to have write access to that file. 84 | /// ## 85 | /// ## Can also be specified via environment variable `LOG_FILE`. 86 | /// ##file = 87 | /// "; 88 | /// 89 | /// fn main() { 90 | /// let toml = confique::toml::template::(FormatOptions::default()); 91 | /// assert_eq!(toml, EXPECTED); 92 | /// } 93 | /// ``` 94 | pub fn template(options: FormatOptions) -> String { 95 | let mut out = TomlFormatter::new(&options); 96 | template::format(&C::META, &mut out, options.general); 97 | out.finish() 98 | } 99 | 100 | struct TomlFormatter { 101 | indent: u8, 102 | buffer: String, 103 | stack: Vec<&'static str>, 104 | } 105 | 106 | impl TomlFormatter { 107 | fn new(options: &FormatOptions) -> Self { 108 | Self { 109 | indent: options.indent, 110 | buffer: String::new(), 111 | stack: Vec::new(), 112 | } 113 | } 114 | 115 | fn emit_indentation(&mut self) { 116 | let num_spaces = self.stack.len() * self.indent as usize; 117 | write!(self.buffer, "{: <1$}", "", num_spaces).unwrap(); 118 | } 119 | } 120 | 121 | impl Formatter for TomlFormatter { 122 | type ExprPrinter = PrintExpr<'static>; 123 | 124 | fn buffer(&mut self) -> &mut String { 125 | &mut self.buffer 126 | } 127 | 128 | fn comment(&mut self, comment: impl fmt::Display) { 129 | self.emit_indentation(); 130 | writeln!(self.buffer, "#{comment}").unwrap(); 131 | } 132 | 133 | fn disabled_field(&mut self, name: &str, value: Option<&'static Expr>) { 134 | match value.map(PrintExpr) { 135 | None => self.comment(format_args!("{name} =")), 136 | Some(v) => self.comment(format_args!("{name} = {v}")), 137 | }; 138 | } 139 | 140 | fn start_nested(&mut self, name: &'static str, doc: &[&'static str]) { 141 | self.stack.push(name); 142 | doc.iter().for_each(|doc| self.comment(doc)); 143 | self.emit_indentation(); 144 | writeln!(self.buffer, "[{}]", self.stack.join(".")).unwrap(); 145 | } 146 | 147 | fn end_nested(&mut self) { 148 | self.stack.pop().expect("formatter bug: stack empty"); 149 | } 150 | 151 | fn start_main(&mut self) { 152 | self.make_gap(1); 153 | } 154 | 155 | fn finish(self) -> String { 156 | assert!(self.stack.is_empty(), "formatter bug: stack not empty"); 157 | self.buffer 158 | } 159 | } 160 | 161 | /// Helper to emit `meta::Expr` into TOML. 162 | struct PrintExpr<'a>(&'a Expr); 163 | 164 | impl From<&'static Expr> for PrintExpr<'static> { 165 | fn from(expr: &'static Expr) -> Self { 166 | Self(expr) 167 | } 168 | } 169 | 170 | impl fmt::Display for PrintExpr<'_> { 171 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 172 | match self.0 { 173 | Expr::Map(entries) => { 174 | // TODO: pretty printing of long arrays onto multiple lines? 175 | f.write_str("{ ")?; 176 | for (i, entry) in entries.iter().enumerate() { 177 | if i != 0 { 178 | f.write_str(", ")?; 179 | } 180 | 181 | match entry.key { 182 | MapKey::Str(s) if is_valid_bare_key(s) => f.write_str(s)?, 183 | _ => PrintExpr(&entry.key.into()).fmt(f)?, 184 | } 185 | f.write_str(" = ")?; 186 | PrintExpr(&entry.value).fmt(f)?; 187 | } 188 | f.write_str(" }")?; 189 | Ok(()) 190 | }, 191 | 192 | // We special case floats as the TOML serializer below doesn't work 193 | // well with floats, not rounding them appropriately. See: 194 | // https://github.com/toml-rs/toml/issues/494 195 | // 196 | // For all non-NAN floats, the `Display` output is compatible with 197 | // TOML. 198 | Expr::Float(fv) if !fv.is_nan() => fv.fmt(f), 199 | 200 | // All these other types can simply be serialized as is. 201 | Expr::Str(_) | Expr::Float(_) | Expr::Integer(_) | Expr::Bool(_) | Expr::Array(_) => { 202 | let mut s = String::new(); 203 | serde::Serialize::serialize(&self.0, toml::ser::ValueSerializer::new(&mut s)) 204 | .expect("string serialization to TOML failed"); 205 | s.fmt(f) 206 | } 207 | } 208 | } 209 | } 210 | 211 | fn is_valid_bare_key(s: &str) -> bool { 212 | s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') 213 | } 214 | 215 | #[cfg(test)] 216 | mod tests { 217 | use pretty_assertions::assert_str_eq; 218 | 219 | use crate::test_utils::{self, include_format_output}; 220 | use super::{template, FormatOptions}; 221 | 222 | #[test] 223 | fn default() { 224 | let out = template::(FormatOptions::default()); 225 | assert_str_eq!(&out, include_format_output!("1-default.toml")); 226 | } 227 | 228 | #[test] 229 | fn no_comments() { 230 | let mut options = FormatOptions::default(); 231 | options.general.comments = false; 232 | let out = template::(options); 233 | assert_str_eq!(&out, include_format_output!("1-no-comments.toml")); 234 | } 235 | 236 | #[test] 237 | fn indent_2() { 238 | let mut options = FormatOptions::default(); 239 | options.indent = 2; 240 | let out = template::(options); 241 | assert_str_eq!(&out, include_format_output!("1-indent-2.toml")); 242 | } 243 | 244 | #[test] 245 | fn nested_gap_2() { 246 | let mut options = FormatOptions::default(); 247 | options.general.nested_field_gap = 2; 248 | let out = template::(options); 249 | assert_str_eq!(&out, include_format_output!("1-nested-gap-2.toml")); 250 | } 251 | 252 | #[test] 253 | fn immediately_nested() { 254 | let out = template::(Default::default()); 255 | assert_str_eq!(&out, include_format_output!("2-default.toml")); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/yaml.rs: -------------------------------------------------------------------------------- 1 | //! YAML specific features. This module only exists if the Cargo feature `yaml` 2 | //! is enabled. 3 | 4 | use std::fmt::{self, Write}; 5 | 6 | use crate::{ 7 | meta::Expr, 8 | template::{self, Formatter}, 9 | Config, 10 | }; 11 | 12 | 13 | 14 | /// Options for generating a YAML template. 15 | #[non_exhaustive] 16 | pub struct FormatOptions { 17 | /// Amount of indentation in spaces. Default: 2. 18 | pub indent: u8, 19 | 20 | /// Non YAML-specific options. 21 | pub general: template::FormatOptions, 22 | } 23 | 24 | impl Default for FormatOptions { 25 | fn default() -> Self { 26 | Self { 27 | indent: 2, 28 | general: Default::default(), 29 | } 30 | } 31 | } 32 | 33 | 34 | /// Formats the configuration description as a YAML file. 35 | /// 36 | /// This can be used to generate a template file that you can give to the users 37 | /// of your application. It usually is a convenient to start with a correctly 38 | /// formatted file with all possible options inside. 39 | /// 40 | /// # Example 41 | /// 42 | /// ``` 43 | /// # use pretty_assertions::assert_eq; 44 | /// use std::path::PathBuf; 45 | /// use confique::{Config, yaml::FormatOptions}; 46 | /// 47 | /// /// App configuration. 48 | /// #[derive(Config)] 49 | /// struct Conf { 50 | /// /// The color of the app. 51 | /// color: String, 52 | /// 53 | /// #[config(nested)] 54 | /// log: LogConfig, 55 | /// } 56 | /// 57 | /// #[derive(Config)] 58 | /// struct LogConfig { 59 | /// /// If set to `true`, the app will log to stdout. 60 | /// #[config(default = true)] 61 | /// stdout: bool, 62 | /// 63 | /// /// If this is set, the app will write logs to the given file. Of course, 64 | /// /// the app has to have write access to that file. 65 | /// #[config(env = "LOG_FILE")] 66 | /// file: Option, 67 | /// } 68 | /// 69 | /// const EXPECTED: &str = "\ 70 | /// ## App configuration. 71 | /// 72 | /// ## The color of the app. 73 | /// ## 74 | /// ## Required! This value must be specified. 75 | /// ##color: 76 | /// 77 | /// log: 78 | /// ## If set to `true`, the app will log to stdout. 79 | /// ## 80 | /// ## Default value: true 81 | /// ##stdout: true 82 | /// 83 | /// ## If this is set, the app will write logs to the given file. Of course, 84 | /// ## the app has to have write access to that file. 85 | /// ## 86 | /// ## Can also be specified via environment variable `LOG_FILE`. 87 | /// ##file: 88 | /// "; 89 | /// 90 | /// 91 | /// fn main() { 92 | /// let yaml = confique::yaml::template::(FormatOptions::default()); 93 | /// assert_eq!(yaml, EXPECTED); 94 | /// } 95 | /// ``` 96 | pub fn template(options: FormatOptions) -> String { 97 | let mut out = YamlFormatter::new(&options); 98 | template::format(&C::META, &mut out, options.general); 99 | out.finish() 100 | } 101 | 102 | struct YamlFormatter { 103 | indent: u8, 104 | buffer: String, 105 | depth: u8, 106 | } 107 | 108 | impl YamlFormatter { 109 | fn new(options: &FormatOptions) -> Self { 110 | Self { 111 | indent: options.indent, 112 | buffer: String::new(), 113 | depth: 0, 114 | } 115 | } 116 | 117 | fn emit_indentation(&mut self) { 118 | let num_spaces = self.depth as usize * self.indent as usize; 119 | write!(self.buffer, "{: <1$}", "", num_spaces).unwrap(); 120 | } 121 | } 122 | 123 | impl Formatter for YamlFormatter { 124 | type ExprPrinter = PrintExpr<'static>; 125 | 126 | fn buffer(&mut self) -> &mut String { 127 | &mut self.buffer 128 | } 129 | 130 | fn comment(&mut self, comment: impl fmt::Display) { 131 | self.emit_indentation(); 132 | writeln!(self.buffer, "#{comment}").unwrap(); 133 | } 134 | 135 | fn disabled_field(&mut self, name: &str, value: Option<&'static Expr>) { 136 | match value.map(PrintExpr) { 137 | None => self.comment(format_args!("{name}:")), 138 | Some(v) => self.comment(format_args!("{name}: {v}")), 139 | }; 140 | } 141 | 142 | fn start_nested(&mut self, name: &'static str, doc: &[&'static str]) { 143 | doc.iter().for_each(|doc| self.comment(doc)); 144 | self.emit_indentation(); 145 | writeln!(self.buffer, "{name}:").unwrap(); 146 | self.depth += 1; 147 | } 148 | 149 | fn end_nested(&mut self) { 150 | self.depth = self 151 | .depth 152 | .checked_sub(1) 153 | .expect("formatter bug: ended too many nested"); 154 | } 155 | 156 | fn start_main(&mut self) { 157 | self.make_gap(1); 158 | } 159 | 160 | fn finish(self) -> String { 161 | assert_eq!(self.depth, 0, "formatter bug: lingering nested objects"); 162 | self.buffer 163 | } 164 | } 165 | 166 | /// Helper to emit `meta::Expr` into YAML. 167 | struct PrintExpr<'a>(&'a Expr); 168 | 169 | impl From<&'static Expr> for PrintExpr<'static> { 170 | fn from(expr: &'static Expr) -> Self { 171 | Self(expr) 172 | } 173 | } 174 | 175 | impl fmt::Display for PrintExpr<'_> { 176 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 177 | match *self.0 { 178 | // We have to special case arrays as the normal formatter only emits 179 | // multi line lists. 180 | Expr::Array(items) => { 181 | // TODO: pretty printing of long arrays onto multiple lines? 182 | f.write_char('[')?; 183 | for (i, item) in items.iter().enumerate() { 184 | if i != 0 { 185 | f.write_str(", ")?; 186 | } 187 | PrintExpr(item).fmt(f)?; 188 | } 189 | f.write_char(']')?; 190 | Ok(()) 191 | } 192 | 193 | Expr::Map(entries) => { 194 | // TODO: pretty printing of long arrays onto multiple lines? 195 | f.write_str("{ ")?; 196 | for (i, entry) in entries.iter().enumerate() { 197 | if i != 0 { 198 | f.write_str(", ")?; 199 | } 200 | PrintExpr(&entry.key.into()).fmt(f)?; 201 | f.write_str(": ")?; 202 | PrintExpr(&entry.value).fmt(f)?; 203 | } 204 | f.write_str(" }")?; 205 | Ok(()) 206 | } 207 | 208 | // All these other types can simply be serialized as is. 209 | Expr::Str(_) | Expr::Float(_) | Expr::Integer(_) | Expr::Bool(_) => { 210 | let out = serde_yaml::to_string(&self.0) 211 | .expect("string serialization to YAML failed"); 212 | 213 | // Unfortunately, `serde_yaml` cannot serialize these values on its own 214 | // without embedding them in a full document (starting with `---` and 215 | // ending with a newline). So we need to cleanup. 216 | out.strip_prefix("---\n") 217 | .unwrap_or(&out) 218 | .trim_matches('\n') 219 | .fmt(f) 220 | } 221 | } 222 | } 223 | } 224 | 225 | 226 | #[cfg(test)] 227 | mod tests { 228 | use pretty_assertions::assert_str_eq; 229 | 230 | use crate::test_utils::{self, include_format_output}; 231 | use super::{template, FormatOptions}; 232 | 233 | 234 | #[test] 235 | fn default() { 236 | let out = template::(FormatOptions::default()); 237 | assert_str_eq!(&out, include_format_output!("1-default.yaml")); 238 | } 239 | 240 | #[test] 241 | fn no_comments() { 242 | let mut options = FormatOptions::default(); 243 | options.general.comments = false; 244 | let out = template::(options); 245 | assert_str_eq!(&out, include_format_output!("1-no-comments.yaml")); 246 | } 247 | 248 | #[test] 249 | fn immediately_nested() { 250 | let out = template::(Default::default()); 251 | assert_str_eq!(&out, include_format_output!("2-default.yaml")); 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /tests/array_default.rs: -------------------------------------------------------------------------------- 1 | use pretty_assertions::assert_eq; 2 | 3 | use confique::{meta, Config}; 4 | 5 | 6 | #[test] 7 | fn vec_u32() { 8 | #[derive(Config)] 9 | struct Foo { 10 | /// A nice doc comment. 11 | #[config(default = [1, 2, 3])] 12 | bar: Vec, 13 | } 14 | 15 | assert_eq!(Foo::META, meta::Meta { 16 | name: "Foo", 17 | doc: &[], 18 | fields: &[ 19 | meta::Field { 20 | name: "bar", 21 | doc: &[" A nice doc comment."], 22 | kind: meta::FieldKind::Leaf { 23 | env: None, 24 | kind: meta::LeafKind::Required { 25 | default: Some(meta::Expr::Array(&[ 26 | meta::Expr::Integer(meta::Integer::U32(1)), 27 | meta::Expr::Integer(meta::Integer::U32(2)), 28 | meta::Expr::Integer(meta::Integer::U32(3)), 29 | ])), 30 | }, 31 | }, 32 | }, 33 | ], 34 | }); 35 | 36 | let def = Foo::builder().load().unwrap(); 37 | assert_eq!(def.bar, vec![1, 2, 3]); 38 | } 39 | 40 | #[test] 41 | #[allow(unused_parens)] 42 | fn inferred_type() { 43 | #[derive(Config)] 44 | struct Foo { 45 | #[config(default = [1, 2])] 46 | array: [i8; 2], 47 | 48 | #[config(default = [1, 2])] 49 | linked_list: std::collections::LinkedList, 50 | 51 | #[config(default = [1.0, 2.0])] 52 | parens: (Vec), 53 | 54 | // A type from which we cannot correctly infer the item type. 55 | #[config(default = [13, 27])] 56 | fallback: std::time::Duration, 57 | } 58 | 59 | #[track_caller] 60 | fn assert_helper( 61 | actual: &meta::FieldKind, 62 | expected_items: &[meta::Expr], 63 | ) { 64 | match actual { 65 | meta::FieldKind::Leaf { 66 | env: None, 67 | kind: meta::LeafKind::Required { 68 | default: Some(meta::Expr::Array(items)), 69 | }, 70 | } => { 71 | assert_eq!(*items, expected_items); 72 | } 73 | _ => panic!("expected required leaf field, found: {actual:?}"), 74 | } 75 | } 76 | 77 | assert_helper(&Foo::META.fields[0].kind, 78 | &[1, 2].map(|i| meta::Expr::Integer(meta::Integer::I8(i))) 79 | ); 80 | assert_helper(&Foo::META.fields[1].kind, 81 | &[1, 2].map(|i| meta::Expr::Integer(meta::Integer::Usize(i))) 82 | ); 83 | assert_helper(&Foo::META.fields[2].kind, 84 | &[1.0, 2.0].map(|f| meta::Expr::Float(meta::Float::F32(f))) 85 | ); 86 | assert_helper(&Foo::META.fields[3].kind, 87 | &[13, 27].map(|i| meta::Expr::Integer(meta::Integer::I32(i))) 88 | ); 89 | 90 | let def = Foo::builder().load().unwrap(); 91 | assert_eq!(def.array, [1, 2]); 92 | assert_eq!(def.linked_list, std::collections::LinkedList::from_iter([1, 2])); 93 | assert_eq!(def.parens, vec![1.0, 2.0]); 94 | assert_eq!(def.fallback, std::time::Duration::new(13, 27)); 95 | } 96 | -------------------------------------------------------------------------------- /tests/check_symbols.rs: -------------------------------------------------------------------------------- 1 | #![no_implicit_prelude] 2 | #![allow(dead_code)] 3 | 4 | extern crate confique; 5 | extern crate std; 6 | 7 | use confique::Config; 8 | 9 | 10 | #[derive(Debug, Config)] 11 | /// A sample configuration for our app. 12 | struct Conf { 13 | #[config(nested)] 14 | http: Http, 15 | 16 | title: std::string::String, 17 | } 18 | 19 | /// Configuring the HTTP server of our app. 20 | #[derive(Debug, Config)] 21 | struct Http { 22 | /// The port the server will listen on. 23 | #[config(env = "PORT")] 24 | port: u16, 25 | 26 | /// The bind address of the server. Can be set to `0.0.0.0` for example, to 27 | /// allow other users of the network to access the server. 28 | #[config(default = "127.0.0.1")] 29 | bind: std::net::IpAddr, 30 | } 31 | 32 | #[test] 33 | fn compiles() {} 34 | -------------------------------------------------------------------------------- /tests/env.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use confique::{Config, Partial}; 3 | use pretty_assertions::assert_eq; 4 | 5 | #[derive(Debug, Deserialize)] 6 | enum Foo { A, B, C } 7 | 8 | 9 | #[test] 10 | fn enum_env() { 11 | #[derive(Config)] 12 | struct Conf { 13 | #[config(env = "FOO")] 14 | foo: Foo, 15 | } 16 | 17 | std::env::set_var("FOO", "B"); 18 | let conf = Conf::builder().env().load(); 19 | assert!(matches!(conf, Ok(Conf { foo: Foo::B }))); 20 | } 21 | 22 | fn my_parser(s: &str) -> Result { 23 | s.trim().parse() 24 | } 25 | 26 | fn my_parser2(s: &str) -> Result { 27 | if s.is_empty() { 28 | Ok(0) 29 | } else { 30 | s.trim().parse() 31 | } 32 | } 33 | 34 | #[test] 35 | fn empty_error_is_unset() { 36 | #[derive(Config)] 37 | #[config(partial_attr(derive(PartialEq, Debug)))] 38 | #[allow(dead_code)] 39 | struct Conf { 40 | #[config(env = "EMPTY_ERROR_IS_UNSET_FOO")] 41 | foo: u32, 42 | 43 | #[config(env = "EMPTY_ERROR_IS_UNSET_BAR", parse_env = my_parser)] 44 | bar: u32, 45 | 46 | #[config(env = "EMPTY_ERROR_IS_UNSET_BAZ")] 47 | baz: String, 48 | 49 | #[config(env = "EMPTY_ERROR_IS_UNSET_VALIDATE", validate(!validate.is_empty(), "bad"))] 50 | validate: String, 51 | 52 | #[config( 53 | env = "EMPTY_ERROR_IS_UNSET_VALIDATE_PARSE", 54 | parse_env = my_parser2, 55 | validate(*validate_parse != 0, "bad"), 56 | )] 57 | validate_parse: u32, 58 | } 59 | 60 | type Partial = ::Partial; 61 | 62 | std::env::set_var("EMPTY_ERROR_IS_UNSET_FOO", ""); 63 | assert_eq!(Partial::from_env().unwrap(), Partial { 64 | foo: None, 65 | bar: None, 66 | baz: None, 67 | validate: None, 68 | validate_parse: None, 69 | }); 70 | 71 | std::env::set_var("EMPTY_ERROR_IS_UNSET_BAR", ""); 72 | assert_eq!(Partial::from_env().unwrap(), Partial { 73 | foo: None, 74 | bar: None, 75 | baz: None, 76 | validate: None, 77 | validate_parse: None, 78 | }); 79 | 80 | std::env::set_var("EMPTY_ERROR_IS_UNSET_BAZ", ""); 81 | assert_eq!(Partial::from_env().unwrap(), Partial { 82 | foo: None, 83 | bar: None, 84 | baz: Some("".into()), 85 | validate: None, 86 | validate_parse: None, 87 | }); 88 | 89 | std::env::set_var("EMPTY_ERROR_IS_UNSET_VALIDATE", ""); 90 | assert_eq!(Partial::from_env().unwrap(), Partial { 91 | foo: None, 92 | bar: None, 93 | baz: Some("".into()), 94 | validate: None, 95 | validate_parse: None, 96 | }); 97 | 98 | std::env::set_var("EMPTY_ERROR_IS_UNSET_VALIDATE_PARSE", ""); 99 | assert_eq!(Partial::from_env().unwrap(), Partial { 100 | foo: None, 101 | bar: None, 102 | baz: Some("".into()), 103 | validate: None, 104 | validate_parse: None, 105 | }); 106 | } 107 | -------------------------------------------------------------------------------- /tests/format-output/1-default.json5: -------------------------------------------------------------------------------- 1 | // A sample configuration for our app. 2 | { 3 | // Name of the website. 4 | // 5 | // Required! This value must be specified. 6 | //site_name: , 7 | 8 | // Configurations related to the HTTP communication. 9 | http: { 10 | // The port the server will listen on. 11 | // 12 | // Can also be specified via environment variable `PORT`. 13 | // 14 | // Required! This value must be specified. 15 | //port: , 16 | 17 | // The bind address of the server. Can be set to `0.0.0.0` for example, to 18 | // allow other users of the network to access the server. 19 | // 20 | // Default value: "127.0.0.1" 21 | //bind: "127.0.0.1", 22 | 23 | headers: { 24 | // The header in which the reverse proxy specifies the username. 25 | // 26 | // Default value: "x-username" 27 | //username: "x-username", 28 | 29 | // The header in which the reverse proxy specifies the display name. 30 | // 31 | // Default value: "x-display-name" 32 | //display_name: "x-display-name", 33 | 34 | // Headers that are allowed. 35 | // 36 | // Default value: ["content-type","content-encoding"] 37 | //allowed: ["content-type","content-encoding"], 38 | 39 | // Assigns a score to some headers. 40 | // 41 | // Default value: {"cookie":1.5,"server":12.7} 42 | //score: {"cookie":1.5,"server":12.7}, 43 | }, 44 | }, 45 | 46 | // Configuring the logging. 47 | log: { 48 | // If set to `true`, the app will log to stdout. 49 | // 50 | // Default value: true 51 | //stdout: true, 52 | 53 | // If this is set, the app will write logs to the given file. Of course, 54 | // the app has to have write access to that file. 55 | //file: , 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /tests/format-output/1-default.toml: -------------------------------------------------------------------------------- 1 | # A sample configuration for our app. 2 | 3 | # Name of the website. 4 | # 5 | # Required! This value must be specified. 6 | #site_name = 7 | 8 | # Configurations related to the HTTP communication. 9 | [http] 10 | # The port the server will listen on. 11 | # 12 | # Can also be specified via environment variable `PORT`. 13 | # 14 | # Required! This value must be specified. 15 | #port = 16 | 17 | # The bind address of the server. Can be set to `0.0.0.0` for example, to 18 | # allow other users of the network to access the server. 19 | # 20 | # Default value: "127.0.0.1" 21 | #bind = "127.0.0.1" 22 | 23 | [http.headers] 24 | # The header in which the reverse proxy specifies the username. 25 | # 26 | # Default value: "x-username" 27 | #username = "x-username" 28 | 29 | # The header in which the reverse proxy specifies the display name. 30 | # 31 | # Default value: "x-display-name" 32 | #display_name = "x-display-name" 33 | 34 | # Headers that are allowed. 35 | # 36 | # Default value: ["content-type", "content-encoding"] 37 | #allowed = ["content-type", "content-encoding"] 38 | 39 | # Assigns a score to some headers. 40 | # 41 | # Default value: { cookie = 1.5, server = 12.7 } 42 | #score = { cookie = 1.5, server = 12.7 } 43 | 44 | # Configuring the logging. 45 | [log] 46 | # If set to `true`, the app will log to stdout. 47 | # 48 | # Default value: true 49 | #stdout = true 50 | 51 | # If this is set, the app will write logs to the given file. Of course, 52 | # the app has to have write access to that file. 53 | #file = 54 | -------------------------------------------------------------------------------- /tests/format-output/1-default.yaml: -------------------------------------------------------------------------------- 1 | # A sample configuration for our app. 2 | 3 | # Name of the website. 4 | # 5 | # Required! This value must be specified. 6 | #site_name: 7 | 8 | # Configurations related to the HTTP communication. 9 | http: 10 | # The port the server will listen on. 11 | # 12 | # Can also be specified via environment variable `PORT`. 13 | # 14 | # Required! This value must be specified. 15 | #port: 16 | 17 | # The bind address of the server. Can be set to `0.0.0.0` for example, to 18 | # allow other users of the network to access the server. 19 | # 20 | # Default value: 127.0.0.1 21 | #bind: 127.0.0.1 22 | 23 | headers: 24 | # The header in which the reverse proxy specifies the username. 25 | # 26 | # Default value: x-username 27 | #username: x-username 28 | 29 | # The header in which the reverse proxy specifies the display name. 30 | # 31 | # Default value: x-display-name 32 | #display_name: x-display-name 33 | 34 | # Headers that are allowed. 35 | # 36 | # Default value: [content-type, content-encoding] 37 | #allowed: [content-type, content-encoding] 38 | 39 | # Assigns a score to some headers. 40 | # 41 | # Default value: { cookie: 1.5, server: 12.7 } 42 | #score: { cookie: 1.5, server: 12.7 } 43 | 44 | # Configuring the logging. 45 | log: 46 | # If set to `true`, the app will log to stdout. 47 | # 48 | # Default value: true 49 | #stdout: true 50 | 51 | # If this is set, the app will write logs to the given file. Of course, 52 | # the app has to have write access to that file. 53 | #file: 54 | -------------------------------------------------------------------------------- /tests/format-output/1-indent-2.toml: -------------------------------------------------------------------------------- 1 | # A sample configuration for our app. 2 | 3 | # Name of the website. 4 | # 5 | # Required! This value must be specified. 6 | #site_name = 7 | 8 | # Configurations related to the HTTP communication. 9 | [http] 10 | # The port the server will listen on. 11 | # 12 | # Can also be specified via environment variable `PORT`. 13 | # 14 | # Required! This value must be specified. 15 | #port = 16 | 17 | # The bind address of the server. Can be set to `0.0.0.0` for example, to 18 | # allow other users of the network to access the server. 19 | # 20 | # Default value: "127.0.0.1" 21 | #bind = "127.0.0.1" 22 | 23 | [http.headers] 24 | # The header in which the reverse proxy specifies the username. 25 | # 26 | # Default value: "x-username" 27 | #username = "x-username" 28 | 29 | # The header in which the reverse proxy specifies the display name. 30 | # 31 | # Default value: "x-display-name" 32 | #display_name = "x-display-name" 33 | 34 | # Headers that are allowed. 35 | # 36 | # Default value: ["content-type", "content-encoding"] 37 | #allowed = ["content-type", "content-encoding"] 38 | 39 | # Assigns a score to some headers. 40 | # 41 | # Default value: { cookie = 1.5, server = 12.7 } 42 | #score = { cookie = 1.5, server = 12.7 } 43 | 44 | # Configuring the logging. 45 | [log] 46 | # If set to `true`, the app will log to stdout. 47 | # 48 | # Default value: true 49 | #stdout = true 50 | 51 | # If this is set, the app will write logs to the given file. Of course, 52 | # the app has to have write access to that file. 53 | #file = 54 | -------------------------------------------------------------------------------- /tests/format-output/1-nested-gap-2.toml: -------------------------------------------------------------------------------- 1 | # A sample configuration for our app. 2 | 3 | # Name of the website. 4 | # 5 | # Required! This value must be specified. 6 | #site_name = 7 | 8 | 9 | # Configurations related to the HTTP communication. 10 | [http] 11 | # The port the server will listen on. 12 | # 13 | # Can also be specified via environment variable `PORT`. 14 | # 15 | # Required! This value must be specified. 16 | #port = 17 | 18 | # The bind address of the server. Can be set to `0.0.0.0` for example, to 19 | # allow other users of the network to access the server. 20 | # 21 | # Default value: "127.0.0.1" 22 | #bind = "127.0.0.1" 23 | 24 | 25 | [http.headers] 26 | # The header in which the reverse proxy specifies the username. 27 | # 28 | # Default value: "x-username" 29 | #username = "x-username" 30 | 31 | # The header in which the reverse proxy specifies the display name. 32 | # 33 | # Default value: "x-display-name" 34 | #display_name = "x-display-name" 35 | 36 | # Headers that are allowed. 37 | # 38 | # Default value: ["content-type", "content-encoding"] 39 | #allowed = ["content-type", "content-encoding"] 40 | 41 | # Assigns a score to some headers. 42 | # 43 | # Default value: { cookie = 1.5, server = 12.7 } 44 | #score = { cookie = 1.5, server = 12.7 } 45 | 46 | 47 | # Configuring the logging. 48 | [log] 49 | # If set to `true`, the app will log to stdout. 50 | # 51 | # Default value: true 52 | #stdout = true 53 | 54 | # If this is set, the app will write logs to the given file. Of course, 55 | # the app has to have write access to that file. 56 | #file = 57 | -------------------------------------------------------------------------------- /tests/format-output/1-no-comments.json5: -------------------------------------------------------------------------------- 1 | { 2 | //site_name: , 3 | 4 | http: { 5 | //port: , 6 | //bind: "127.0.0.1", 7 | 8 | headers: { 9 | //username: "x-username", 10 | //display_name: "x-display-name", 11 | //allowed: ["content-type","content-encoding"], 12 | //score: {"cookie":1.5,"server":12.7}, 13 | }, 14 | }, 15 | 16 | log: { 17 | //stdout: true, 18 | //file: , 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /tests/format-output/1-no-comments.toml: -------------------------------------------------------------------------------- 1 | #site_name = 2 | 3 | [http] 4 | #port = 5 | #bind = "127.0.0.1" 6 | 7 | [http.headers] 8 | #username = "x-username" 9 | #display_name = "x-display-name" 10 | #allowed = ["content-type", "content-encoding"] 11 | #score = { cookie = 1.5, server = 12.7 } 12 | 13 | [log] 14 | #stdout = true 15 | #file = 16 | -------------------------------------------------------------------------------- /tests/format-output/1-no-comments.yaml: -------------------------------------------------------------------------------- 1 | #site_name: 2 | 3 | http: 4 | #port: 5 | #bind: 127.0.0.1 6 | 7 | headers: 8 | #username: x-username 9 | #display_name: x-display-name 10 | #allowed: [content-type, content-encoding] 11 | #score: { cookie: 1.5, server: 12.7 } 12 | 13 | log: 14 | #stdout: true 15 | #file: 16 | -------------------------------------------------------------------------------- /tests/format-output/2-default.json5: -------------------------------------------------------------------------------- 1 | // A sample configuration for our app. 2 | { 3 | http: { 4 | headers: { 5 | // The header in which the reverse proxy specifies the username. 6 | // 7 | // Default value: "x-username" 8 | //username: "x-username", 9 | 10 | // The header in which the reverse proxy specifies the display name. 11 | // 12 | // Default value: "x-display-name" 13 | //display_name: "x-display-name", 14 | }, 15 | 16 | log: { 17 | // If set to `true`, the app will log to stdout. 18 | // 19 | // Default value: true 20 | //stdout: true, 21 | 22 | // If this is set, the app will write logs to the given file. Of course, 23 | // the app has to have write access to that file. 24 | //file: , 25 | }, 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /tests/format-output/2-default.toml: -------------------------------------------------------------------------------- 1 | # A sample configuration for our app. 2 | 3 | [http] 4 | [http.headers] 5 | # The header in which the reverse proxy specifies the username. 6 | # 7 | # Default value: "x-username" 8 | #username = "x-username" 9 | 10 | # The header in which the reverse proxy specifies the display name. 11 | # 12 | # Default value: "x-display-name" 13 | #display_name = "x-display-name" 14 | 15 | [http.log] 16 | # If set to `true`, the app will log to stdout. 17 | # 18 | # Default value: true 19 | #stdout = true 20 | 21 | # If this is set, the app will write logs to the given file. Of course, 22 | # the app has to have write access to that file. 23 | #file = 24 | -------------------------------------------------------------------------------- /tests/format-output/2-default.yaml: -------------------------------------------------------------------------------- 1 | # A sample configuration for our app. 2 | 3 | http: 4 | headers: 5 | # The header in which the reverse proxy specifies the username. 6 | # 7 | # Default value: x-username 8 | #username: x-username 9 | 10 | # The header in which the reverse proxy specifies the display name. 11 | # 12 | # Default value: x-display-name 13 | #display_name: x-display-name 14 | 15 | log: 16 | # If set to `true`, the app will log to stdout. 17 | # 18 | # Default value: true 19 | #stdout: true 20 | 21 | # If this is set, the app will write logs to the given file. Of course, 22 | # the app has to have write access to that file. 23 | #file: 24 | -------------------------------------------------------------------------------- /tests/general.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, net::IpAddr, path::PathBuf, convert::Infallible}; 2 | 3 | use pretty_assertions::assert_eq; 4 | use serde::Deserialize; 5 | 6 | use confique::{meta, Config, Partial}; 7 | 8 | 9 | #[test] 10 | fn simple() { 11 | /// Root doc comment banana. 12 | #[derive(Config)] 13 | #[allow(dead_code)] 14 | struct Animals { 15 | /// Doc comment for cat. 16 | #[config(default = 8080)] 17 | cat: u32, 18 | 19 | /// Doc comment for dog. 20 | dog: String, 21 | } 22 | 23 | assert_eq!(Animals::META, meta::Meta { 24 | name: "Animals", 25 | doc: &[" Root doc comment banana."], 26 | fields: &[ 27 | meta::Field { 28 | name: "cat", 29 | doc: &[" Doc comment for cat."], 30 | kind: meta::FieldKind::Leaf { 31 | env: None, 32 | kind: meta::LeafKind::Required { 33 | default: Some(meta::Expr::Integer(meta::Integer::U32(8080))), 34 | }, 35 | }, 36 | }, 37 | meta::Field { 38 | name: "dog", 39 | doc: &[" Doc comment for dog."], 40 | kind: meta::FieldKind::Leaf { 41 | env: None, 42 | kind: meta::LeafKind::Required { 43 | default: None, 44 | }, 45 | }, 46 | }, 47 | ], 48 | }); 49 | 50 | let def = ::Partial::default_values(); 51 | assert_eq!(def.cat, Some(8080)); 52 | assert_eq!(def.dog, None); 53 | } 54 | 55 | mod full { 56 | #![allow(dead_code)] 57 | 58 | use super::*; 59 | 60 | /// A sample configuration for our app. 61 | #[derive(Config)] 62 | pub(crate) struct Conf { 63 | /// Leaf field on top level struct. 64 | app_name: String, 65 | 66 | #[config(nested)] 67 | normal: NormalTest, 68 | 69 | #[config(nested)] 70 | deserialize_with: DeserializeWithTest, 71 | 72 | /// Doc comment on nested. 73 | #[config(nested)] 74 | env: EnvTest, 75 | } 76 | 77 | #[derive(Config)] 78 | pub(crate) struct NormalTest { 79 | required: String, 80 | 81 | #[config(default = "127.0.0.1")] 82 | with_default: IpAddr, 83 | 84 | optional: Option, 85 | } 86 | 87 | /// Testing the `deserialize_with` attribute! 88 | /// Multiline, wow! 89 | #[derive(Config)] 90 | pub(crate) struct DeserializeWithTest { 91 | #[config(deserialize_with = deserialize_dummy)] 92 | required: Dummy, 93 | 94 | #[config(deserialize_with = deserialize_dummy, default = "peter")] 95 | with_default: Dummy, 96 | 97 | #[config(deserialize_with = deserialize_dummy)] 98 | optional: Option, 99 | 100 | #[config(env = "ENV_TEST_FULL_0", deserialize_with = deserialize_dummy)] 101 | with_env: Dummy, 102 | } 103 | 104 | /// Doc comment on nested struct! 105 | #[derive(Config)] 106 | pub(crate) struct EnvTest { 107 | #[config(env = "ENV_TEST_FULL_1")] 108 | required: String, 109 | 110 | #[config(env = "ENV_TEST_FULL_2", default = 8080)] 111 | with_default: u16, 112 | 113 | #[config(env = "ENV_TEST_FULL_3")] 114 | optional: Option, 115 | 116 | #[config(env = "ENV_TEST_FULL_4", parse_env = parse_dummy_collection)] 117 | env_collection: DummyCollection, 118 | } 119 | } 120 | 121 | #[test] 122 | fn full() { 123 | use full::*; 124 | 125 | assert_eq!(Conf::META, meta::Meta { 126 | name: "Conf", 127 | doc: &[" A sample configuration for our app."], 128 | fields: &[ 129 | meta::Field { 130 | name: "app_name", 131 | doc: &[" Leaf field on top level struct."], 132 | kind: meta::FieldKind::Leaf { 133 | env: None, 134 | kind: meta::LeafKind::Required { default: None }, 135 | }, 136 | }, 137 | meta::Field { 138 | name: "normal", 139 | doc: &[], 140 | kind: meta::FieldKind::Nested { 141 | meta: &meta::Meta { 142 | name: "NormalTest", 143 | doc: &[], 144 | fields: &[ 145 | meta::Field { 146 | name: "required", 147 | doc: &[], 148 | kind: meta::FieldKind::Leaf { 149 | env: None, 150 | kind: meta::LeafKind::Required { default: None }, 151 | }, 152 | }, 153 | meta::Field { 154 | name: "with_default", 155 | doc: &[], 156 | kind: meta::FieldKind::Leaf { 157 | env: None, 158 | kind: meta::LeafKind::Required { 159 | default: Some(meta::Expr::Str("127.0.0.1")), 160 | }, 161 | }, 162 | }, 163 | meta::Field { 164 | name: "optional", 165 | doc: &[], 166 | kind: meta::FieldKind::Leaf { 167 | env: None, 168 | kind: meta::LeafKind::Optional, 169 | }, 170 | }, 171 | ], 172 | }, 173 | }, 174 | }, 175 | meta::Field { 176 | name: "deserialize_with", 177 | doc: &[], 178 | kind: meta::FieldKind::Nested { 179 | meta: &meta::Meta { 180 | name: "DeserializeWithTest", 181 | doc: &[" Testing the `deserialize_with` attribute!", " Multiline, wow!"], 182 | fields: &[ 183 | meta::Field { 184 | name: "required", 185 | doc: &[], 186 | kind: meta::FieldKind::Leaf { 187 | env: None, 188 | kind: meta::LeafKind::Required { default: None }, 189 | }, 190 | }, 191 | meta::Field { 192 | name: "with_default", 193 | doc: &[], 194 | kind: meta::FieldKind::Leaf { 195 | env: None, 196 | kind: meta::LeafKind::Required { 197 | default: Some(meta::Expr::Str("peter")), 198 | }, 199 | }, 200 | }, 201 | meta::Field { 202 | name: "optional", 203 | doc: &[], 204 | kind: meta::FieldKind::Leaf { 205 | env: None, 206 | kind: meta::LeafKind::Optional, 207 | }, 208 | }, 209 | meta::Field { 210 | name: "with_env", 211 | doc: &[], 212 | kind: meta::FieldKind::Leaf { 213 | env: Some("ENV_TEST_FULL_0"), 214 | kind: meta::LeafKind::Required { default: None }, 215 | }, 216 | }, 217 | ] 218 | }, 219 | }, 220 | }, 221 | meta::Field { 222 | name: "env", 223 | doc: &[" Doc comment on nested."], 224 | kind: meta::FieldKind::Nested { 225 | meta: &meta::Meta { 226 | name: "EnvTest", 227 | doc: &[" Doc comment on nested struct!"], 228 | fields: &[ 229 | meta::Field { 230 | name: "required", 231 | doc: &[], 232 | kind: meta::FieldKind::Leaf { 233 | env: Some("ENV_TEST_FULL_1"), 234 | kind: meta::LeafKind::Required { default: None }, 235 | }, 236 | }, 237 | meta::Field { 238 | name: "with_default", 239 | doc: &[], 240 | kind: meta::FieldKind::Leaf { 241 | env: Some("ENV_TEST_FULL_2"), 242 | kind: meta::LeafKind::Required { 243 | default: Some( 244 | meta::Expr::Integer(meta::Integer::U16(8080)) 245 | ), 246 | }, 247 | }, 248 | }, 249 | meta::Field { 250 | name: "optional", 251 | doc: &[], 252 | kind: meta::FieldKind::Leaf { 253 | env: Some("ENV_TEST_FULL_3"), 254 | kind: meta::LeafKind::Optional, 255 | }, 256 | }, 257 | meta::Field { 258 | name: "env_collection", 259 | doc: &[], 260 | kind: meta::FieldKind::Leaf { 261 | env: Some("ENV_TEST_FULL_4"), 262 | kind: meta::LeafKind::Required { default: None }, 263 | }, 264 | }, 265 | ], 266 | }, 267 | }, 268 | }, 269 | ], 270 | }); 271 | 272 | let def = ::Partial::default_values(); 273 | assert_eq!(def.app_name, None); 274 | assert_eq!(def.normal.required, None); 275 | assert_eq!(def.normal.with_default, Some(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))); 276 | assert_eq!(def.normal.optional, None); 277 | assert_eq!(def.deserialize_with.required, None); 278 | assert_eq!(def.deserialize_with.with_default, Some(Dummy("dummy peter".into()))); 279 | assert_eq!(def.deserialize_with.optional, None); 280 | assert_eq!(def.deserialize_with.with_env, None); 281 | assert_eq!(def.env.required, None); 282 | assert_eq!(def.env.with_default, Some(8080)); 283 | assert_eq!(def.env.optional, None); 284 | assert_eq!(def.env.env_collection, None); 285 | } 286 | 287 | #[derive(Debug, PartialEq)] 288 | struct Dummy(String); 289 | 290 | pub(crate) fn deserialize_dummy<'de, D>(deserializer: D) -> Result 291 | where 292 | D: serde::Deserializer<'de>, 293 | { 294 | let s = String::deserialize(deserializer)?; 295 | Ok(Dummy(format!("dummy {s}"))) 296 | } 297 | 298 | #[derive(Debug, PartialEq, Deserialize)] 299 | struct DummyCollection(Vec); 300 | 301 | pub(crate) fn parse_dummy_collection(input: &str) -> Result { 302 | Ok(DummyCollection( 303 | input.split(',').map(ToString::to_string).collect(), 304 | )) 305 | } 306 | 307 | // This only makes sure this compiles and doesn't result in any "cannot infer 308 | // type" problems. 309 | #[test] 310 | fn empty_array_and_map() { 311 | #[derive(Config)] 312 | #[allow(dead_code)] 313 | struct Animals { 314 | #[config(default = [])] 315 | cat: Vec, 316 | 317 | #[config(default = {})] 318 | dog: HashMap, 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /tests/indirect-serde/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | -------------------------------------------------------------------------------- /tests/indirect-serde/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "indirect-serde" 3 | version = "0.0.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | confique = { path = "../..", default-features = false } 8 | -------------------------------------------------------------------------------- /tests/indirect-serde/README.md: -------------------------------------------------------------------------------- 1 | This is just a compile test to make sure `confique`'s derives also work when the using crate does not have `serde` in its direct dependencies. 2 | Having this test as separate folder with a `run.rs` calling `cargo` was the easiest way I found to do that. 3 | That way, this is also executed as part of `cargo test`. 4 | -------------------------------------------------------------------------------- /tests/indirect-serde/run.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | fn main() { 4 | let res = Command::new("cargo") 5 | .args(["check"]) 6 | .current_dir("tests/indirect-serde") 7 | .status(); 8 | 9 | assert!(res.is_ok_and(|exitcode| exitcode.success())); 10 | } 11 | -------------------------------------------------------------------------------- /tests/indirect-serde/src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use confique::Config; 4 | 5 | #[derive(Config)] 6 | struct Conf { 7 | #[config(deserialize_with = my_deserialize_fn)] 8 | username: String, 9 | 10 | normal: u32, 11 | 12 | opt: Option, 13 | 14 | #[config(nested)] 15 | nested: Nested, 16 | } 17 | 18 | #[derive(Config)] 19 | struct Nested { 20 | #[config(env = "APP_PORT")] 21 | port: u16, 22 | 23 | #[config(default = "127.0.0.1")] 24 | bind: std::net::IpAddr, 25 | 26 | #[config(default = ["x-user", "x-password"])] 27 | headers: Vec, 28 | } 29 | 30 | fn my_deserialize_fn<'de, D>(_: D) -> Result 31 | where 32 | D: confique::serde::Deserializer<'de>, 33 | { 34 | todo!() 35 | } 36 | 37 | fn main() {} 38 | -------------------------------------------------------------------------------- /tests/map_default.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use pretty_assertions::assert_eq; 3 | 4 | use confique::{meta, Config}; 5 | 6 | 7 | #[test] 8 | fn string_to_u32() { 9 | #[derive(Config)] 10 | struct Foo { 11 | /// A nice doc comment. 12 | #[config(default = { "peter": 3, "anna": 27 })] 13 | bar: HashMap, 14 | } 15 | 16 | assert_eq!(Foo::META, meta::Meta { 17 | name: "Foo", 18 | doc: &[], 19 | fields: &[ 20 | meta::Field { 21 | name: "bar", 22 | doc: &[" A nice doc comment."], 23 | kind: meta::FieldKind::Leaf { 24 | env: None, 25 | kind: meta::LeafKind::Required { 26 | default: Some(meta::Expr::Map(&[ 27 | meta::MapEntry { 28 | key: meta::MapKey::Str("peter"), 29 | value: meta::Expr::Integer(meta::Integer::U32(3)), 30 | }, 31 | meta::MapEntry { 32 | key: meta::MapKey::Str("anna"), 33 | value: meta::Expr::Integer(meta::Integer::U32(27)), 34 | }, 35 | ])), 36 | }, 37 | }, 38 | }, 39 | ], 40 | }); 41 | 42 | let def = Foo::builder().load().unwrap(); 43 | assert_eq!(def.bar, HashMap::from([("peter".into(), 3), ("anna".into(), 27)])); 44 | } 45 | -------------------------------------------------------------------------------- /tests/validation.rs: -------------------------------------------------------------------------------- 1 | use pretty_assertions::assert_eq; 2 | 3 | use confique::{Config, Partial}; 4 | 5 | 6 | fn validate_not_1234(foo: &u32) -> Result<(), &'static str> { 7 | if *foo == 1234 { 8 | Err("bad password") 9 | } else { 10 | Ok(()) 11 | } 12 | } 13 | 14 | #[test] 15 | #[should_panic(expected = "default config value for `Conf::foo` cannot be \ 16 | deserialized: Error(\"validation failed: bad password\")")] 17 | fn invalid_default_panics_function() { 18 | #[derive(Config)] 19 | #[allow(dead_code)] 20 | struct Conf { 21 | #[config(default = 1234, validate = validate_not_1234)] 22 | foo: u32, 23 | } 24 | 25 | let _ = ::Partial::default_values(); 26 | } 27 | 28 | #[test] 29 | #[should_panic(expected = "default config value for `Conf::foo` cannot be \ 30 | deserialized: Error(\"validation failed: ugly number\")")] 31 | fn invalid_default_panics_assert_like() { 32 | #[derive(Config)] 33 | #[allow(dead_code)] 34 | struct Conf { 35 | #[config(default = 1234, validate(*foo != 1234, "ugly number"))] 36 | foo: u32, 37 | } 38 | 39 | let _ = ::Partial::default_values(); 40 | } 41 | 42 | #[test] 43 | fn assert_like() { 44 | #[derive(Config)] 45 | #[allow(dead_code)] 46 | #[config(partial_attr(derive(Debug, PartialEq)))] 47 | struct Conf { 48 | #[config( 49 | env = "AL_REQ", 50 | validate(req.is_ascii(), "non-ASCII characters ~req are not allowed"), 51 | )] 52 | req: String, 53 | 54 | #[config( 55 | env = "AL_DEF", 56 | default = "root", 57 | validate(def.is_ascii(), "non-ASCII characters ~def are not allowed"), 58 | )] 59 | def: String, 60 | 61 | #[config( 62 | env = "AL_OPT", 63 | validate(opt.is_ascii(), "non-ASCII characters ~opt are not allowed"), 64 | )] 65 | opt: Option, 66 | } 67 | 68 | type Partial = ::Partial; 69 | 70 | // Defaults 71 | assert_eq!(Partial::default_values(), Partial { 72 | req: None, 73 | def: Some("root".into()), 74 | opt: None, 75 | }); 76 | 77 | 78 | // From env 79 | std::env::set_var("AL_REQ", "jürgen"); 80 | assert_err_contains(Partial::from_env(), "non-ASCII characters ~req are not allowed"); 81 | std::env::set_var("AL_REQ", "cat"); 82 | assert_eq!(Partial::from_env().unwrap(), Partial { 83 | req: Some("cat".into()), 84 | def: None, 85 | opt: None, 86 | }); 87 | 88 | std::env::set_var("AL_DEF", "I ❤️ fluffy animals"); 89 | assert_err_contains(Partial::from_env(), "non-ASCII characters ~def are not allowed"); 90 | std::env::set_var("AL_DEF", "dog"); 91 | assert_eq!(Partial::from_env().unwrap(), Partial { 92 | req: Some("cat".into()), 93 | def: Some("dog".into()), 94 | opt: None, 95 | }); 96 | 97 | std::env::set_var("AL_OPT", "Μου αρέσουν τα χνουδωτά ζώα"); 98 | assert_err_contains(Partial::from_env(), "non-ASCII characters ~opt are not allowed"); 99 | std::env::set_var("AL_OPT", "fox"); 100 | assert_eq!(Partial::from_env().unwrap(), Partial { 101 | req: Some("cat".into()), 102 | def: Some("dog".into()), 103 | opt: Some("fox".into()), 104 | }); 105 | 106 | 107 | // From file 108 | assert_err_contains( 109 | toml::from_str::(r#"req = "jürgen""#), 110 | "non-ASCII characters ~req are not allowed", 111 | ); 112 | assert_err_contains( 113 | toml::from_str::(r#"def = "I ❤️ fluffy animals""#), 114 | "non-ASCII characters ~def are not allowed", 115 | ); 116 | assert_err_contains( 117 | toml::from_str::(r#"opt = "Μου αρέσουν τα χνουδωτά ζώα""#), 118 | "non-ASCII characters ~opt are not allowed", 119 | ); 120 | assert_eq!( 121 | toml::from_str::("req = \"cat\"\ndef = \"dog\"\nopt = \"fox\"").unwrap(), 122 | Partial { 123 | req: Some("cat".into()), 124 | def: Some("dog".into()), 125 | opt: Some("fox".into()), 126 | }, 127 | ); 128 | } 129 | 130 | fn assert_is_ascii(s: &String) -> Result<(), &'static str> { 131 | if !s.is_ascii() { 132 | Err("non-ASCII characters are not allowed") 133 | } else { 134 | Ok(()) 135 | } 136 | } 137 | 138 | #[test] 139 | fn function() { 140 | #[derive(Config)] 141 | #[allow(dead_code)] 142 | #[config(partial_attr(derive(Debug, PartialEq)))] 143 | struct Conf { 144 | #[config(env = "FN_REQ", validate = assert_is_ascii)] 145 | req: String, 146 | 147 | #[config(env = "FN_DEF", default = "root", validate = assert_is_ascii)] 148 | def: String, 149 | 150 | #[config(env = "FN_OPT", validate = assert_is_ascii)] 151 | opt: Option, 152 | } 153 | 154 | type Partial = ::Partial; 155 | 156 | // Defaults 157 | assert_eq!(Partial::default_values(), Partial { 158 | req: None, 159 | def: Some("root".into()), 160 | opt: None, 161 | }); 162 | 163 | 164 | // From env 165 | std::env::set_var("FN_REQ", "jürgen"); 166 | assert_err_contains(Partial::from_env(), "non-ASCII characters are not allowed"); 167 | std::env::set_var("FN_REQ", "cat"); 168 | assert_eq!(Partial::from_env().unwrap(), Partial { 169 | req: Some("cat".into()), 170 | def: None, 171 | opt: None, 172 | }); 173 | 174 | std::env::set_var("FN_DEF", "I ❤️ fluffy animals"); 175 | assert_err_contains(Partial::from_env(), "non-ASCII characters are not allowed"); 176 | std::env::set_var("FN_DEF", "dog"); 177 | assert_eq!(Partial::from_env().unwrap(), Partial { 178 | req: Some("cat".into()), 179 | def: Some("dog".into()), 180 | opt: None, 181 | }); 182 | 183 | std::env::set_var("FN_OPT", "Μου αρέσουν τα χνουδωτά ζώα"); 184 | assert_err_contains(Partial::from_env(), "non-ASCII characters are not allowed"); 185 | std::env::set_var("FN_OPT", "fox"); 186 | assert_eq!(Partial::from_env().unwrap(), Partial { 187 | req: Some("cat".into()), 188 | def: Some("dog".into()), 189 | opt: Some("fox".into()), 190 | }); 191 | 192 | 193 | // From file 194 | assert_err_contains( 195 | toml::from_str::(r#"req = "jürgen""#), 196 | "non-ASCII characters are not allowed", 197 | ); 198 | assert_err_contains( 199 | toml::from_str::(r#"def = "I ❤️ fluffy animals""#), 200 | "non-ASCII characters are not allowed", 201 | ); 202 | assert_err_contains( 203 | toml::from_str::(r#"opt = "Μου αρέσουν τα χνουδωτά ζώα""#), 204 | "non-ASCII characters are not allowed", 205 | ); 206 | assert_eq!( 207 | toml::from_str::("req = \"cat\"\ndef = \"dog\"\nopt = \"fox\"").unwrap(), 208 | Partial { 209 | req: Some("cat".into()), 210 | def: Some("dog".into()), 211 | opt: Some("fox".into()), 212 | }, 213 | ); 214 | } 215 | 216 | fn deserialize_append<'de, D>(deserializer: D) -> Result 217 | where 218 | D: serde::Deserializer<'de>, 219 | { 220 | let mut s = ::deserialize(deserializer)?; 221 | s.push_str("-henlo"); 222 | Ok(s) 223 | } 224 | 225 | #[test] 226 | fn assert_like_with_deserializer() { 227 | #[derive(Config)] 228 | #[allow(dead_code)] 229 | #[config(partial_attr(derive(Debug, PartialEq)))] 230 | struct Conf { 231 | #[config( 232 | env = "ALD_REQ", 233 | deserialize_with = deserialize_append, 234 | validate(req.is_ascii(), "non-ASCII characters ~req are not allowed"), 235 | )] 236 | req: String, 237 | 238 | #[config( 239 | env = "ALD_DEF", 240 | default = "root", 241 | deserialize_with = deserialize_append, 242 | validate(def.is_ascii(), "non-ASCII characters ~def are not allowed"), 243 | )] 244 | def: String, 245 | 246 | #[config( 247 | env = "ALD_OPT", 248 | deserialize_with = deserialize_append, 249 | validate(opt.is_ascii(), "non-ASCII characters ~opt are not allowed"), 250 | )] 251 | opt: Option, 252 | } 253 | 254 | type Partial = ::Partial; 255 | 256 | // Defaults 257 | assert_eq!(Partial::default_values(), Partial { 258 | req: None, 259 | def: Some("root-henlo".into()), 260 | opt: None, 261 | }); 262 | 263 | 264 | // From env 265 | std::env::set_var("ALD_REQ", "jürgen"); 266 | assert_err_contains(Partial::from_env(), "non-ASCII characters ~req are not allowed"); 267 | std::env::set_var("ALD_REQ", "cat"); 268 | assert_eq!(Partial::from_env().unwrap(), Partial { 269 | req: Some("cat-henlo".into()), 270 | def: None, 271 | opt: None, 272 | }); 273 | 274 | std::env::set_var("ALD_DEF", "I ❤️ fluffy animals"); 275 | assert_err_contains(Partial::from_env(), "non-ASCII characters ~def are not allowed"); 276 | std::env::set_var("ALD_DEF", "dog"); 277 | assert_eq!(Partial::from_env().unwrap(), Partial { 278 | req: Some("cat-henlo".into()), 279 | def: Some("dog-henlo".into()), 280 | opt: None, 281 | }); 282 | 283 | std::env::set_var("ALD_OPT", "Μου αρέσουν τα χνουδωτά ζώα"); 284 | assert_err_contains(Partial::from_env(), "non-ASCII characters ~opt are not allowed"); 285 | std::env::set_var("ALD_OPT", "fox"); 286 | assert_eq!(Partial::from_env().unwrap(), Partial { 287 | req: Some("cat-henlo".into()), 288 | def: Some("dog-henlo".into()), 289 | opt: Some("fox-henlo".into()), 290 | }); 291 | 292 | 293 | // From file 294 | assert_err_contains( 295 | toml::from_str::(r#"req = "jürgen""#), 296 | "non-ASCII characters ~req are not allowed", 297 | ); 298 | assert_err_contains( 299 | toml::from_str::(r#"def = "I ❤️ fluffy animals""#), 300 | "non-ASCII characters ~def are not allowed", 301 | ); 302 | assert_err_contains( 303 | toml::from_str::(r#"opt = "Μου αρέσουν τα χνουδωτά ζώα""#), 304 | "non-ASCII characters ~opt are not allowed", 305 | ); 306 | assert_eq!( 307 | toml::from_str::("req = \"cat\"\ndef = \"dog\"\nopt = \"fox\"").unwrap(), 308 | Partial { 309 | req: Some("cat-henlo".into()), 310 | def: Some("dog-henlo".into()), 311 | opt: Some("fox-henlo".into()), 312 | }, 313 | ); 314 | } 315 | 316 | #[test] 317 | fn function_with_deserializer() { 318 | #[derive(Config)] 319 | #[allow(dead_code)] 320 | #[config(partial_attr(derive(Debug, PartialEq)))] 321 | struct Conf { 322 | #[config( 323 | env = "FND_REQ", 324 | validate = assert_is_ascii, 325 | deserialize_with = deserialize_append, 326 | )] 327 | req: String, 328 | 329 | #[config( 330 | env = "FND_DEF", 331 | default = "root", 332 | validate = assert_is_ascii, 333 | deserialize_with = deserialize_append, 334 | )] 335 | def: String, 336 | 337 | #[config( 338 | env = "FND_OPT", 339 | validate = assert_is_ascii, 340 | deserialize_with = deserialize_append, 341 | )] 342 | opt: Option, 343 | } 344 | 345 | type Partial = ::Partial; 346 | 347 | // Defaults 348 | assert_eq!(Partial::default_values(), Partial { 349 | req: None, 350 | def: Some("root-henlo".into()), 351 | opt: None, 352 | }); 353 | 354 | 355 | // From env 356 | std::env::set_var("FND_REQ", "jürgen"); 357 | assert_err_contains(Partial::from_env(), "non-ASCII characters are not allowed"); 358 | std::env::set_var("FND_REQ", "cat"); 359 | assert_eq!(Partial::from_env().unwrap(), Partial { 360 | req: Some("cat-henlo".into()), 361 | def: None, 362 | opt: None, 363 | }); 364 | 365 | std::env::set_var("FND_DEF", "I ❤️ fluffy animals"); 366 | assert_err_contains(Partial::from_env(), "non-ASCII characters are not allowed"); 367 | std::env::set_var("FND_DEF", "dog"); 368 | assert_eq!(Partial::from_env().unwrap(), Partial { 369 | req: Some("cat-henlo".into()), 370 | def: Some("dog-henlo".into()), 371 | opt: None, 372 | }); 373 | 374 | std::env::set_var("FND_OPT", "Μου αρέσουν τα χνουδωτά ζώα"); 375 | assert_err_contains(Partial::from_env(), "non-ASCII characters are not allowed"); 376 | std::env::set_var("FND_OPT", "fox"); 377 | assert_eq!(Partial::from_env().unwrap(), Partial { 378 | req: Some("cat-henlo".into()), 379 | def: Some("dog-henlo".into()), 380 | opt: Some("fox-henlo".into()), 381 | }); 382 | 383 | 384 | // From file 385 | assert_err_contains( 386 | toml::from_str::(r#"req = "jürgen""#), 387 | "non-ASCII characters are not allowed", 388 | ); 389 | assert_err_contains( 390 | toml::from_str::(r#"def = "I ❤️ fluffy animals""#), 391 | "non-ASCII characters are not allowed", 392 | ); 393 | assert_err_contains( 394 | toml::from_str::(r#"opt = "Μου αρέσουν τα χνουδωτά ζώα""#), 395 | "non-ASCII characters are not allowed", 396 | ); 397 | assert_eq!( 398 | toml::from_str::("req = \"cat\"\ndef = \"dog\"\nopt = \"fox\"").unwrap(), 399 | Partial { 400 | req: Some("cat-henlo".into()), 401 | def: Some("dog-henlo".into()), 402 | opt: Some("fox-henlo".into()), 403 | }, 404 | ); 405 | } 406 | 407 | fn validate_vec(v: &Vec) -> Result<(), &'static str> { 408 | if v.len() < 3 { 409 | return Err("list too short"); 410 | } 411 | Ok(()) 412 | } 413 | 414 | #[test] 415 | fn parse_env() { 416 | #[derive(Config)] 417 | #[allow(dead_code)] 418 | #[config(partial_attr(derive(Debug, PartialEq)))] 419 | struct Conf { 420 | #[config( 421 | env = "PE_FUN", 422 | parse_env = confique::env::parse::list_by_comma, 423 | validate = validate_vec, 424 | )] 425 | function: Vec, 426 | 427 | #[config( 428 | env = "PE_AL", 429 | parse_env = confique::env::parse::list_by_colon, 430 | validate(assert_like.len() >= 3, "list too ~req short"), 431 | )] 432 | assert_like: Vec, 433 | 434 | #[config( 435 | env = "PE_FUN_OPT", 436 | parse_env = confique::env::parse::list_by_semicolon, 437 | validate = validate_vec, 438 | )] 439 | function_opt: Option>, 440 | 441 | #[config( 442 | env = "PE_AL_OPT", 443 | parse_env = confique::env::parse::list_by_space, 444 | validate(assert_like_opt.len() >= 3, "list too ~opt short"), 445 | )] 446 | assert_like_opt: Option>, 447 | } 448 | 449 | type Partial = ::Partial; 450 | 451 | 452 | std::env::set_var("PE_FUN", "1,2"); 453 | assert_err_contains(Partial::from_env(), "list too short"); 454 | std::env::set_var("PE_FUN", "1,2,3"); 455 | assert_eq!(Partial::from_env().unwrap(), Partial { 456 | function: Some(vec![1, 2, 3]), 457 | assert_like: None, 458 | function_opt: None, 459 | assert_like_opt: None, 460 | }); 461 | 462 | std::env::set_var("PE_AL", "1:2"); 463 | assert_err_contains(Partial::from_env(), "list too ~req short"); 464 | std::env::set_var("PE_AL", "1:2:3"); 465 | assert_eq!(Partial::from_env().unwrap(), Partial { 466 | function: Some(vec![1, 2, 3]), 467 | assert_like: Some(vec![1, 2, 3]), 468 | function_opt: None, 469 | assert_like_opt: None, 470 | }); 471 | 472 | std::env::set_var("PE_FUN_OPT", "1;2"); 473 | assert_err_contains(Partial::from_env(), "list too short"); 474 | std::env::set_var("PE_FUN_OPT", "1;2;3"); 475 | assert_eq!(Partial::from_env().unwrap(), Partial { 476 | function: Some(vec![1, 2, 3]), 477 | assert_like: Some(vec![1, 2, 3]), 478 | function_opt: Some(vec![1, 2, 3]), 479 | assert_like_opt: None, 480 | }); 481 | 482 | std::env::set_var("PE_AL_OPT", "1 2"); 483 | assert_err_contains(Partial::from_env(), "list too ~opt short"); 484 | std::env::set_var("PE_AL_OPT", "1 2 3"); 485 | assert_eq!(Partial::from_env().unwrap(), Partial { 486 | function: Some(vec![1, 2, 3]), 487 | assert_like: Some(vec![1, 2, 3]), 488 | function_opt: Some(vec![1, 2, 3]), 489 | assert_like_opt: Some(vec![1, 2, 3]), 490 | }); 491 | } 492 | 493 | #[test] 494 | fn struct_validation() { 495 | #[derive(Config, PartialEq, Debug)] 496 | #[allow(dead_code)] 497 | #[config(validate = Self::validate)] 498 | struct Conf { 499 | foo: Option, 500 | bar: Option, 501 | } 502 | 503 | impl Conf { 504 | fn validate(&self) -> Result<(), &'static str> { 505 | if !(self.foo.is_some() ^ self.bar.is_some()) { 506 | return Err("exactly one of foo and bar must be set"); 507 | } 508 | Ok(()) 509 | } 510 | } 511 | 512 | let load = |s: &str| { 513 | let partial = toml::from_str::<::Partial>(s).unwrap(); 514 | Conf::from_partial(partial) 515 | }; 516 | 517 | assert_eq!(load("foo = 123").unwrap(), Conf { 518 | foo: Some(123), 519 | bar: None, 520 | }); 521 | assert_eq!(load("bar = 27").unwrap(), Conf { 522 | foo: None, 523 | bar: Some(27), 524 | }); 525 | assert_err_contains(load(""), "exactly one of foo and bar must be set"); 526 | assert_err_contains(load("foo = 123\nbar=27"), "exactly one of foo and bar must be set"); 527 | } 528 | 529 | #[track_caller] 530 | fn assert_err_contains(r: Result, expected: &str) { 531 | let e = r.map(|_| ()).unwrap_err(); 532 | let s = format!("{e:#}"); 533 | if !s.contains(expected) { 534 | panic!("expected error msg to contain '{expected}', but it doesn't: \n{s}"); 535 | } 536 | } 537 | --------------------------------------------------------------------------------