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}