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}