Skip to main content

jsony_config/
lib.rs

1//! Jsony Config is an opinionated application/service configuration framework based on [jsony].
2//!
3//! **Features:**
4//!
5//! - Fast compile times, a thin layer over [jsony].
6//! - Lenient Json Configs Supporting ([jsony::JsonParserConfig])
7//!   - Trailing Commas
8//!   - Comments
9//!   - Unquoted static keys
10//! - Loading from multiple config files with priority.
11//! - Resolving paths relative to the location of the config file ([relative_path])
12//! - Warnings for duplicate and unused fields, localized to the specific file and line ([Diagnostic])
13//! - Various search strategies for finding configs ([Search])
14//! - Lazy initialized global config, useable from tests ([GlobalConfig])
15//!
16//! ## Configuration Formats
17//!
18//! Two configuration file formats are supported: `.json` and `.js`.
19//!
20//! ### JSON (`.json`)
21//!
22//! The `.json` format supports lenient features like trailing commas and comments, which can be useful for configuration files. However, editor support for these extensions to the JSON standard can be inconsistent and may require special configuration.
23//!
24//! ```json
25//! // application.config.json
26//! {
27//!   "number": 32,
28//!   // A useful comment
29//!   "test_output": "./output"
30//! }
31//! ```
32//!
33//! ### JavaScript (`.js`)
34//!
35//! To provide a better out-of-the-box editor experience with syntax highlighting and validation for lenient JSON features, a `.js` format is also supported.
36//! This format is a workaround that uses a subset of JavaScript syntax.
37//!
38//! The configuration must be assigned to a `const CONFIG =` declaration. `jsony_config` will locate this line and parse the object that follows.
39//!
40//! ```javascript
41//! // application.config.js
42//! const CONFIG = {
43//!   number: 32,
44//!   // A useful comment
45//!   test_output: "./output",
46//! };
47//! ```
48//!
49//! **Note:** While this looks like JavaScript, it is not executed as such. JavaScript features like variables, functions, or arithmetic are not supported.
50//! This approach is simply a "hack" to leverage editor support for JavaScript object literals, which closely resembles the lenient JSON syntax.
51//!
52//! ### Example
53//!
54//! ```rust
55//! use jsony_config::{Search, GlobalConfig, relative_path};
56//!
57//! #[derive(jsony::Jsony, Debug)]
58//! #[jsony(Flattenable)]
59//! pub struct Config {
60//!    #[jsony(default = 42)]
61//!    number: u32,
62//!    #[jsony(with = relative_path)]
63//!    test_output: Option<std::path::PathBuf>,
64//! }
65//!
66//! static CONFIG: GlobalConfig<Config> = GlobalConfig::new(&[
67//!     Search::Flag("--config"),
68//!     Search::Upwards{
69//!         file_stem: "application.config",
70//!         override_file_stem: Some("application.local.config"),
71//!     },
72//! ]);
73//!
74//! fn main() {
75//!     CONFIG.initialize(&mut jsony_config::print_diagnostics).unwrap();
76//!     println!("{:#?}", CONFIG);
77//!     assert_eq!(CONFIG.number, 42)
78//! }
79//! ```
80
81use std::{
82    cell::RefCell,
83    mem::MaybeUninit,
84    num::NonZeroUsize,
85    path::{Path, PathBuf},
86    ptr::NonNull,
87    sync::OnceLock,
88};
89
90use jsony::{
91    JsonError, JsonParserConfig,
92    json::{DecodeError, FieldVisitor, FromJsonFieldVisitor, Parser},
93};
94
95#[derive(Debug)]
96/// Defines the strategy for locating configuration files.
97pub enum Search<'a> {
98    /// Looks for a configuration file path specified by a command-line flag.
99    ///
100    /// Example: `--config ../data/config.json`
101    /// If multiple flag configs are provided, missing files are warnings as long as at least
102    /// one flag config exists. A missing file is still fatal when no flag config can be loaded.
103    Flag(&'a str),
104    /// Searches for a configuration file at a specific path relative to the current working directory.
105    Path(&'a std::path::Path),
106    /// Searches for a configuration file in the current working directory and then traverses up to parent directories.
107    ///
108    /// It looks for a file with `file_stem` (e.g., `application.config.json` or `.js`). If `override_file_stem` is provided,
109    /// then if a config file with override stem is found next config file it will apply with it will high priority, useful
110    /// for local configuration overrides.
111    Upwards {
112        file_stem: &'a str,
113        override_file_stem: Option<&'a str>,
114    },
115}
116
117/// Helper for parsing paths relative to the config file's location.
118///
119/// When a field is annotated with `#[jsony(with = relative_path)]`, its string value
120/// will be interpreted as a path relative to the directory containing the config file
121/// from which it was loaded. The path is then resolved to an absolute path.
122///
123/// # Example
124///
125/// ```
126/// use std::path::PathBuf;
127/// use jsony::Jsony;
128/// use jsony_config::relative_path;
129///
130/// #[derive(Jsony)]
131/// pub struct MyConfig {
132///     #[jsony(with = relative_path)]
133///     log_file: PathBuf,
134/// }
135/// ```
136///
137/// If the config file is at `/etc/myapp/config.json` and contains `"log_file": "logs/app.log"`,
138/// the `log_file` field will be parsed as `/etc/myapp/logs/app.log`.
139pub mod relative_path {
140    use std::path::PathBuf;
141
142    use jsony::{FromJson, TextWriter, ToJson, json::DecodeError};
143
144    use crate::ConfigContext;
145
146    pub fn encode_json<T: ToJson>(value: &T, output: &mut TextWriter) {
147        value.encode_json__jsony(output);
148    }
149
150    /// When in the context of a `jsony_config` parse, this function decodes a string
151    /// and resolves it as a path relative to the parent directory of the current config file.
152    ///
153    /// Outside of a `jsony_config` context, it decodes the path as-is.
154    pub fn decode_json<T: From<PathBuf>>(
155        parser: &mut jsony::parser::Parser<'_>,
156    ) -> Result<T, &'static DecodeError> {
157        pub fn decode_pathbuf(
158            parser: &mut jsony::parser::Parser<'_>,
159        ) -> Result<PathBuf, &'static DecodeError> {
160            let mut path = PathBuf::decode_json(parser)?;
161            if let Some(context) = unsafe { ConfigContext::current() } {
162                path = context
163                    .config_file
164                    .parent()
165                    .unwrap_or(&context.config_file)
166                    .join(path);
167                if let Ok(abs_path) = std::path::absolute(&path) {
168                    path = abs_path;
169                }
170                if let Ok(canonical_path) = path.canonicalize() {
171                    path = canonical_path;
172                }
173            }
174            Ok(path)
175        }
176        Ok(decode_pathbuf(parser)?.into())
177    }
178}
179
180/// A thread-safe, lazily initialized global configuration container.
181///
182/// It holds the application's configuration, which is loaded according to a `ConfigSearchStrategy`.
183/// Accessing the configuration for the first time will trigger the search and parsing,
184/// which will panic on failure.
185///
186/// It implements `Deref`, allowing for direct, transparent access to the inner config struct.
187pub struct GlobalConfig<C: JsonyConfig> {
188    config: std::sync::OnceLock<C>,
189    strategy: &'static [Search<'static>],
190    transform: Option<fn(&mut C) -> Result<(), Error>>,
191}
192
193impl<C: JsonyConfig + std::fmt::Debug> std::fmt::Debug for GlobalConfig<C> {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        if let Some(config) = self.config.get() {
196            config.fmt(f)
197        } else {
198            f.write_str("GlobalConfig::None")
199        }
200    }
201}
202
203impl<C: JsonyConfig> GlobalConfig<C> {
204    /// Eagerly initializes the global configuration.
205    ///
206    /// This method performs the configuration search and parsing immediately.
207    /// If initialization fails, it will panic with a detailed error message.
208    /// Diagnostics (warnings, errors) are passed to the provided `handler`.
209    /// If the config is already initialized, this function does nothing.
210    pub fn initialize(&self, handler: &mut DiagnosticHandler) -> Result<&C, Error> {
211        if let Some(value) = self.config.get() {
212            return Ok(value);
213        }
214        let result = load::<C>(&self.strategy, handler);
215        match result {
216            Ok(mut config) => {
217                if let Some(transform) = self.transform {
218                    if let Err(err) = transform(&mut config) {
219                        handler(Diagnostic {
220                            level: DiagnosticLevel::Error,
221                            message: &format!("Failed to transform config: {:?}", err),
222                            file: None,
223                            line: None,
224                        });
225                        return Err(err);
226                    }
227                }
228                Ok(self.config.get_or_init(|| config))
229            }
230            Err(err) => Err(err),
231        }
232    }
233}
234
235impl<C: JsonyConfig> GlobalConfig<C> {
236    /// Creates a new, uninitialized `GlobalConfig`.
237    ///
238    /// The configuration will be loaded on first access or when `initialize` is called.
239    /// The search options specified first are prioritized and the search will stop when the first match is found.
240    pub const fn new(strategy: &'static [Search<'static>]) -> GlobalConfig<C> {
241        GlobalConfig {
242            config: OnceLock::new(),
243            strategy,
244            transform: None,
245        }
246    }
247
248    /// Creates a new, uninitialized `GlobalConfig` with a transform function to be called on initialization.
249    ///
250    /// The configuration will be loaded on first access or when `initialize` is called.
251    /// The search options specified first are prioritized and the search will stop when the first match is found.
252    pub const fn new_with_transform(
253        strategy: &'static [Search<'static>],
254        transform: fn(&mut C) -> Result<(), Error>,
255    ) -> GlobalConfig<C> {
256        GlobalConfig {
257            config: OnceLock::new(),
258            strategy,
259            transform: Some(transform),
260        }
261    }
262
263    #[cold]
264    #[inline(never)]
265    fn lazy_init(&self) -> &C {
266        #[cfg(feature = "kvlog")]
267        let logger: &mut DiagnosticHandler = &mut kvlog_diagnostics;
268        #[cfg(not(feature = "kvlog"))]
269        let logger: &mut DiagnosticHandler = &mut print_diagnostics;
270        match self.initialize(logger) {
271            Ok(config) => config,
272            Err(err) => panic!("Failed to lazy initialize config: {:?}", err),
273        }
274    }
275}
276
277impl<C: JsonyConfig> std::ops::Deref for GlobalConfig<C> {
278    type Target = C;
279
280    fn deref(&self) -> &Self::Target {
281        // Note we avoid get_or_init here to encourage better codegen, with the #[cold], lazy_init
282        if let Some(config) = self.config.get() {
283            config
284        } else {
285            self.lazy_init()
286        }
287    }
288}
289/// A function type that handles diagnostics emitted during config parsing.
290///
291/// See [print_diagnostics] and [kvlog_diagnostics] for example implementations.
292pub type DiagnosticHandler = dyn FnMut(Diagnostic);
293
294#[derive(Debug)]
295/// The severity level of a diagnostic message.
296pub enum DiagnosticLevel {
297    Info,
298    Warn,
299    Error,
300}
301
302#[cfg(feature = "kvlog")]
303/// A `DiagnosticHandler` that emits structured logs using the `kvlog` crate.
304///
305/// This is only available when the `kvlog` feature is enabled.
306pub fn kvlog_diagnostics(dia: Diagnostic) {
307    use kvlog::encoding::Encode;
308    let mut log = kvlog::global_logger();
309    let mut fields = log.encoder.append_now(match dia.level {
310        DiagnosticLevel::Info => kvlog::LogLevel::Info,
311        DiagnosticLevel::Warn => kvlog::LogLevel::Warn,
312        DiagnosticLevel::Error => kvlog::LogLevel::Error,
313    });
314    if let Some(line) = &dia.line {
315        line.get().encode_log_value_into(fields.dynamic_key("line"));
316    }
317    if let Some(file) = &dia.file {
318        (fields.dynamic_key("file")).value_via_display(&(file.display()));
319    }
320    // todo make get_static_key pub and const and then use it here
321    dia.message.encode_log_value_into(fields.key("err"));
322    module_path!().encode_log_value_into(fields.key("target"));
323    "Parsing Config".encode_log_value_into(fields.key("msg"));
324    fields.apply_current_span();
325    log.poke();
326}
327
328/// A `DiagnosticHandler` that prints formatted messages to standard output.
329///
330/// This is the default handler when the `kvlog` feature is not enabled.
331///
332/// # Example Output
333///
334/// ```text
335/// WARN: JSONY_CONFIG: Unused field `debug_mode` @ /path/to/config.json:12
336/// ```
337pub fn print_diagnostics(dia: Diagnostic) {
338    let level = match dia.level {
339        DiagnosticLevel::Info => "INFO",
340        DiagnosticLevel::Warn => "WARN",
341        DiagnosticLevel::Error => "ERROR",
342    };
343    print!("{}: JSONY_CONFIG: {}", level, dia.message);
344    if let Some(file) = &dia.file {
345        print!(" @ {}", file.display());
346    }
347    if let Some(line) = &dia.line {
348        print!(":{line}");
349    }
350    println!();
351}
352
353#[derive(Debug)]
354/// Diagnostic message (e.g., a warning or error) from the config loading process.
355pub struct Diagnostic<'a> {
356    /// The severity of the diagnostic.
357    pub level: DiagnosticLevel,
358    /// The diagnostic message. Example: `Unused Field: "data"`
359    pub message: &'a str,
360    /// The configuration file where the issue was found.
361    pub file: Option<&'a Path>,
362    /// The line number in the file where the issue occurred.
363    pub line: Option<NonZeroUsize>,
364}
365/// A trait that must be implemented by the root configuration struct.
366///
367/// This trait is implemented automatically for any type that derives `jsony::Jsony`.
368/// It is recommended to derive it via `#[jsony(Flattenable)]` on your main config struct.
369///
370/// # Example
371///
372/// ```
373/// use jsony::Jsony;
374/// use jsony_config::JsonyConfig;
375///
376/// #[derive(Jsony, Debug)]
377/// #[jsony(Flattenable)] // This enables the implementation of JsonyConfig
378/// pub struct MyConfig {
379///     // ... fields
380/// }
381/// ```
382#[diagnostic::on_unimplemented(note = "You can derive JsonyConfig via `#[jsony(Flattenable)]`")]
383pub trait JsonyConfig {
384    #[doc(hidden)]
385    unsafe fn config_field_visitor<'a>(
386        config: NonNull<()>,
387    ) -> jsony::__internal::DynamicFieldDecoder<'a>;
388}
389
390#[diagnostic::do_not_recommend]
391impl<T: for<'a> FromJsonFieldVisitor<'a, Visitor = jsony::__internal::DynamicFieldDecoder<'a>>>
392    JsonyConfig for T
393{
394    unsafe fn config_field_visitor<'a>(
395        config: NonNull<()>,
396    ) -> jsony::__internal::DynamicFieldDecoder<'a> {
397        let parser = Parser::new("", JsonParserConfig::default());
398        let visitor = unsafe { T::new_field_visitor(config, &parser) };
399        visitor
400    }
401}
402
403fn load_config_file(output: &mut Vec<ConfigFile>, path: PathBuf) -> Result<(), Error> {
404    let raw_contents = match std::fs::read_to_string(&path) {
405        Ok(value) => value,
406        Err(err) => {
407            return Err(Error::IOError(path.clone(), err));
408        }
409    };
410    let prelude_length = if path.extension().is_some_and(|ext| ext == "js") {
411        let header = "const CONFIG =";
412        if let Some(idx) = raw_contents.find(header) {
413            idx + header.len()
414        } else {
415            // todo make this an error
416            0
417        }
418    } else {
419        0
420    };
421    output.push(ConfigFile {
422        path,
423        raw_contents,
424        prelude_length,
425    });
426    Ok(())
427}
428
429fn warn_missing_config_file(handler: &mut DiagnosticHandler, path: &Path) {
430    handler(Diagnostic {
431        level: DiagnosticLevel::Warn,
432        message: "Configuration file does not exist",
433        file: Some(path),
434        line: None,
435    });
436}
437
438fn load_flag_config_files(
439    flag: &str,
440    args: impl IntoIterator<Item = String>,
441    output: &mut Vec<ConfigFile>,
442    cwd: &std::path::Path,
443    handler: &mut DiagnosticHandler,
444) -> Result<(), Error> {
445    let loaded_before = output.len();
446    let mut missing_configs = Vec::new();
447    let mut args = args.into_iter();
448    while args.by_ref().find(|a| a == flag).is_some() {
449        if let Some(config) = args.next() {
450            let path = cwd.join(config);
451            match load_config_file(output, path) {
452                Ok(()) => {}
453                Err(Error::IOError(path, err)) if err.kind() == std::io::ErrorKind::NotFound => {
454                    missing_configs.push((path, err));
455                }
456                Err(err) => return Err(err),
457            }
458        }
459    }
460    for (path, _) in &missing_configs {
461        warn_missing_config_file(handler, path);
462    }
463    if output.len() == loaded_before {
464        if let Some((path, err)) = missing_configs.into_iter().next() {
465            return Err(Error::IOError(path, err));
466        }
467    }
468    Ok(())
469}
470
471impl<'a> Search<'a> {
472    fn search_until_found(
473        many: &'a [Search<'a>],
474        output: &mut Vec<ConfigFile>,
475        cwd: &std::path::Path,
476        handler: &mut DiagnosticHandler,
477    ) -> Result<(), Error> {
478        for strategy in many {
479            strategy.search(output, cwd, handler)?;
480            if !output.is_empty() {
481                return Ok(());
482            }
483        }
484        Ok(())
485    }
486    fn search(
487        &self,
488        output: &mut Vec<ConfigFile>,
489        cwd: &std::path::Path,
490        handler: &mut DiagnosticHandler,
491    ) -> Result<(), Error> {
492        match self {
493            Search::Flag(flag) => {
494                load_flag_config_files(flag, std::env::args(), output, cwd, handler)?;
495            }
496            Search::Path(path) => {
497                if path.exists() {
498                    return load_config_file(output, cwd.join(path));
499                }
500                return Ok(());
501            }
502            Search::Upwards {
503                file_stem,
504                override_file_stem,
505            } => {
506                let mut cwd = cwd.to_path_buf();
507                let filename = format!("{file_stem}.js");
508                loop {
509                    let mut path = cwd.join(&filename);
510                    for _ in 0..2 {
511                        if !path.exists() {
512                            path.set_extension("json");
513                            continue;
514                        }
515                        if let Some(override_file_stem) = override_file_stem {
516                            let mut override_path = cwd.join(format!("{override_file_stem}.js"));
517                            if override_path.exists() {
518                                load_config_file(output, override_path)?;
519                            } else {
520                                override_path.set_extension("json");
521                                if override_path.exists() {
522                                    load_config_file(output, override_path)?;
523                                }
524                            }
525                        }
526                        return load_config_file(output, path);
527                    }
528                    if !cwd.pop() {
529                        break;
530                    }
531                }
532            }
533        }
534        Ok(())
535    }
536}
537
538struct ConfigContext {
539    config_file: PathBuf,
540    handler: RefCell<&'static mut DiagnosticHandler>,
541    config_line_offset: usize,
542}
543
544thread_local! {
545    static CONFIG_CONTEXT: std::cell::Cell<Option<&'static ConfigContext>> = const {std::cell::Cell::new(None)};
546}
547
548struct ConfigContextDropGuard;
549
550impl Drop for ConfigContextDropGuard {
551    fn drop(&mut self) {
552        CONFIG_CONTEXT.set(None)
553    }
554}
555
556impl ConfigContext {
557    /// Safety: The lifetime is fictitious and the returned reference must not leave
558    /// the function containing the current call.
559    unsafe fn current() -> Option<&'static ConfigContext> {
560        CONFIG_CONTEXT.get()
561    }
562}
563
564struct ConfigFile {
565    path: PathBuf,
566    raw_contents: String,
567    prelude_length: usize,
568}
569
570impl ConfigFile {
571    fn prelude(&self) -> &str {
572        &self.raw_contents[..self.prelude_length]
573    }
574    fn config_text(&self) -> &str {
575        &self.raw_contents[self.prelude_length..]
576    }
577}
578
579/// Loads a configuration of type `T` from the specified search locations.
580///
581/// The config will be loaded from the first `Search` strategy that finds a match.
582/// See [GlobalConfig] for a wrapper type for loading into a static global variable.
583pub fn load<T: JsonyConfig>(
584    locations: &[Search],
585    diagnostic_handler: &mut DiagnosticHandler,
586) -> Result<T, Error> {
587    let mut conf = MaybeUninit::<T>::uninit();
588    let mut configs = Vec::new();
589    let cwd = match std::env::current_dir() {
590        Ok(cwd) => cwd,
591        Err(err) => return Err(Error::Other(err.to_string())),
592    };
593    if let Err(err) = Search::search_until_found(locations, &mut configs, &cwd, diagnostic_handler)
594    {
595        return Err(err);
596    };
597    if configs.is_empty() {
598        diagnostic_handler(Diagnostic {
599            level: DiagnosticLevel::Warn,
600            message: &format!(
601                "Using Default config, no configuration files found from: {:#?}",
602                locations
603            ),
604            file: None,
605            line: None,
606        })
607    }
608    let mut visitor =
609        unsafe { T::config_field_visitor(NonNull::new_unchecked(conf.as_mut_ptr().cast())) };
610    match initialize_config_internal(&configs, &mut visitor, diagnostic_handler) {
611        Ok(()) => unsafe { Ok(conf.assume_init()) },
612        Err(err) => Err(err),
613    }
614}
615
616fn initialize_config_internal<'a>(
617    paths: &'a [ConfigFile],
618    visitor: &mut jsony::__internal::DynamicFieldDecoder<'a>,
619    handler: &mut DiagnosticHandler,
620) -> Result<(), Error> {
621    let res = unsafe { initialize_configs_inner(paths, visitor, handler) };
622    match res {
623        Ok(()) => match visitor.complete() {
624            Ok(()) => Ok(()),
625            Err(err) => {
626                if err == &jsony::error::MISSING_REQUIRED_FIELDS {
627                    let missing = !visitor.bitset & visitor.required;
628                    let mut message = format!("Missing required root config fields: [");
629                    for (i, field) in visitor.schema.fields().iter().enumerate() {
630                        if missing & (1 << i) != 0 {
631                            use std::fmt::Write;
632                            let _ = write!(message, "\n    {:?},", field.name);
633                        }
634                    }
635                    message.push_str("\n]");
636                    handler(Diagnostic {
637                        level: DiagnosticLevel::Error,
638                        message: &message,
639                        file: None,
640                        line: None,
641                    });
642                    return Err(Error::Other(message));
643                }
644                return Err(Error::JsonError(JsonError::new(err, None)));
645            }
646        },
647        Err(err) => unsafe {
648            visitor.destroy();
649            Err(err)
650        },
651    }
652}
653
654/// This type represents all possible errors thatcan occur when loading configurations.
655pub enum Error {
656    JsonError(jsony::JsonError),
657    IOError(PathBuf, std::io::Error),
658    Other(String),
659    Custom(Box<dyn std::error::Error + Send + Sync>),
660}
661
662impl<T: Into<Box<dyn std::error::Error + Send + Sync>>> From<T> for Error {
663    fn from(value: T) -> Self {
664        Error::Custom(value.into())
665    }
666}
667
668impl std::fmt::Display for Error {
669    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
670        <Error as std::fmt::Debug>::fmt(self, f)
671    }
672}
673
674impl std::fmt::Debug for Error {
675    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
676        match self {
677            Self::JsonError(arg0) => f.debug_tuple("JsonError").field(arg0).finish(),
678            Self::IOError(arg0, arg1) => f.debug_tuple("IOError").field(arg0).field(arg1).finish(),
679            Self::Other(arg0) => f.write_str(arg0),
680            Self::Custom(arg0) => arg0.fmt(f),
681        }
682    }
683}
684
685unsafe fn initialize_configs_inner<'a>(
686    configs: &'a [ConfigFile],
687    decoder: &mut jsony::__internal::DynamicFieldDecoder<'a>,
688    handler: &mut DiagnosticHandler,
689) -> Result<(), Error> {
690    let mut ctx = ConfigContext {
691        config_file: PathBuf::default(),
692        config_line_offset: 0,
693        // Safety: We only lie about the lifetime to store it a thread local which will be unset
694        // by the `ConfigContextDropGuard Below`
695        handler: RefCell::new(unsafe {
696            std::mem::transmute::<&mut DiagnosticHandler, &mut DiagnosticHandler>(handler)
697        }),
698    };
699    'to_next_config_file: for config in configs {
700        {
701            ctx.config_file.clone_from(&config.path);
702            ctx.config_line_offset = config.prelude().matches('\n').count();
703        }
704        let ctx: &ConfigContext = &ctx;
705        let mut duplicate_ignore = decoder.bitset;
706
707        let _guard = ConfigContextDropGuard;
708        // Safety: The _guard above will unset CONFIG_CONTEXT at the end
709        // the scope. Accessing CONFIG_CONTEXT is guarded by unsafe.
710        unsafe {
711            CONFIG_CONTEXT.set(Some(&*(ctx as *const ConfigContext)));
712        }
713
714        let mut parser = jsony::parser::Parser::new(
715            &config.config_text(),
716            JsonParserConfig {
717                allow_comments: true,
718                allow_unquoted_field_keys: true,
719                allow_trailing_data: true,
720                allow_trailing_commas: true,
721                ..Default::default()
722            },
723        );
724
725        parser.attach_unused_field_hook(|info| {
726            if let Some(ctx) = unsafe { ConfigContext::current() } {
727                let message = format!("Unused field: `{}`", info.key());
728                let parser = info.into_parser();
729                let prefix = &parser.at.ctx.as_bytes()[..parser.at.index];
730                let lines =
731                    prefix.iter().filter(|ch| **ch == b'\n').count() + ctx.config_line_offset;
732                ctx.handler.borrow_mut()(Diagnostic {
733                    level: DiagnosticLevel::Warn,
734                    file: Some(&ctx.config_file),
735                    line: Some(NonZeroUsize::MIN.saturating_add(lines)),
736                    message: &message,
737                });
738            }
739        });
740        let error: &DecodeError = 'err: {
741            match parser.at.enter_object(&mut parser.scratch) {
742                Ok(Some(mut key)) => 'key_loop: loop {
743                    'next: {
744                        'unused: {
745                            let (index, field) = 'found: {
746                                let fields = decoder.schema.fields();
747                                for (index, field) in fields.iter().enumerate() {
748                                    if field.name != key {
749                                        continue;
750                                    }
751                                    break 'found (index, field);
752                                }
753                                for (index, alias_name) in decoder.alias {
754                                    if *alias_name == key {
755                                        break 'found (*index, &fields[*index]);
756                                    }
757                                }
758                                {
759                                    if let Some(ctx) = unsafe { ConfigContext::current() } {
760                                        let message = format!("Unused field: `{}`", key);
761                                        let prefix = &parser.at.ctx.as_bytes()[..parser.at.index];
762                                        let lines =
763                                            prefix.iter().filter(|ch| **ch == b'\n').count()
764                                                + ctx.config_line_offset;
765                                        ctx.handler.borrow_mut()(Diagnostic {
766                                            level: DiagnosticLevel::Warn,
767                                            file: Some(&ctx.config_file),
768                                            line: Some(NonZeroUsize::MIN.saturating_add(lines)),
769                                            message: &message,
770                                        });
771                                    }
772                                }
773                                break 'unused;
774                            };
775                            let mask = 1 << index;
776                            if decoder.bitset & mask != 0 {
777                                if mask & duplicate_ignore != 0 {
778                                    duplicate_ignore ^= mask;
779                                } else {
780                                    let message = format!(
781                                        "Duplicate field in same file is ignored: `{}`",
782                                        key
783                                    );
784                                    let prefix = &parser.at.ctx.as_bytes()[..parser.at.index];
785                                    let lines = prefix.iter().filter(|ch| **ch == b'\n').count()
786                                        + ctx.config_line_offset;
787                                    ctx.handler.borrow_mut()(Diagnostic {
788                                        level: DiagnosticLevel::Warn,
789                                        file: Some(&ctx.config_file),
790                                        line: Some(NonZeroUsize::MIN.saturating_add(lines)),
791                                        message: &message,
792                                    });
793                                }
794                                break 'unused;
795                            }
796                            if let Err(err) = unsafe {
797                                (field.decode)(
798                                    decoder.destination.byte_add(field.offset),
799                                    &mut parser,
800                                )
801                            } {
802                                break 'err err;
803                            }
804                            decoder.bitset |= mask;
805                            break 'next;
806                        }
807
808                        if let Err(error) = parser.at.skip_value() {
809                            return Err(Error::JsonError(JsonError::extract(error, &mut parser)));
810                        }
811                    }
812
813                    match parser.at.object_step(&mut parser.scratch) {
814                        Ok(Some(next_key2)) => {
815                            key = next_key2;
816                            continue 'key_loop;
817                        }
818                        Ok(None) => break 'key_loop,
819                        Err(err) => break 'err err,
820                    }
821                },
822                Ok(None) => {}
823                Err(err) => break 'err err,
824            };
825            continue 'to_next_config_file;
826        };
827        let err = JsonError::extract(error, &mut parser);
828        let beat = parser.at.ctx.as_bytes();
829        let prefix = beat.get(..err.index()).unwrap_or(beat);
830        let line = prefix.iter().filter(|ch| **ch == b'\n').count() + ctx.config_line_offset;
831        ctx.handler.borrow_mut()(Diagnostic {
832            level: DiagnosticLevel::Error,
833            message: &err.to_string(),
834            file: Some(&ctx.config_file),
835            line: Some(NonZeroUsize::MIN.saturating_add(line)),
836        });
837        return Err(Error::JsonError(err));
838    }
839    Ok(())
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845    use std::{
846        cell::RefCell,
847        rc::Rc,
848        sync::atomic::{AtomicUsize, Ordering},
849    };
850
851    static TEST_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
852    type RecordedDiagnostics = Rc<RefCell<Vec<(bool, String, Option<PathBuf>)>>>;
853
854    fn temp_test_dir(name: &str) -> PathBuf {
855        let id = TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
856        let path =
857            std::env::temp_dir().join(format!("jsony_config_{name}_{}_{}", std::process::id(), id));
858        std::fs::create_dir(&path).unwrap();
859        path
860    }
861
862    fn string_args(args: &[&str]) -> Vec<String> {
863        args.iter().map(|arg| (*arg).to_owned()).collect()
864    }
865
866    #[test]
867    fn missing_flag_overlay_is_warning_when_another_flag_config_loads() {
868        let cwd = temp_test_dir("missing_flag_overlay");
869        let found_path = cwd.join("found.json");
870        let missing_path = cwd.join("missing.json");
871        std::fs::write(&found_path, "{}").unwrap();
872
873        let mut configs = Vec::new();
874        let diagnostics: RecordedDiagnostics = Rc::new(RefCell::new(Vec::new()));
875        {
876            let diagnostics = Rc::clone(&diagnostics);
877            let mut handler = move |diagnostic: Diagnostic<'_>| {
878                diagnostics.borrow_mut().push((
879                    matches!(diagnostic.level, DiagnosticLevel::Warn),
880                    diagnostic.message.to_owned(),
881                    diagnostic.file.map(std::path::Path::to_path_buf),
882                ));
883            };
884            let result = load_flag_config_files(
885                "--config",
886                string_args(&["app", "--config", "missing.json", "--config", "found.json"]),
887                &mut configs,
888                &cwd,
889                &mut handler,
890            );
891            assert!(result.is_ok(), "{result:?}");
892        }
893
894        assert_eq!(configs.len(), 1);
895        assert_eq!(configs[0].path, found_path);
896        let diagnostics = diagnostics.borrow();
897        assert_eq!(diagnostics.len(), 1);
898        assert!(diagnostics[0].0);
899        assert_eq!(diagnostics[0].1, "Configuration file does not exist");
900        assert_eq!(diagnostics[0].2.as_deref(), Some(missing_path.as_path()));
901
902        let _ = std::fs::remove_dir_all(cwd);
903    }
904
905    #[test]
906    fn missing_flag_config_is_fatal_when_no_flag_config_loads() {
907        let cwd = temp_test_dir("missing_only_flag_config");
908        let missing_path = cwd.join("missing.json");
909
910        let mut configs = Vec::new();
911        let diagnostics: RecordedDiagnostics = Rc::new(RefCell::new(Vec::new()));
912        let error = {
913            let diagnostics = Rc::clone(&diagnostics);
914            let mut handler = move |diagnostic: Diagnostic<'_>| {
915                diagnostics.borrow_mut().push((
916                    matches!(diagnostic.level, DiagnosticLevel::Warn),
917                    diagnostic.message.to_owned(),
918                    diagnostic.file.map(std::path::Path::to_path_buf),
919                ));
920            };
921            load_flag_config_files(
922                "--config",
923                string_args(&["app", "--config", "missing.json"]),
924                &mut configs,
925                &cwd,
926                &mut handler,
927            )
928            .expect_err("missing only config should be fatal")
929        };
930
931        assert!(configs.is_empty());
932        match error {
933            Error::IOError(path, err) => {
934                assert_eq!(path, missing_path);
935                assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
936            }
937            other => panic!("expected not found IO error, got {other:?}"),
938        }
939        let diagnostics = diagnostics.borrow();
940        assert_eq!(diagnostics.len(), 1);
941        assert!(diagnostics[0].0);
942        assert_eq!(diagnostics[0].1, "Configuration file does not exist");
943        assert_eq!(diagnostics[0].2.as_deref(), Some(missing_path.as_path()));
944
945        let _ = std::fs::remove_dir_all(cwd);
946    }
947}