cli_settings_derive/
lib.rs

1//! Manage CLI settings via config files and command line parsing.
2
3use std::str::FromStr;
4
5use quote::{quote, ToTokens};
6use syn::{parse_macro_input, spanned::Spanned};
7
8/// Map of attributes by category
9/// `cli_settings_default`: default field value
10/// `cli_settings_file`: config file related attributes
11/// `cli_settings_clap`: command line related attributes
12/// `cli_settings_mandatory`: indicate a mandatory CLI argument (presence/absence only, no associated value)
13/// 'doc': doc related attributes
14/// '_': other attributes
15type AttrMap = std::collections::HashMap<String, proc_macro2::TokenStream>;
16
17/// Field element, quite similar to `syn::Field`, keeping only the relevant fields
18struct Field<'a> {
19    attrs: AttrMap,           // classified attributes of the field
20    vis: &'a syn::Visibility, // field visibility
21    ident: &'a syn::Ident,    // field name
22    ty: &'a syn::Type,        // field type
23    opt: bool,                // whether the type shall be converted to Option<ty>
24}
25
26/// Container for the whole settings struct
27struct SettingStruct<'a> {
28    s: &'a syn::ItemStruct, // associated syn::ItemStruct object
29    attrs: AttrMap,         // classified attributes of the struct
30    fields: Vec<Field<'a>>, // list of fields
31}
32
33impl<'a> SettingStruct<'a> {
34    /// Build `SettingStruct` from a `syn::ItemStruct`
35    fn build(s: &'a syn::ItemStruct) -> Result<Self, syn::Error> {
36        let mut ss = Self {
37            s,
38            attrs: AttrMap::default(),
39            fields: vec![],
40        };
41
42        let syn::Fields::Named(fields) = &s.fields else {
43            return Err(syn::Error::new(
44                s.span(),
45                "only named structs are supported",
46            ));
47        };
48
49        // struct attributes
50        ss.attrs = Self::classify_attributes(&s.attrs)?;
51
52        // fields
53        ss.fields.reserve_exact(fields.named.len());
54        for field in &fields.named {
55            let mut f = Field {
56                attrs: Self::classify_attributes(&field.attrs)?,
57                vis: &field.vis,
58                ident: field.ident.as_ref().ok_or_else(|| {
59                    syn::Error::new(field.span(), "only named fields are supported")
60                })?,
61                ty: &field.ty,
62                opt: false,
63            };
64            f.opt = !f.attrs.contains_key("cli_settings_mandatory");
65            ss.fields.push(f);
66        }
67
68        Ok(ss)
69    }
70
71    /// Classify a list of attributes, related to file , clap, or other
72    fn classify_attributes(attrs: &'a Vec<syn::Attribute>) -> Result<AttrMap, syn::Error> {
73        let mut res = AttrMap::default();
74        for attr in attrs {
75            #[allow(clippy::match_wildcard_for_single_variants)]
76            let (path, value) = match &attr.meta {
77                syn::Meta::Path(p) => (Some(p), None),
78                syn::Meta::NameValue(v) => (Some(&v.path), Some(&v.value)),
79                _ => (None, None),
80            };
81            let mut handled_attr = false;
82            if let Some(p) = path {
83                if let Some(path_ident) = p.get_ident() {
84                    let path_ident_str = path_ident.to_string();
85                    if path_ident_str == "doc" {
86                        handled_attr = true;
87                        res.entry("doc".to_string())
88                            .or_default()
89                            .extend(attr.to_token_stream());
90                    } else if path_ident_str.starts_with("cli_settings_") {
91                        handled_attr = true;
92                        if value.is_none() {
93                            res.entry(path_ident_str).or_default();
94                        } else if let Some(syn::Expr::Lit(syn::ExprLit {
95                            attrs: _,
96                            lit: syn::Lit::Str(l),
97                        })) = value
98                        {
99                            res.entry(path_ident_str)
100                                .or_default()
101                                .extend(proc_macro2::TokenStream::from_str(&l.value())?);
102                        } else {
103                            return Err(syn::Error::new(attr.span(), "invalid attribute format"));
104                        }
105                    }
106                }
107            }
108            if !handled_attr {
109                // other attribute (including doc), keep as is
110                res.entry("_".to_string())
111                    .or_default()
112                    .extend(attr.to_token_stream());
113            }
114        }
115        Ok(res)
116    }
117
118    /// Output struct with given selection
119    fn output_struct(
120        &self,
121        prefix: &str,
122        field_filter: Option<&str>,
123        attr_keys: &[&str],
124    ) -> proc_macro2::TokenStream {
125        let empty = proc_macro2::TokenStream::new();
126        let attrs = attr_keys
127            .iter()
128            .map(|k| self.attrs.get(*k).unwrap_or(&empty))
129            .collect::<Vec<_>>();
130        let vis = if prefix.is_empty() {
131            self.s.vis.to_token_stream()
132        } else {
133            empty.clone()
134        };
135        let struct_token = &self.s.struct_token;
136        let name = format!("{}{}", prefix, self.s.ident);
137        let ident = syn::Ident::new(&name, self.s.ident.span());
138        // all fields tokens
139        let fields = self
140            .fields
141            .iter()
142            .filter(|f| {
143                if let Some(k) = field_filter {
144                    f.attrs.contains_key(k)
145                } else {
146                    true
147                }
148            })
149            .map(|f| {
150                // field tokens
151                let field_attrs = attr_keys
152                    .iter()
153                    .map(|k| f.attrs.get(*k).unwrap_or(&empty))
154                    .collect::<Vec<_>>();
155                let field_vis = f.vis;
156                let field_ident = f.ident;
157                let field_ty = f.ty;
158                let (field_ty_start, field_ty_end) = if prefix.is_empty() || !f.opt {
159                    // no prefix, field with configured type
160                    (empty.clone(), empty.clone())
161                } else {
162                    // prefix, field with Option<configured type>
163                    (
164                        proc_macro2::TokenStream::from_str("Option<").unwrap(),
165                        proc_macro2::TokenStream::from_str(">").unwrap(),
166                    )
167                };
168                // struct tokens
169                        // output one field (without separator)
170                quote! {
171                    #(#field_attrs)* #field_vis #field_ident: #field_ty_start #field_ty #field_ty_end
172                }
173            })
174            .collect::<Vec<_>>();
175        // output the whole struct
176        quote! {
177            #(#attrs)* #vis #struct_token #ident
178            {
179                #(#fields),*
180            }
181        }
182    }
183
184    /// Output the main structure
185    fn output_main_struct(&self) -> proc_macro2::TokenStream {
186        self.output_struct("", None, &["_", "doc"])
187    }
188    /// Output the file structure
189    fn output_file_struct(&self) -> proc_macro2::TokenStream {
190        self.output_struct("File", Some("cli_settings_file"), &["cli_settings_file"])
191    }
192    /// Output the clap structure
193    fn output_clap_struct(&self) -> proc_macro2::TokenStream {
194        self.output_struct(
195            "Clap",
196            Some("cli_settings_clap"),
197            &["doc", "cli_settings_clap"],
198        )
199    }
200
201    /// Output Default implementation for the main struct
202    fn output_main_struct_default(&self) -> proc_macro2::TokenStream {
203        let default = proc_macro2::TokenStream::from_str("Default::default()").unwrap();
204        let ident = &self.s.ident;
205        let fields = self
206            .fields
207            .iter()
208            .map(|f| {
209                let field_ident = f.ident;
210                let field_default = f.attrs.get("cli_settings_default").unwrap_or(&default);
211                // output one field (without separator)
212                quote! {
213                    #field_ident: #field_default
214                }
215            })
216            .collect::<Vec<_>>();
217        quote! {
218            impl Default for #ident {
219                fn default() -> Self {
220                    Self{
221                        #(#fields),*
222                    }
223                }
224            }
225        }
226    }
227
228    /// Output `build()` implementation for the main struct
229    fn output_main_struct_build(&self) -> proc_macro2::TokenStream {
230        let ident = &self.s.ident;
231        quote! {
232            impl #ident {
233                pub fn build<F, I, T>(cfg_files: F, args: I) -> anyhow::Result<Self>
234                where
235                    F: IntoIterator<Item = std::path::PathBuf>,
236                    I: IntoIterator<Item = T>,
237                    T: Into<std::ffi::OsString> + Clone,
238                {
239                    let mut cfg = Self::default();
240                    for file in cfg_files {
241                        _cli_settings_derive::load_file(&file, &mut cfg)?;
242                    }
243                    _cli_settings_derive::parse_cli_args(args, &mut cfg)?;
244                    Ok(cfg)
245                }
246            }
247        }
248    }
249
250    /// Output `update()` implementation for the file struct
251    fn output_struct_update(&self, prefix: &str, field_filter: &str) -> proc_macro2::TokenStream {
252        let main_ident = &self.s.ident;
253        let name = format!("{}{}", prefix, self.s.ident);
254        let ident = syn::Ident::new(&name, self.s.ident.span());
255        let fields = self
256            .fields
257            .iter()
258            .filter(|f| f.attrs.contains_key(field_filter))
259            .map(|f| {
260                let field_ident = f.ident;
261                // output one field (without separator)
262                if f.opt {
263                    quote! {
264                        if let Some(param) = self.#field_ident {
265                            cfg.#field_ident = param;
266                        }
267                    }
268                } else {
269                    quote! {
270                        cfg.#field_ident = self.#field_ident;
271                    }
272                }
273            })
274            .collect::<Vec<_>>();
275        quote! {
276            impl #ident {
277                fn update(self, cfg: &mut super::#main_ident) {
278                    #(#fields)*
279                }
280            }
281        }
282    }
283    /// Output the file struct `update()`
284    fn output_file_struct_update(&self) -> proc_macro2::TokenStream {
285        self.output_struct_update("File", "cli_settings_file")
286    }
287    /// Output the clap struct `update()`
288    fn output_clap_struct_update(&self) -> proc_macro2::TokenStream {
289        self.output_struct_update("Clap", "cli_settings_clap")
290    }
291
292    /// Output `load_file()` function
293    fn output_load_file(&self) -> proc_macro2::TokenStream {
294        let main_ident = &self.s.ident;
295        let name = format!("File{}", self.s.ident);
296        let ident = syn::Ident::new(&name, self.s.ident.span());
297        quote! {
298            pub fn load_file(path: &std::path::Path, cfg: &mut super::#main_ident) -> anyhow::Result<()> {
299                // access file
300                let file = std::fs::File::open(path);
301                if let Err(err) = file {
302                    if err.kind() == std::io::ErrorKind::NotFound {
303                        // file not found is not a problem...
304                        return Ok(());
305                    }
306                    // ... but everything else is -> propagate error
307                    return Err(err).context(format!(
308                        "Failed to open the configuration file '{}'",
309                        path.display()
310                    ));
311                }
312                let file = file.unwrap();
313
314                // get parsed content
315                let file_config: #ident = serde_yaml::from_reader(file).with_context(|| {
316                    format!(
317                        "Failed to parse the configuration file '{}'",
318                        path.display()
319                    )
320                })?;
321
322                // update config with content from the file
323                file_config.update(cfg);
324
325                Ok(())
326            }
327        }
328    }
329
330    /// Output `parse_cli_args()` function
331    fn output_parse_cli_args(&self) -> proc_macro2::TokenStream {
332        let main_ident = &self.s.ident;
333        let name = format!("Clap{}", self.s.ident);
334        let ident = syn::Ident::new(&name, self.s.ident.span());
335        quote! {
336            pub fn parse_cli_args<I, T>(args: I, cfg: &mut super::#main_ident) -> anyhow::Result<()>
337            where
338                I: IntoIterator<Item = T>,
339                T: Into<std::ffi::OsString> + Clone,
340            {
341                let cli_args = #ident ::parse_from(args);
342                cli_args.update(cfg);
343                Ok(())
344            }
345        }
346    }
347
348    /// Output `parse_cli_args()` function
349    fn output_clap_test(&self) -> proc_macro2::TokenStream {
350        let name = format!("Clap{}", self.s.ident);
351        let ident = syn::Ident::new(&name, self.s.ident.span());
352        quote! {
353            #[cfg(test)]
354            mod tests {
355                use super::*;
356
357                #[test]
358                fn verify_cli() {
359                    use clap::CommandFactory;
360                    #ident ::command().debug_assert()
361                }
362            }
363        }
364    }
365}
366
367/// Macro to use on the Command Line Interface settings struct
368///
369/// # Description
370///
371/// Use a derive macro with annotations on your Command Line Interface settings struct
372/// to manage the settings of your application:
373/// - create an instance with default values (provided by annotations)
374/// - read each possible configuration file, if it exists:
375///   - update the fields that are defined in the configuration file
376/// - parse the command line arguments, and update the relevant fields with the provided argument
377///
378/// By using annotations, each field can be configurable via the configuration file(s) and/or the command line.
379///
380/// cli-settings-derive can be seen as a top layer above
381/// - [serde](https://docs.rs/serde) for the file configuration parsing
382/// - [clap](https://docs.rs/clap) for the command line parsing
383///
384/// ## Usage
385///
386/// - Define your own configuration structure.
387/// - Add `#[cli_settings]` annotation to the struct
388/// - Add `#[cli_settings_file = "xxx"]` annotation to provide the annotation(s) for file parsing (serde)
389/// - Add `#[cli_settings_clap = "xxx"]` annotation to provide the annotation(s) for argument parsing (clap)
390/// - For each field, also provide annotations (all are optional)
391///   - `#[cli_settings_default = "xxx"]` to set the default value.
392///   - `#[cli_settings_file = "xxx"]` to indicate that the field shall be read from the config
393///     file(s). The passed string if any will be extra annotation(s) to the file parsing struct.
394///   - `#[cli_settings_clap = "xxx"]` to indicate that the field shall be a command line argument.
395///     The passed string (if any) will be extra annotation(s) to the command line parsing struct.
396/// - For each field, provide documentation (with ///) to generate the help message via clap.
397/// - In your application code, call the `Settings::build()` method with the list of config files to read
398///   and the command line arguments to get your application configuration.
399///
400/// ### User-defined struct
401///
402/// A user-defined struct can be used as a field in the configuration struct.
403/// It shall:
404/// - be annotated with `#[derive(Debug, Clone)]` for command line argument parsing
405/// - be annotated with `#[derive(Debug, serde_with::DeserializeFromStr)]` for config file parsing
406/// - implement `std::str::FromStr`, method `from_str()` to generate an object instance from the argument string
407///
408/// ### Enumerations
409///
410/// #### User-defined enumeration
411///
412/// A user-defined enum can be used as a field in the configuration struct. Add the following annotations to the enum declaration:
413/// - `#[derive(clap::ValueEnum, Clone, Debug)]` for command line argument parsing
414/// - `#[derive(serde::Deserialize)]#[serde(rename_all = "lowercase")]` for config file parsing
415///
416/// #### External enumeration
417///
418/// An external enum can be used as a field in the configuration struct. As annotations are not possible on this
419/// external enum, the solution is to use a custom parsing function:
420/// - command line argument parsing
421///   - define the parsing function, with signature `fn parse_field(input: &str) -> Result<FieldType, &'static str>`
422///   - annotate the field to use the parsing function as `value_parser`: `#[cli_settings_clap = "#[arg(short, long, value_parser = parse_field)]"]`
423/// - config file parsing: use `serde_with` with the following annotation `#[cli_settings_file = "#[serde_as(as = \"Option<serde_with::DisplayFromStr>\")]"]`
424///
425/// An alternate solution is to wrap the external enumeration in a user-defined struct, as described above.
426///
427/// ### Clap mandatory arguments
428///
429/// Clap mandatory arguments shall get the extra annotation `#[cli_settings_mandatory]`.
430/// The field type shall implement Default or a default value shall be provided with `#[cli_settings_default = "xxx"]`.
431/// This default value will never been used by the application as clap will terminate with error
432/// if the argument is not provided, but is needed for the struct instantiation.
433///
434/// ### Clap subcommands
435///
436/// Clap subcommands are supported as mandatory arguments, as shown in the example from the repository.
437///
438/// Note: set `global = true` for fields of the first level parameters that apply to all subcommands,
439/// so that parameters can be passed before and after the subcommand.
440///
441/// ## Basic example
442///
443/// ```
444/// use cli_settings_derive::cli_settings;
445///
446/// #[cli_settings]
447/// #[cli_settings_file = "#[serde_with::serde_as]#[derive(serde::Deserialize)]"]
448/// #[cli_settings_clap = "#[derive(clap::Parser)]#[command(version)]"]
449/// pub struct Settings {
450///     /// alpha setting explanation
451///     #[cli_settings_file]
452///     #[cli_settings_clap = "#[arg(long)]"]
453///     pub alpha: u32,
454///
455///     /// beta setting explanation, settable only from command line
456///     #[cli_settings_default = "\"beta default value\".to_string()"]
457///     #[cli_settings_clap = "#[arg(short, long)]"]
458///     pub beta: String,
459///
460///     /// gamma setting explanation, settable only from config file
461///     #[cli_settings_default = "1 << 63"]
462///     #[cli_settings_file]
463///     pub gamma: u64,
464/// }
465///
466/// fn main() {
467///     // Get the application configuration
468///     let cfg = Settings::build(
469///         vec![
470///             std::path::PathBuf::from("/path/to/system-config.yml"),
471///             std::path::PathBuf::from("/path/to/user-config.yml"),
472///         ],
473///         std::env::args_os(),
474///     ).unwrap();
475/// }
476/// ```
477///
478/// ## Complete example
479///
480/// A more complex example is available in the [crate repository](https://github.com/mic006/cli-settings-derive/blob/main/examples/example.rs), with:
481/// - clap settings to tune the generated help message (-h)
482/// - field with custom type and user provided function to parse the value from string
483/// - local enumeration field
484/// - external enumeration field
485/// - clap subcommands
486///
487#[proc_macro_attribute]
488pub fn cli_settings(
489    _attr: proc_macro::TokenStream,
490    item: proc_macro::TokenStream,
491) -> proc_macro::TokenStream {
492    let syn_struct = parse_macro_input!(item as syn::ItemStruct);
493    let ss = match SettingStruct::build(&syn_struct) {
494        Ok(ss) => ss,
495        Err(e) => return e.to_compile_error().into(),
496    };
497
498    let main_struct = ss.output_main_struct();
499    let main_struct_default = ss.output_main_struct_default();
500    let main_struct_build = ss.output_main_struct_build();
501    let file_struct = ss.output_file_struct();
502    let file_struct_update = ss.output_file_struct_update();
503    let load_file = ss.output_load_file();
504    let clap_struct = ss.output_clap_struct();
505    let clap_struct_update = ss.output_clap_struct_update();
506    let parse_cli_args = ss.output_parse_cli_args();
507    let clap_test = ss.output_clap_test();
508
509    quote! {
510        #main_struct
511        #main_struct_default
512        #main_struct_build
513
514        mod _cli_settings_derive {
515            use anyhow::Context;
516            use clap::Parser;
517            use super::*;
518
519            #file_struct
520            #file_struct_update
521
522            #load_file
523
524            #clap_struct
525            #clap_struct_update
526
527            #parse_cli_args
528
529            #clap_test
530        }
531    }
532    .into()
533}