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, is_env, 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::to_string).unwrap_or(#default.to_string())) },
332            None => quote! { propmap.get(#key).map(String::to_string) },
333        };
334
335        let val_token_stream = match is_env {
336            Some(env_key) => quote! { std::env::var(#env_key).map(|val| Some(val)).unwrap_or(#val_token_stream) },
337            None => val_token_stream,
338        };
339
340        let init = match field_type {
341            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 {
342                syn::PathArguments::AngleBracketed(arguments) if arguments.args.first().is_some() => match arguments.args.first().unwrap() {
343                    syn::GenericArgument::Type(ftype) => generate_field_init_quote(ftype, field_name, val_token_stream, key, true),
344                    _ => panic!("Option not configured {field_name} properly"),
345                },
346                _ => panic!("Option not configured {field_name} properly"),
347            },
348            _ => generate_field_init_quote(field_type, field_name, val_token_stream, key, false),
349        };
350
351        init_arr.push(init);
352    }
353
354    Ok(init_arr)
355}
356
357fn generate_field_hm_token_stream(key: LitStr, field_type: &syn::Type, field_name: &proc_macro2::Ident, is_option: bool) -> proc_macro2::TokenStream {
358    let field_name_str = field_name.to_string();
359    match field_type {
360        syn::Type::Path(tpath) if tpath.path.segments.last().is_some_and(|segment| segment.ident == "Vec") => match is_option {
361            false => quote! {
362                // When convert to a hashmap, we insert #filed_name and #key. This will be very helpful
363                // 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
364                // either #key or #field_name whichever it wants to construct its values.
365                hm.insert(#field_name_str.to_string() ,self.#field_name.iter().map(|s| s.to_string()).collect::<Vec<String>>().join(","));
366                hm.insert(#key.to_string(), self.#field_name.iter().map(|s| s.to_string()).collect::<Vec<String>>().join(","));
367            },
368            true => quote! {
369                if self.#field_name.is_some() {
370                    hm.insert(#field_name_str.to_string() ,self.#field_name.clone().unwrap().iter().map(|s| s.to_string()).collect::<Vec<String>>().join(","));
371                    hm.insert(#key.to_string() ,self.#field_name.unwrap().iter().map(|s| s.to_string()).collect::<Vec<String>>().join(","));
372                }
373            },
374        },
375        _ => match is_option {
376            false => quote! {
377                hm.insert(#field_name_str.to_string(), self.#field_name.clone().to_string());
378                hm.insert(#key.to_string(), self.#field_name.to_string());
379            },
380            true => quote! {
381                if self.#field_name.is_some() {
382                    hm.insert(#field_name_str.to_string(), self.#field_name.clone().unwrap().to_string());
383                    hm.insert(#key.to_string(), self.#field_name.unwrap().to_string());
384                }
385            },
386        },
387    }
388}
389
390fn generate_hashmap_token_streams(fields: Punctuated<Field, Comma>) -> syn::Result<Vec<proc_macro2::TokenStream>> {
391    let mut init_arr: Vec<proc_macro2::TokenStream> = Vec::new();
392
393    for field in fields {
394        let (key, _, _) = parse_key_default(&field).map_err(|e| Error::new_spanned(field.clone(), format!("Error parsing prop {e}")))?;
395        let field_name = field.ident.as_ref().to_owned().unwrap();
396        let field_type = &field.ty;
397
398        let quote = match field_type {
399            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 {
400                syn::PathArguments::AngleBracketed(arguments) if arguments.args.first().is_some() => match arguments.args.first().unwrap() {
401                    syn::GenericArgument::Type(ftype) => generate_field_hm_token_stream(key, ftype, field_name, true),
402                    _ => return Err(Error::new_spanned(field, "Optional {field_name} is not configured properly")),
403                },
404                _ => return Err(Error::new_spanned(field, "Optional {field_name} not configured properly")),
405            },
406            _ => generate_field_hm_token_stream(key, field_type, field_name, false),
407        };
408
409        init_arr.push(quote);
410    }
411
412    Ok(init_arr)
413}
414
415fn generate_prop_fns(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
416    let fields = extract_named_fields(input)?;
417    let init_arr = generate_init_token_streams(fields.clone())?;
418    let ht_arr = generate_hashmap_token_streams(fields)?;
419
420    let new_impl = quote! {
421
422        fn parse_vec<T: std::str::FromStr>(string: &str) -> anyhow::Result<Vec<T>> {
423            Ok(string
424                .split(',')
425                .map(|s| s.trim())
426                .filter(|s| !s.is_empty())
427                .map(|s| s.parse::<T>().map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Error Parsing with value `{s}`"))))
428                .collect::<std::io::Result<Vec<T>>>()?)
429        }
430
431        fn parse<T : std::str::FromStr>(string : &str) -> anyhow::Result<T> {
432            Ok(string.parse::<T>().map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Error Parsing with value `{string}`")))?)
433        }
434
435        /// Loads properties from a file into an instance of this struct.
436        ///
437        /// # Example
438        ///
439        /// ```rust,no_run
440        /// use props_util::Properties;
441        /// use std::io::Result;
442        ///
443        /// #[derive(Properties, Debug)]
444        /// struct Config {
445        ///     #[prop(key = "server.host", default = "localhost")]
446        ///     host: String,
447        ///
448        ///     #[prop(key = "server.port", default = "8080")]
449        ///     port: u16,
450        ///
451        ///     #[prop(key = "debug.enabled", default = "false")]
452        ///     debug: bool,
453        /// }
454        ///
455        /// fn main() -> Result<()> {
456        ///
457        ///     let config = Config::from_file("config.properties")?;
458        ///     println!("Server: {}:{}", config.host, config.port);
459        ///     println!("Debug mode: {}", config.debug);
460        ///     Ok(())
461        /// }
462        /// ```
463        ///
464        pub fn from_file(path : &str) -> std::io::Result<Self> {
465            use std::collections::HashMap;
466            use std::fs;
467            use std::io::{self, ErrorKind}; // Explicitly import ErrorKind
468            use std::path::Path; // Required for AsRef<Path> trait bound
469            use std::{fs::File, io::Read};
470
471            let mut content = String::new();
472
473            let mut file = File::open(path).map_err(|e| std::io::Error::new(e.kind(), format!("Error opening file {}", path)))?;
474            file.read_to_string(&mut content) .map_err(|e| std::io::Error::new(e.kind(), format!("Error Reading File : {}", path)))?;
475
476            let mut propmap = std::collections::HashMap::<String, String>::new();
477            for (line_num, line) in content.lines().enumerate() {
478                let line = line.trim();
479
480                if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
481                    continue;
482                }
483
484                // Find the first '=', handling potential whitespace
485                match line.split_once('=') {
486                    Some((key, value)) => propmap.insert(key.trim().to_string(), value.trim().to_string()),
487                    None => return Err(io::Error::new( ErrorKind::InvalidData, format!("Malformed line {} in '{}' (missing '='): {}", line_num + 1, path, line) )),
488                };
489            }
490
491            Ok(Self { #( #init_arr ),* })
492        }
493
494        fn into_hash_map(self) -> std::collections::HashMap<String, String> {
495            use std::collections::HashMap;
496            let mut hm = HashMap::<String, String>::new();
497            #( #ht_arr )*
498            hm
499        }
500
501        /// Convert from another type that implements `Properties` into this type.
502        ///
503        /// This function uses `into_hash_map` internally to perform the conversion.
504        /// 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.
505        ///
506        ///
507        /// # Example
508        ///
509        /// ```rust,no_run
510        /// use props_util::Properties;
511        /// use std::io::Result;
512        ///
513        /// #[derive(Properties, Debug)]
514        /// struct ServerConfig {
515        ///     #[prop(key = "host", default = "localhost")]
516        ///     host: String,
517        ///     #[prop(key = "port", default = "8080")]
518        ///     port: u16,
519        /// }
520        ///
521        /// #[derive(Properties, Debug)]
522        /// struct ClientConfig {
523        ///     #[prop(key = "host", default = "localhost")]  // Note: using same key as ServerConfig
524        ///     server_host: String,
525        ///     #[prop(key = "port", default = "8080")]      // Note: using same key as ServerConfig
526        ///     server_port: u16,
527        /// }
528        ///
529        /// fn main() -> Result<()> {
530        ///     let server_config = ServerConfig::default()?;
531        ///     let client_config = ClientConfig::from(server_config)?;
532        ///     println!("Server host: {}", client_config.server_host);
533        ///     println!("Server port: {}", client_config.server_port);
534        ///     Ok(())
535        /// }
536        /// ```
537        pub fn from<T>(other: T) -> std::io::Result<Self>
538        where
539            T: Into<std::collections::HashMap<String, String>>
540        {
541            let propmap = other.into();
542            Ok(Self { #( #init_arr ),* })
543        }
544
545        pub fn default() -> std::io::Result<Self> {
546            use std::collections::HashMap;
547            let mut propmap = HashMap::<String, String>::new();
548            Ok(Self { #( #init_arr ),* })
549        }
550    };
551
552    Ok(new_impl)
553}
554
555fn parse_key_default(field: &syn::Field) -> syn::Result<(LitStr, Option<LitStr>, Option<LitStr>)> {
556    let prop_attr = field.attrs.iter().find(|attr| attr.path().is_ident("prop"));
557    let prop_attr = match prop_attr {
558        Some(attr) => attr,
559        None => {
560            // If there is no "prop" attr, simply return the field name with None default
561            let ident = field.ident.to_owned().unwrap();
562            let key = LitStr::new(&ident.to_string(), ident.span());
563            return Ok((key, None, None));
564        }
565    };
566
567    let mut key: Option<LitStr> = None;
568    let mut default: Option<LitStr> = None;
569    let mut env: Option<LitStr> = None;
570
571    // parse the metadata to find `key` and `default` values
572    prop_attr.parse_nested_meta(|meta| {
573        match () {
574            _ if meta.path.is_ident("key") => match key {
575                Some(_) => return Err(meta.error("duplicate 'key' parameter")),
576                None => key = Some(meta.value()?.parse()?),
577            },
578            _ if meta.path.is_ident("default") => match default {
579                Some(_) => return Err(meta.error("duplicate 'default' parameter")),
580                None => default = Some(meta.value()?.parse()?),
581            },
582            _ if meta.path.is_ident("env") => match env {
583                Some(_) => return Err(meta.error("duplicate `env` parameter")),
584                None => env = Some(meta.value()?.parse()?),
585            },
586            _ => return Err(meta.error(format!("unrecognized parameter '{}' in #[prop] attribute", meta.path.get_ident().map(|i| i.to_string()).unwrap_or_else(|| "<?>".into())))),
587        }
588        Ok(())
589    })?;
590
591    // if there is no key, simple use the ident field name
592    let key_str = match key {
593        Some(key) => key,
594        None => match field.ident.to_owned() {
595            Some(key) => LitStr::new(&key.to_string(), key.span()),
596            None => return Err(syn::Error::new_spanned(prop_attr, "Missing 'key' parameter in #[prop] attribute")),
597        },
598    };
599
600    Ok((key_str, env, default))
601}