use proc_macro2::Span;
use syn::ext::IdentExt;
use syn::parse::{Parse, ParseStream};
use syn::{Error, Ident, LitInt, LitStr, Result, Token};
pub(crate) const DEFAULT_DEBOUNCE_MS: u64 = 250;
pub(crate) const DEFAULT_POLL_INTERVAL_MS: u64 = 2_000;
pub(crate) struct Args {
pub(crate) files: Vec<LitStr>,
pub(crate) search: Option<(LitStr, Vec<LitStr>)>,
pub(crate) env: Option<LitStr>,
pub(crate) key: LitStr,
pub(crate) watch: bool,
pub(crate) debounce: u64,
pub(crate) asynchronous: bool,
pub(crate) allow_empty_env: bool,
pub(crate) poll_interval: Option<u64>,
pub(crate) profile_env: Option<LitStr>,
pub(crate) validate: bool,
pub(crate) diff: bool,
pub(crate) nest: Option<LitStr>,
pub(crate) env_files: Vec<syn::LitStr>,
pub(crate) save: bool,
pub(crate) schema: bool,
pub(crate) cache: Option<LitStr>,
pub(crate) cache_mode: Option<LitStr>,
}
#[derive(Default)]
struct Raw {
files: Option<Vec<LitStr>>,
name: Option<LitStr>,
paths: Option<(Span, Vec<LitStr>)>,
env: Option<LitStr>,
key: Option<LitStr>,
watch: Option<Span>,
debounce: Option<(Span, u64)>,
asynchronous: Option<Span>,
allow_empty_env: Option<Span>,
poll: Option<Span>,
poll_interval: Option<(Span, u64)>,
profile_env: Option<LitStr>,
validate: Option<Span>,
diff: Option<Span>,
nest: Option<LitStr>,
env_files: Option<Vec<syn::LitStr>>,
save: Option<Span>,
schema: Option<Span>,
cache: Option<LitStr>,
cache_mode: Option<LitStr>,
}
impl Parse for Args {
fn parse(input: ParseStream<'_>) -> Result<Self> {
let mut raw = Raw::default();
while !input.is_empty() {
let name = input.call(Ident::parse_any)?;
match name.to_string().as_str() {
"files" => {
reject_duplicate(raw.files.is_some(), &name)?;
raw.files = Some(parse_string_array(input)?);
}
"name" => {
reject_duplicate(raw.name.is_some(), &name)?;
input.parse::<Token![=]>()?;
raw.name = Some(parse_non_empty(input, "`name`")?);
}
"paths" => {
reject_duplicate(raw.paths.is_some(), &name)?;
raw.paths = Some((name.span(), parse_string_array(input)?));
}
"env" => {
reject_duplicate(raw.env.is_some(), &name)?;
input.parse::<Token![=]>()?;
raw.env = Some(parse_non_empty(input, "`env` prefix")?);
}
"key" => {
reject_duplicate(raw.key.is_some(), &name)?;
input.parse::<Token![=]>()?;
raw.key = Some(parse_non_empty(input, "`key`")?);
}
"watch" => {
reject_duplicate(raw.watch.is_some(), &name)?;
raw.watch = Some(name.span());
}
"async" => {
reject_duplicate(raw.asynchronous.is_some(), &name)?;
raw.asynchronous = Some(name.span());
}
"profile_env" => {
reject_duplicate(raw.profile_env.is_some(), &name)?;
input.parse::<Token![=]>()?;
raw.profile_env = Some(parse_non_empty(input, "`profile_env`")?);
}
"nest" => {
reject_duplicate(raw.nest.is_some(), &name)?;
input.parse::<Token![=]>()?;
raw.nest = Some(parse_non_empty(input, "`nest` separator")?);
}
"cache" => {
reject_duplicate(raw.cache.is_some(), &name)?;
input.parse::<Token![=]>()?;
raw.cache = Some(parse_non_empty(input, "`cache` path")?);
}
"cache_mode" => {
reject_duplicate(raw.cache_mode.is_some(), &name)?;
input.parse::<Token![=]>()?;
let mode = parse_non_empty(input, "`cache_mode`")?;
if !matches!(mode.value().as_str(), "full" | "redacted" | "fingerprint") {
return Err(Error::new(
mode.span(),
"unknown `cache_mode`; expected \"full\", \"redacted\" or \"fingerprint\"",
));
}
raw.cache_mode = Some(mode);
}
"env_files" => {
reject_duplicate(raw.env_files.is_some(), &name)?;
raw.env_files = Some(parse_string_array(input)?);
}
"save" => {
reject_duplicate(raw.save.is_some(), &name)?;
raw.save = Some(name.span());
}
"schema" => {
reject_duplicate(raw.schema.is_some(), &name)?;
raw.schema = Some(name.span());
}
"validate" => {
reject_duplicate(raw.validate.is_some(), &name)?;
raw.validate = Some(name.span());
}
"diff" => {
reject_duplicate(raw.diff.is_some(), &name)?;
raw.diff = Some(name.span());
}
"poll" => {
reject_duplicate(raw.poll.is_some(), &name)?;
raw.poll = Some(name.span());
}
"poll_interval" => {
reject_duplicate(raw.poll_interval.is_some(), &name)?;
input.parse::<Token![=]>()?;
let literal: LitInt = input.parse()?;
let value: u64 = literal.base10_parse()?;
if value == 0 {
return Err(Error::new(
literal.span(),
"`poll_interval` must be greater than zero",
));
}
raw.poll_interval = Some((literal.span(), value));
}
"allow_empty_env" => {
reject_duplicate(raw.allow_empty_env.is_some(), &name)?;
raw.allow_empty_env = Some(name.span());
}
"debounce" => {
reject_duplicate(raw.debounce.is_some(), &name)?;
input.parse::<Token![=]>()?;
let literal: LitInt = input.parse()?;
let value: u64 = literal.base10_parse()?;
if value == 0 {
return Err(Error::new(
literal.span(),
"`debounce` must be greater than zero",
));
}
raw.debounce = Some((literal.span(), value));
}
other => {
return Err(Error::new(
name.span(),
format!(
"unknown argument `{other}`; \
expected one of: files, name, paths, key, env, watch, debounce, poll, \
poll_interval, profile_env, validate, diff, nest, env_files, \
save, schema, \
cache, \
cache_mode, async, allow_empty_env"
),
));
}
}
if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
}
}
let search = match (raw.name, raw.paths) {
(Some(name), Some((_, paths))) if !paths.is_empty() => Some((name, paths)),
(Some(name), _) => {
return Err(Error::new(
name.span(),
"`name` needs somewhere to look; add \
`paths = [\"/etc/myapp\", \"~/.config/myapp\", \".\"]`",
))
}
(None, Some((span, _))) => {
return Err(Error::new(
span,
"`paths` needs something to look for; add `name = \"config\"`",
))
}
(None, None) => None,
};
let stated_no_files = raw.files.as_ref().is_some_and(Vec::is_empty);
let files = raw.files.unwrap_or_default();
if files.is_empty() && search.is_none() && !stated_no_files {
return Err(Error::new(
Span::call_site(),
"nothing to load; give `files = [\"config.toml\"]`, or \
`name = \"config\"` with `paths = [..]` to search for one — \
or `files = []` if the configuration comes from the environment \
and a remote store alone",
));
}
let key = raw.key.ok_or_else(|| {
Error::new(
Span::call_site(),
"`key` is required; it names the config section this struct maps to, \
e.g. `key = \"db\"`",
)
})?;
if let (Some((span, _)), None) = (&raw.debounce, &raw.watch) {
return Err(Error::new(
*span,
"`debounce` only applies to the file watcher; add `watch` or drop `debounce`",
));
}
for (span, argument) in [
(raw.poll.as_ref(), "poll"),
(
raw.poll_interval.as_ref().map(|(span, _)| span),
"poll_interval",
),
] {
if let (Some(span), None) = (span, &raw.watch) {
return Err(Error::new(
*span,
format!("`{argument}` only applies to the file watcher; add `watch`"),
));
}
}
if let (Some(mode), None) = (&raw.cache_mode, &raw.cache) {
return Err(Error::new(
mode.span(),
"`cache_mode` needs somewhere to write; add `cache = \"/var/lib/app/db.json\"`",
));
}
if let (Some(separator), None) = (&raw.nest, &raw.env) {
return Err(Error::new(
separator.span(),
"`nest` only applies to the environment layer; \
add `env = \"..\"` or drop `nest`",
));
}
if let (Some(span), None) = (&raw.allow_empty_env, &raw.env) {
return Err(Error::new(
*span,
"`allow_empty_env` only applies to the environment layer; \
add `env = \"..\"` or drop `allow_empty_env`",
));
}
Ok(Self {
files,
search,
env: raw.env,
key,
watch: raw.watch.is_some(),
debounce: raw.debounce.map_or(DEFAULT_DEBOUNCE_MS, |(_, value)| value),
asynchronous: raw.asynchronous.is_some(),
allow_empty_env: raw.allow_empty_env.is_some(),
profile_env: raw.profile_env,
validate: raw.validate.is_some(),
diff: raw.diff.is_some(),
nest: raw.nest,
env_files: raw.env_files.unwrap_or_default(),
save: raw.save.is_some(),
schema: raw.schema.is_some(),
cache: raw.cache,
cache_mode: raw.cache_mode,
poll_interval: match (raw.poll, raw.poll_interval) {
(_, Some((_, interval))) => Some(interval),
(Some(_), None) => Some(DEFAULT_POLL_INTERVAL_MS),
(None, None) => None,
},
})
}
}
fn reject_duplicate(already_seen: bool, name: &Ident) -> Result<()> {
if already_seen {
return Err(Error::new(
name.span(),
format!("duplicate `{name}` argument"),
));
}
Ok(())
}
fn parse_non_empty(input: ParseStream<'_>, what: &str) -> Result<LitStr> {
let literal: LitStr = input.parse()?;
if literal.value().is_empty() {
return Err(Error::new(
literal.span(),
format!("{what} must not be empty"),
));
}
Ok(literal)
}
fn parse_string_array(input: ParseStream<'_>) -> Result<Vec<LitStr>> {
input.parse::<Token![=]>()?;
let content;
syn::bracketed!(content in input);
let mut values = Vec::new();
while !content.is_empty() {
values.push(content.parse::<LitStr>()?);
if content.peek(Token![,]) {
content.parse::<Token![,]>()?;
}
}
Ok(values)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_ok(arguments: &str) -> Args {
match syn::parse_str::<Args>(arguments) {
Ok(args) => args,
Err(error) => panic!("`{arguments}` should parse: {error}"),
}
}
fn parse_err(arguments: &str) -> Error {
syn::parse_str::<Args>(arguments)
.err()
.unwrap_or_else(|| panic!("`{arguments}` should be rejected"))
}
#[test]
fn a_duplicate_argument_names_itself() {
let error = parse_err(r#"files = ["a.json"], key = "db", key = "web""#);
assert!(error.to_string().contains("duplicate `key`"), "{error}");
}
#[test]
fn name_and_paths_must_travel_together() {
let alone = parse_err(r#"name = "config", key = "db""#);
assert!(alone.to_string().contains("somewhere to look"), "{alone}");
let other = parse_err(r#"paths = ["/etc/app"], key = "db""#);
assert!(
other.to_string().contains("something to look for"),
"{other}"
);
}
#[test]
fn debounce_without_watch_is_refused_rather_than_ignored() {
let error = parse_err(r#"files = ["a.json"], key = "db", debounce = 100"#);
assert!(error.to_string().contains("add `watch`"), "{error}");
}
#[test]
fn poll_without_watch_is_refused_the_same_way() {
let error = parse_err(r#"files = ["a.json"], key = "db", poll"#);
assert!(error.to_string().contains("add `watch`"), "{error}");
}
#[test]
fn cache_mode_accepts_only_the_three_modes() {
let error =
parse_err(r#"files = ["a.json"], key = "db", cache = "c.json", cache_mode = "shiny""#);
assert!(
error.to_string().contains("unknown `cache_mode`"),
"{error}"
);
for mode in ["full", "redacted", "fingerprint"] {
let arguments = format!(
r#"files = ["a.json"], key = "db", cache = "c.json", cache_mode = "{mode}""#
);
assert!(
syn::parse_str::<Args>(&arguments).is_ok(),
"`{mode}` is a real mode"
);
}
}
#[test]
fn an_empty_files_list_is_a_decision_and_omitting_it_is_a_mistake() {
parse_ok(r#"files = [], key = "db""#);
let error = parse_err(r#"key = "db""#);
assert!(error.to_string().contains("nothing to load"), "{error}");
}
#[test]
fn env_files_parse_into_the_list_they_were_written_as() {
let args =
parse_ok(r#"files = ["a.json"], key = "db", env_files = [".env", ".env.local"]"#);
let paths: Vec<String> = args.env_files.iter().map(LitStr::value).collect();
assert_eq!(paths, [".env", ".env.local"]);
}
#[test]
fn poll_alone_gets_the_default_interval() {
let args = parse_ok(r#"files = ["a.json"], key = "db", watch, poll"#);
assert_eq!(args.poll_interval, Some(DEFAULT_POLL_INTERVAL_MS));
let explicit = parse_ok(r#"files = ["a.json"], key = "db", watch, poll_interval = 500"#);
assert_eq!(explicit.poll_interval, Some(500));
}
#[test]
fn nest_and_allow_empty_env_need_the_env_layer() {
let nest = parse_err(r#"files = ["a.json"], key = "db", nest = "__""#);
assert!(nest.to_string().contains("environment layer"), "{nest}");
let empty = parse_err(r#"files = ["a.json"], key = "db", allow_empty_env"#);
assert!(empty.to_string().contains("environment layer"), "{empty}");
}
#[test]
fn an_unknown_argument_lists_what_exists() {
let error = parse_err(r#"files = ["a.json"], key = "db", wach"#);
let rendered = error.to_string();
assert!(rendered.contains("unknown argument `wach`"), "{rendered}");
assert!(rendered.contains("watch"), "{rendered}");
}
}