props_util/lib.rs
1//! # Props-Util
2//!
3//! A Rust library for easily loading and parsing properties files into strongly-typed structs.
4//!
5//! ## Overview
6//!
7//! Props-Util provides a procedural macro that allows you to derive a `Properties` trait for your structs,
8//! enabling automatic parsing of properties files into your struct fields. This makes configuration
9//! management in Rust applications more type-safe and convenient.
10//!
11//! ## Features
12//!
13//! - Derive macro for automatic properties parsing
14//! - Support for default values
15//! - Type conversion from string to your struct's field types
16//! - Error handling for missing or malformed properties
17//! - Support for both file-based and default initialization
18//! - Type conversion between different configuration types
19//!
20//! ## Usage
21//!
22//! ### Basic Example
23//!
24//! ```rust
25//! use props_util::Properties;
26//! use std::io::Result;
27//!
28//! #[derive(Properties, Debug)]
29//! struct Config {
30//! #[prop(key = "server.host", default = "localhost")]
31//! host: String,
32//!
33//! #[prop(key = "server.port", default = "8080")]
34//! port: u16,
35//!
36//! #[prop(key = "debug.enabled", default = "false")]
37//! debug: bool,
38//! }
39//!
40//! fn main() -> Result<()> {
41//! // Create a temporary file for testing
42//! let temp_file = tempfile::NamedTempFile::new()?;
43//! std::fs::write(&temp_file, "server.host=example.com\nserver.port=9090\ndebug.enabled=true")?;
44//!
45//! let config = Config::from_file(temp_file.path().to_str().unwrap())?;
46//! println!("Server: {}:{}", config.host, config.port);
47//! println!("Debug mode: {}", config.debug);
48//! Ok(())
49//! }
50//! ```
51//!
52//! ### Attribute Parameters
53//!
54//! The `#[prop]` attribute accepts the following parameters:
55//!
56//! - `key`: The property key to look for in the properties file (optional). If not specified, the field name will be used as the key.
57//! - `default`: A default value to use if the property is not found in the file (optional)
58//!
59//! ### Field Types
60//!
61//! Props-Util supports any type that implements `FromStr`. This includes:
62//!
63//! - `String`
64//! - Numeric types (`u8`, `u16`, `u32`, `u64`, `i8`, `i16`, `i32`, `i64`, `f32`, `f64`)
65//! - Boolean (`bool`)
66//! - `Vec<T>` where `T` implements `FromStr` (values are comma-separated in the properties file)
67//! - `Option<T>` where `T` implements `FromStr` (optional fields that may or may not be present in the properties file)
68//! - Custom types that implement `FromStr`
69//!
70//! ### Example of using Vec and Option types:
71//!
72//! ```rust
73//! use props_util::Properties;
74//! use std::io::Result;
75//!
76//! #[derive(Properties, Debug)]
77//! struct Config {
78//! #[prop(key = "numbers", default = "1,2,3")]
79//! numbers: Vec<i32>,
80//!
81//! #[prop(key = "strings", default = "hello,world")]
82//! strings: Vec<String>,
83//!
84//! #[prop(key = "optional_port")] // No default needed for Option
85//! optional_port: Option<u16>,
86//!
87//! #[prop(key = "optional_host")] // No default needed for Option
88//! optional_host: Option<String>,
89//! }
90//!
91//! fn main() -> Result<()> {
92//! // Create a temporary file for testing
93//! let temp_file = tempfile::NamedTempFile::new()?;
94//! std::fs::write(&temp_file, "numbers=4,5,6,7\nstrings=test,vec,parsing\noptional_port=9090")?;
95//!
96//! let config = Config::from_file(temp_file.path().to_str().unwrap())?;
97//! println!("Numbers: {:?}", config.numbers);
98//! println!("Strings: {:?}", config.strings);
99//! println!("Optional port: {:?}", config.optional_port);
100//! println!("Optional host: {:?}", config.optional_host);
101//! Ok(())
102//! }
103//! ```
104//!
105//! ### Converting Between Different Types
106//!
107//! You can use the `from` function to convert between different configuration types. This is particularly useful
108//! when you have multiple structs that share similar configuration fields but with different types or structures:
109//!
110//! ```rust
111//! use props_util::Properties;
112//! use std::io::Result;
113//!
114//! #[derive(Properties, Debug)]
115//! struct ServerConfig {
116//! #[prop(key = "host", default = "localhost")]
117//! host: String,
118//! #[prop(key = "port", default = "8080")]
119//! port: u16,
120//! }
121//!
122//! #[derive(Properties, Debug)]
123//! struct ClientConfig {
124//! #[prop(key = "host", default = "localhost")] // Note: using same key as ServerConfig
125//! server_host: String,
126//! #[prop(key = "port", default = "8080")] // Note: using same key as ServerConfig
127//! server_port: u16,
128//! }
129//!
130//! fn main() -> Result<()> {
131//! let server_config = ServerConfig::default()?;
132//! let client_config = ClientConfig::from(server_config)?;
133//! println!("Server host: {}", client_config.server_host);
134//! println!("Server port: {}", client_config.server_port);
135//! Ok(())
136//! }
137//! ```
138//!
139//! > **Important**: When converting between types using `from`, the `key` attribute values must match between the source and target types. If no `key` is specified, the field names must match. This ensures that the configuration values are correctly mapped between the different types.
140//!
141//! ### Error Handling
142//!
143//! The `from_file` method returns a `std::io::Result<T>`, which will contain:
144//!
145//! - `Ok(T)` if the properties file was successfully parsed
146//! - `Err` with an appropriate error message if:
147//! - The file couldn't be opened or read
148//! - A required property is missing (and no default is provided)
149//! - A property value couldn't be parsed into the expected type
150//! - The properties file is malformed (e.g., missing `=` character)
151//!
152//! ### Default Initialization
153//!
154//! You can also create an instance with default values without reading from a file:
155//!
156//! ```rust
157//! use props_util::Properties;
158//! use std::io::Result;
159//!
160//! #[derive(Properties, Debug)]
161//! struct Config {
162//! #[prop(key = "server.host", default = "localhost")]
163//! host: String,
164//! #[prop(key = "server.port", default = "8080")]
165//! port: u16,
166//! }
167//!
168//! fn main() -> Result<()> {
169//! let config = Config::default()?;
170//! println!("Host: {}", config.host);
171//! println!("Port: {}", config.port);
172//! Ok(())
173//! }
174//! ```
175//!
176//! ## Properties File Format
177//!
178//! The properties file follows a simple key-value format:
179//!
180//! - Each line represents a single property
181//! - The format is `key=value`
182//! - Lines starting with `#` or `!` are treated as comments and ignored
183//! - Empty lines are ignored
184//! - Leading and trailing whitespace around both key and value is trimmed
185//!
186//! Example:
187//!
188//! ```properties
189//! # Application settings
190//! app.name=MyAwesomeApp
191//! app.version=2.1.0
192//!
193//! # Database configuration
194//! database.url=postgres://user:pass@localhost:5432/mydb
195//! database.pool_size=20
196//!
197//! # Logging settings
198//! logging.level=debug
199//! logging.file=debug.log
200//!
201//! # Network settings
202//! allowed_ips=10.0.0.1,10.0.0.2,192.168.0.1
203//! ports=80,443,8080,8443
204//!
205//! # Features
206//! enabled_features=ssl,compression,caching
207//!
208//! # Optional settings
209//! optional_ssl_port=8443
210//! ```
211//!
212//! ## Limitations
213//!
214//! - Only named structs are supported (not tuple structs or enums)
215//! - All fields must have the `#[prop]` attribute
216//! - Properties files must use the `key=value` format
217
218extern crate proc_macro;
219
220use proc_macro::TokenStream;
221use quote::quote;
222use syn::{DeriveInput, Error, Field, LitStr, parse_macro_input, punctuated::Punctuated, token::Comma};
223
224/// Derive macro for automatically implementing properties parsing functionality.
225///
226/// This macro generates implementations for:
227/// - `from_file`: Load properties from a file
228/// - `from`: Create instance from a type that implements Into<HashMap<String, String>>
229/// - `default`: Create instance with default values
230///
231/// # Example
232///
233/// ```rust
234/// use props_util::Properties;
235/// use std::io::Result;
236///
237/// #[derive(Properties, Debug)]
238/// struct Config {
239/// #[prop(key = "server.host", default = "localhost")]
240/// host: String,
241/// #[prop(key = "server.port", default = "8080")]
242/// port: u16,
243/// }
244///
245/// fn main() -> Result<()> {
246/// let config = Config::default()?;
247/// println!("Host: {}", config.host);
248/// println!("Port: {}", config.port);
249/// Ok(())
250/// }
251/// ```
252#[proc_macro_derive(Properties, attributes(prop))]
253pub fn parse_prop_derive(input: TokenStream) -> TokenStream {
254 let input = parse_macro_input!(input as DeriveInput);
255 let struct_name = &input.ident;
256
257 match generate_prop_fns(&input) {
258 Ok(prop_impl) => quote! {
259 impl #struct_name { #prop_impl }
260
261 impl std::convert::Into<std::collections::HashMap<String, String>> for #struct_name {
262 fn into(self) -> std::collections::HashMap<String, String> {
263 self.into_hash_map()
264 }
265 }
266 }
267 .into(),
268 Err(e) => e.to_compile_error().into(),
269 }
270}
271
272fn extract_named_fields(input: &DeriveInput) -> syn::Result<Punctuated<Field, Comma>> {
273 let fields = match &input.data {
274 syn::Data::Struct(data_struct) => match &data_struct.fields {
275 syn::Fields::Named(fields_named) => &fields_named.named,
276 _ => return Err(Error::new_spanned(&input.ident, "Only named structs are allowd")),
277 },
278 _ => return Err(Error::new_spanned(&input.ident, "Only structs can be used on Properties")),
279 };
280
281 Ok(fields.to_owned())
282}
283
284fn generate_field_init_quote(field_type: &syn::Type, field_name: &proc_macro2::Ident, raw_value_str: proc_macro2::TokenStream, key: LitStr, is_option: bool) -> proc_macro2::TokenStream {
285 // Pregenerated token streams to generate values
286 let vec_parsing = quote! { Self::parse_vec::<_>(val).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Error Parsing `{}` with value `{}` {}", #key, val, e)))? };
287 let parsing = quote! { Self::parse(val).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Error Parsing `{}` with value `{}` {}", #key, val, e)))? };
288 let error = quote! { Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("`{}` value is not configured which is required", #key))) };
289
290 match field_type {
291 syn::Type::Path(tpath) if tpath.path.segments.last().is_some_and(|segment| segment.ident == "Vec") => match is_option {
292 false => quote! {
293 #field_name : match #raw_value_str {
294 Some(val) => #vec_parsing,
295 None => return #error
296 }
297 },
298 true => quote! {
299 #field_name : match #raw_value_str {
300 Some(val) => Some(#vec_parsing),
301 None => None
302 }
303 },
304 },
305 _ => match is_option {
306 false => quote! {
307 #field_name : match #raw_value_str {
308 Some(val) => #parsing,
309 None => return #error
310 }
311 },
312 true => quote! {
313 #field_name : match #raw_value_str {
314 Some(val) => Some(#parsing),
315 None => None
316 }
317 },
318 },
319 }
320}
321
322fn generate_init_token_streams(fields: Punctuated<Field, Comma>) -> syn::Result<Vec<proc_macro2::TokenStream>> {
323 let mut init_arr: Vec<proc_macro2::TokenStream> = Vec::new();
324
325 for field in fields {
326 let (key, default) = parse_key_default(&field).map_err(|_| Error::new_spanned(field.clone(), "Expecting `key` and `default` values"))?;
327 let field_name = field.ident.as_ref().to_owned().unwrap();
328 let field_type = &field.ty;
329
330 let val_token_stream = match default {
331 Some(default) => quote! { Some(propmap.get(#key).map(String::as_str).unwrap_or(#default)) },
332 None => quote! { propmap.get(#key).map(String::as_str) },
333 };
334
335 let init = match field_type {
336 syn::Type::Path(tpath) if tpath.path.segments.last().is_some_and(|segment| segment.ident == "Option") => match tpath.path.segments.last().unwrap().to_owned().arguments {
337 syn::PathArguments::AngleBracketed(arguments) if arguments.args.first().is_some() => match arguments.args.first().unwrap() {
338 syn::GenericArgument::Type(ftype) => generate_field_init_quote(ftype, field_name, val_token_stream, key, true),
339 _ => panic!("Option not configured {field_name} properly"),
340 },
341 _ => panic!("Option not configured {field_name} properly"),
342 },
343 _ => generate_field_init_quote(field_type, field_name, val_token_stream, key, false),
344 };
345
346 init_arr.push(init);
347 }
348
349 Ok(init_arr)
350}
351
352fn generate_field_hm_token_stream(key: LitStr, field_type: &syn::Type, field_name: &proc_macro2::Ident, is_option: bool) -> proc_macro2::TokenStream {
353 let field_name_str = field_name.to_string();
354 match field_type {
355 syn::Type::Path(tpath) if tpath.path.segments.last().is_some_and(|segment| segment.ident == "Vec") => match is_option {
356 false => quote! {
357 // When convert to a hashmap, we insert #filed_name and #key. This will be very helpful
358 // when using the resultant Hashmap to construct some other type which may or may not configure key in the props. That type can look up
359 // either #key or #field_name whichever it wants to construct its values.
360 hm.insert(#field_name_str.to_string() ,self.#field_name.iter().map(|s| s.to_string()).collect::<Vec<String>>().join(","));
361 hm.insert(#key.to_string(), self.#field_name.iter().map(|s| s.to_string()).collect::<Vec<String>>().join(","));
362 },
363 true => quote! {
364 if self.#field_name.is_some() {
365 hm.insert(#field_name_str.to_string() ,self.#field_name.clone().unwrap().iter().map(|s| s.to_string()).collect::<Vec<String>>().join(","));
366 hm.insert(#key.to_string() ,self.#field_name.unwrap().iter().map(|s| s.to_string()).collect::<Vec<String>>().join(","));
367 }
368 },
369 },
370 _ => match is_option {
371 false => quote! {
372 hm.insert(#field_name_str.to_string(), self.#field_name.clone().to_string());
373 hm.insert(#key.to_string(), self.#field_name.to_string());
374 },
375 true => quote! {
376 if self.#field_name.is_some() {
377 hm.insert(#field_name_str.to_string(), self.#field_name.clone().unwrap().to_string());
378 hm.insert(#key.to_string(), self.#field_name.unwrap().to_string());
379 }
380 },
381 },
382 }
383}
384
385fn generate_hashmap_token_streams(fields: Punctuated<Field, Comma>) -> syn::Result<Vec<proc_macro2::TokenStream>> {
386 let mut init_arr: Vec<proc_macro2::TokenStream> = Vec::new();
387
388 for field in fields {
389 let (key, _) = parse_key_default(&field).map_err(|_| Error::new_spanned(field.clone(), "Expecting `key` and `default` values"))?;
390 let field_name = field.ident.as_ref().to_owned().unwrap();
391 let field_type = &field.ty;
392
393 let quote = match field_type {
394 syn::Type::Path(tpath) if tpath.path.segments.last().is_some_and(|segment| segment.ident == "Option") => match tpath.path.segments.last().unwrap().to_owned().arguments {
395 syn::PathArguments::AngleBracketed(arguments) if arguments.args.first().is_some() => match arguments.args.first().unwrap() {
396 syn::GenericArgument::Type(ftype) => generate_field_hm_token_stream(key, ftype, field_name, true),
397 _ => return Err(Error::new_spanned(field, "Optional {field_name} is not configured properly")),
398 },
399 _ => return Err(Error::new_spanned(field, "Optional {field_name} not configured properly")),
400 },
401 _ => generate_field_hm_token_stream(key, field_type, field_name, false),
402 };
403
404 init_arr.push(quote);
405 }
406
407 Ok(init_arr)
408}
409
410fn generate_prop_fns(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
411 let fields = extract_named_fields(input)?;
412 let init_arr = generate_init_token_streams(fields.clone())?;
413 let ht_arr = generate_hashmap_token_streams(fields)?;
414
415 let new_impl = quote! {
416
417 fn parse_vec<T: std::str::FromStr>(string: &str) -> anyhow::Result<Vec<T>> {
418 Ok(string
419 .split(',')
420 .map(|s| s.trim())
421 .filter(|s| !s.is_empty())
422 .map(|s| s.parse::<T>().map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Error Parsing with value `{s}`"))))
423 .collect::<std::io::Result<Vec<T>>>()?)
424 }
425
426 fn parse<T : std::str::FromStr>(string : &str) -> anyhow::Result<T> {
427 Ok(string.parse::<T>().map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Error Parsing with value `{string}`")))?)
428 }
429
430 /// Loads properties from a file into an instance of this struct.
431 ///
432 /// # Example
433 ///
434 /// ```rust
435 /// use props_util::Properties;
436 /// use std::io::Result;
437 ///
438 /// #[derive(Properties, Debug)]
439 /// struct Config {
440 /// #[prop(key = "server.host", default = "localhost")]
441 /// host: String,
442 ///
443 /// #[prop(key = "server.port", default = "8080")]
444 /// port: u16,
445 ///
446 /// #[prop(key = "debug.enabled", default = "false")]
447 /// debug: bool,
448 /// }
449 ///
450 /// fn main() -> Result<()> {
451 /// let config = Config::from_file("config.properties")?;
452 /// println!("Server: {}:{}", config.host, config.port);
453 /// println!("Debug mode: {}", config.debug);
454 /// Ok(())
455 /// }
456 /// ```
457 ///
458 pub fn from_file(path : &str) -> std::io::Result<Self> {
459 use std::collections::HashMap;
460 use std::fs;
461 use std::io::{self, ErrorKind}; // Explicitly import ErrorKind
462 use std::path::Path; // Required for AsRef<Path> trait bound
463 use std::{fs::File, io::Read};
464
465 let mut content = String::new();
466
467 let mut file = File::open(path).map_err(|e| std::io::Error::new(e.kind(), format!("Error opening file {}", path)))?;
468 file.read_to_string(&mut content) .map_err(|e| std::io::Error::new(e.kind(), format!("Error Reading File : {}", path)))?;
469
470 let mut propmap = std::collections::HashMap::<String, String>::new();
471 for (line_num, line) in content.lines().enumerate() {
472 let line = line.trim();
473
474 if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
475 continue;
476 }
477
478 // Find the first '=', handling potential whitespace
479 match line.split_once('=') {
480 Some((key, value)) => propmap.insert(key.trim().to_string(), value.trim().to_string()),
481 None => return Err(io::Error::new( ErrorKind::InvalidData, format!("Malformed line {} in '{}' (missing '='): {}", line_num + 1, path, line) )),
482 };
483 }
484
485 Ok(Self { #( #init_arr ),* })
486 }
487
488 fn into_hash_map(self) -> std::collections::HashMap<String, String> {
489 use std::collections::HashMap;
490 let mut hm = HashMap::<String, String>::new();
491 #( #ht_arr )*
492 hm
493 }
494
495 /// Convert from another type that implements `Properties` into this type.
496 ///
497 /// This function uses `into_hash_map` internally to perform the conversion.
498 /// The conversion will succeed only if the source type's keys match this type's keys. All the required keys must be present in the source type.
499 ///
500 ///
501 /// # Example
502 ///
503 /// ```rust
504 /// use props_util::Properties;
505 /// use std::io::Result;
506 ///
507 /// #[derive(Properties, Debug)]
508 /// struct ServerConfig {
509 /// #[prop(key = "host", default = "localhost")]
510 /// host: String,
511 /// #[prop(key = "port", default = "8080")]
512 /// port: u16,
513 /// }
514 ///
515 /// #[derive(Properties, Debug)]
516 /// struct ClientConfig {
517 /// #[prop(key = "host", default = "localhost")] // Note: using same key as ServerConfig
518 /// server_host: String,
519 /// #[prop(key = "port", default = "8080")] // Note: using same key as ServerConfig
520 /// server_port: u16,
521 /// }
522 ///
523 /// fn main() -> Result<()> {
524 /// let server_config = ServerConfig::default()?;
525 /// let client_config = ClientConfig::from(server_config)?;
526 /// println!("Server host: {}", client_config.server_host);
527 /// println!("Server port: {}", client_config.server_port);
528 /// Ok(())
529 /// }
530 /// ```
531 pub fn from<T>(other: T) -> std::io::Result<Self>
532 where
533 T: Into<std::collections::HashMap<String, String>>
534 {
535 let propmap = other.into();
536 Ok(Self { #( #init_arr ),* })
537 }
538
539 pub fn default() -> std::io::Result<Self> {
540 use std::collections::HashMap;
541 let mut propmap = HashMap::<String, String>::new();
542 Ok(Self { #( #init_arr ),* })
543 }
544 };
545
546 Ok(new_impl)
547}
548
549fn parse_key_default(field: &syn::Field) -> syn::Result<(LitStr, Option<LitStr>)> {
550 let prop_attr = field.attrs.iter().find(|attr| attr.path().is_ident("prop"));
551 let prop_attr = match prop_attr {
552 Some(attr) => attr,
553 None => {
554 // If there is no "prop" attr, simply return the field name with None default
555 let ident = field.ident.to_owned().unwrap();
556 let key = LitStr::new(&ident.to_string(), ident.span());
557 return Ok((key, None));
558 }
559 };
560
561 let mut key: Option<LitStr> = None;
562 let mut default: Option<LitStr> = None;
563
564 // parse the metadata to find `key` and `default` values
565 prop_attr.parse_nested_meta(|meta| {
566 match () {
567 _ if meta.path.is_ident("key") => match key {
568 Some(_) => return Err(meta.error("duplicate 'key' parameter")),
569 None => key = Some(meta.value()?.parse()?),
570 },
571 _ if meta.path.is_ident("default") => match default {
572 Some(_) => return Err(meta.error("duplicate 'default' parameter")),
573 None => default = Some(meta.value()?.parse()?),
574 },
575 _ => return Err(meta.error(format!("unrecognized parameter '{}' in #[prop] attribute", meta.path.get_ident().map(|i| i.to_string()).unwrap_or_else(|| "<?>".into())))),
576 }
577 Ok(())
578 })?;
579
580 // if there is no key, simple use the ident field name
581 let key_str = match key {
582 Some(key) => key,
583 None => match field.ident.to_owned() {
584 Some(key) => LitStr::new(&key.to_string(), key.span()),
585 None => return Err(syn::Error::new_spanned(prop_attr, "Missing 'key' parameter in #[prop] attribute")),
586 },
587 };
588
589 Ok((key_str, default))
590}