1 /**
2  * Base utilities for working with configurations.
3  *
4  * Authors:
5  *  Mike Bierlee, m.bierlee@lostmoment.com
6  * Copyright: 2022-2025 Mike Bierlee
7  * License:
8  *  This software is licensed under the terms of the MIT license.
9  *  The full terms of the license can be found in the LICENSE file.
10  */
11 
12 module mirage.config;
13 
14 import std.exception : enforce;
15 import std.string : split, startsWith, endsWith, join, lastIndexOf, strip, toLower;
16 import std.conv : to, ConvException;
17 import std.file : readText;
18 import std.path : extension;
19 import std.process : environment;
20 import std.typecons : Flag;
21 
22 import mirage.json : loadJsonConfig;
23 import mirage.java : loadJavaProperties;
24 import mirage.ini : loadIniConfig;
25 
26 /** 
27  * Used by the ConfigDictionary when something goes wrong when reading configuration.
28  */
29 class ConfigReadException : Exception {
30     this(string msg, string file = __FILE__, size_t line = __LINE__) {
31         super(msg, file, line);
32     }
33 }
34 
35 /** 
36  * Used by ConfigDictionary when the supplied path does not exist.
37  */
38 class ConfigPathNotFoundException : Exception {
39     this(string msg, string file = __FILE__, size_t line = __LINE__) {
40         super(msg, file, line);
41     }
42 }
43 
44 /** 
45  * Used by ConfigFactory instances when loading or parsing configuration fails.
46  */
47 class ConfigCreationException : Exception {
48     this(string msg, string file = __FILE__, size_t line = __LINE__) {
49         super(msg, file, line);
50     }
51 }
52 
53 /** 
54  * Used by ConfigDictionary when there is something wrong with the path when calling ConfigDictionary.get()
55  */
56 class PathParseException : Exception {
57     this(string msg, string path, string file = __FILE__, size_t line = __LINE__) {
58         string fullMsg = msg ~ " (Path: " ~ path ~ ")";
59         super(fullMsg, file, line);
60     }
61 }
62 
63 /** 
64  * The configuration tree is made up of specific types of ConfigNodes.
65  * Used as generic type for ConfigFactory and ConfigDictionary.
66  */
67 interface ConfigNode {
68     string nodeType();
69 }
70 
71 /** 
72  * A configuration item that is any sort of primitive value (strings, numbers or null).
73  */
74 class ValueNode : ConfigNode {
75     string value;
76 
77     this() {
78     }
79 
80     this(string value) {
81         this.value = value;
82     }
83 
84     string nodeType() {
85         return "value";
86     }
87 }
88 
89 /** 
90  * A configuration item that is an object. 
91  * 
92  * ObjectNodes contain a node dictionary that points to other ConfigNodes.
93  */
94 class ObjectNode : ConfigNode {
95     ConfigNode[string] children;
96 
97     this() {
98     }
99 
100     this(ConfigNode[string] children) {
101         this.children = children;
102     }
103 
104     this(string[string] values) {
105         foreach (key, value; values) {
106             children[key] = new ValueNode(value);
107         }
108     }
109 
110     string nodeType() {
111         return "object";
112     }
113 }
114 
115 /** 
116  * A configuration item that is an array.
117  *
118  * Contains other ConfigNodes as children.
119  */
120 class ArrayNode : ConfigNode {
121     ConfigNode[] children;
122 
123     this() {
124     }
125 
126     this(ConfigNode[] children...) {
127         this.children = children;
128     }
129 
130     this(string[] values...) {
131         foreach (string value; values) {
132             children ~= new ValueNode(value);
133         }
134     }
135 
136     string nodeType() {
137         return "array";
138     }
139 }
140 
141 private interface PathSegment {
142 }
143 
144 private class ArrayPathSegment : PathSegment {
145     const size_t index;
146 
147     this(const size_t index) {
148         this.index = index;
149     }
150 }
151 
152 private class PropertyPathSegment : PathSegment {
153     const string propertyName;
154 
155     this(const string propertyName) {
156         this.propertyName = propertyName;
157     }
158 }
159 
160 private class ConfigPath {
161     private const string path;
162     private string[] previousSegments;
163     private string[] segments;
164 
165     this(const string path) {
166         this.path = path;
167         segmentAndNormalize(path);
168     }
169 
170     private void segmentAndNormalize(string path) {
171         foreach (segment; path.split(".")) {
172             auto trimmedSegment = segment.strip;
173 
174             if (trimmedSegment.length <= 0) {
175                 continue;
176             }
177 
178             if (trimmedSegment.endsWith("]") && !trimmedSegment.startsWith("[")) {
179                 auto openBracketPos = trimmedSegment.lastIndexOf("[");
180                 if (openBracketPos != -1) {
181                     segments ~= trimmedSegment[0 .. openBracketPos];
182                     segments ~= trimmedSegment[openBracketPos .. $];
183                     continue;
184                 }
185             }
186 
187             segments ~= trimmedSegment;
188         }
189     }
190 
191     PathSegment getNextSegment() {
192         if (segments.length == 0) {
193             return null;
194         }
195 
196         PathSegment ret(PathSegment segment) {
197             previousSegments ~= segments[0];
198             segments = segments[1 .. $];
199             return segment;
200         }
201 
202         string segment = segments[0];
203 
204         if (segment.startsWith("[") && segment.endsWith("]")) {
205             if (segment.length <= 2) {
206                 throw new PathParseException("Path has array accessor but no index specified", path);
207             }
208 
209             auto indexString = segment[1 .. $ - 1];
210             try {
211                 auto index = indexString.to!size_t;
212                 return ret(new ArrayPathSegment(index));
213             } catch (ConvException e) {
214                 throw new PathParseException("Value '" ~ indexString ~ "' is not acceptable as an array index", path);
215             }
216         }
217 
218         return ret(new PropertyPathSegment(segment));
219     }
220 
221     string getCurrentPath() {
222         return previousSegments.join(".");
223     }
224 }
225 
226 /** 
227  * Used in a ConfigDictionary to enable to disable environment variable substitution.
228  */
229 alias SubstituteEnvironmentVariables = Flag!"SubstituteEnvironmentVariables";
230 
231 /** 
232  * Used in a ConfigDictionary to enable to disable config path substitution.
233  */
234 alias SubstituteConfigVariables = Flag!"SubstituteConfigVariables";
235 
236 /** 
237  * A ConfigDictionary contains the configuration tree and facilities to get values from that tree.
238  */
239 class ConfigDictionary {
240     ConfigNode rootNode;
241     SubstituteEnvironmentVariables substituteEnvironmentVariables = SubstituteEnvironmentVariables
242         .yes;
243     SubstituteConfigVariables substituteConfigVariables = SubstituteConfigVariables.yes;
244 
245     this(SubstituteEnvironmentVariables substituteEnvironmentVariables = SubstituteEnvironmentVariables
246             .yes, SubstituteConfigVariables substituteConfigVariables = SubstituteConfigVariables
247             .yes) {
248         this.substituteEnvironmentVariables = substituteEnvironmentVariables;
249         this.substituteConfigVariables = substituteConfigVariables;
250     }
251 
252     this(ConfigNode rootNode, SubstituteEnvironmentVariables substituteEnvironmentVariables = SubstituteEnvironmentVariables
253             .yes, SubstituteConfigVariables substituteConfigVariables = SubstituteConfigVariables
254             .yes) {
255         this(substituteEnvironmentVariables, substituteConfigVariables);
256         this.rootNode = rootNode;
257     }
258 
259     /** 
260      * Get values from the configuration using config path notation.
261      *
262      * Params:
263      *   configPath = Path to the wanted config value. The path is separated by dots, e.g. "server.public.hostname". 
264      *                Values from arrays can be selected by brackets, for example: "server[3].hostname.ports[0]".
265      *                When the config is just a value, for example just a string, it can be fetched by just specifying "." as path.
266      *                Although the path should be universally the same over all types of config files, some might not lend to this structure,
267      *                and have a more specific way of retrieving data from the config. See the examples and specific config factories for
268      *                more details.
269      *   defaultValue = (Optional) Value to return when the given configPath is invalid. When not supplied a ConfigPathNotFoundException exception is thrown.
270      *
271      * Throws: ConfigReadException when something goes wrong reading the config. 
272      *         ConfigPathNotFoundException when the given path does not exist in the config.
273      *
274      * Returns: The value at the path in the configuration. To convert it use get!T().
275      */
276     string get(string configPath, string defaultValue = null) {
277         try {
278             auto path = new ConfigPath(configPath);
279             auto node = getNodeAt(path);
280             auto value = cast(ValueNode) node;
281             if (value) {
282                 if (substituteEnvironmentVariables || substituteConfigVariables) {
283                     return substituteVariables(value);
284                 } else {
285                     return value.value;
286                 }
287             } else {
288                 throw new ConfigReadException(
289                     "Value expected but " ~ node.nodeType ~ " found at path: " ~ createExceptionPath(
290                         path));
291             }
292         } catch (ConfigPathNotFoundException e) {
293             if (defaultValue !is null) {
294                 return defaultValue;
295             }
296 
297             throw e;
298         }
299     }
300 
301     /** 
302      * Get values from the configuration and attempts to convert them to the specified type.
303      *
304      * Params:
305      *   configPath = Path to the wanted config value. See get().
306      *
307      * Throws: ConfigReadException when something goes wrong reading the config. 
308      *         ConfigPathNotFoundException when the given path does not exist in the config.
309      *
310      * Returns: The value at the path in the configuration.
311      * See_Also: get
312      */
313     ConvertToType get(ConvertToType)(string configPath) {
314         return get(configPath).to!ConvertToType;
315     }
316 
317     /** 
318      * Get values from the configuration and attempts to convert them to the specified type.
319      *
320      * Params:
321      *   configPath = Path to the wanted config value. See get().
322      *   defaultValue = (Optional) Value to return when the given configPath is invalid. When not supplied a ConfigPathNotFoundException exception is thrown.
323      *
324      * Throws: ConfigReadException when something goes wrong reading the config. 
325      *         ConfigPathNotFoundException when the given path does not exist in the config.
326      *
327      * Returns: The value at the path in the configuration.
328      * See_Also: get
329      */
330     ConvertToType get(ConvertToType)(string configPath, ConvertToType defaultValue) {
331         try {
332             return get(configPath).to!ConvertToType;
333         } catch (ConfigPathNotFoundException e) {
334             return defaultValue;
335         }
336     }
337 
338     /** 
339      * Fetch a sub-section of the config as another config.
340      * 
341      * Commonly used for example to fetch  further configuration from arrays, e.g.: `getConfig("http.servers[3]")` 
342      * which then returns the rest of the config at that path.
343      *
344      * Params:
345      *   configPath = Path to the wanted config. See get(). 
346      * Returns: A sub-section of the configuration.
347      */
348     ConfigDictionary getConfig(string configPath) {
349         auto path = new ConfigPath(configPath);
350         auto node = getNodeAt(path);
351         return new ConfigDictionary(node);
352     }
353 
354     /** 
355      * Assign a value at the given path.
356      * 
357      * Params:
358      *   configPath = Path where to assign the value to. If the path does not exist, it will be created.
359      *   value = Value to set at path.
360      */
361     void set(string configPath, string value) {
362         auto path = new ConfigPath(configPath);
363         rootNode = insertAtPath(rootNode, path, value);
364     }
365 
366     private string createExceptionPath(ConfigPath path) {
367         return "'" ~ path.path ~ "' (at '" ~ path.getCurrentPath() ~ "')";
368     }
369 
370     private ConfigNode getNodeAt(ConfigPath path) {
371         void throwPathNotFound() {
372             throw new ConfigPathNotFoundException(
373                 "Path does not exist: " ~ createExceptionPath(path));
374         }
375 
376         if (rootNode is null) {
377             throwPathNotFound();
378         }
379 
380         auto currentNode = rootNode;
381         PathSegment currentPathSegment = path.getNextSegment();
382 
383         void ifNotNullPointer(void* obj, void delegate() fn) {
384             if (obj) {
385                 fn();
386             } else {
387                 throwPathNotFound();
388             }
389         }
390 
391         void ifNotNull(Object obj, void delegate() fn) {
392             if (obj) {
393                 fn();
394             } else {
395                 throwPathNotFound();
396             }
397         }
398 
399         while (currentPathSegment !is null) {
400             if (currentNode is null) {
401                 throwPathNotFound();
402             }
403 
404             auto valueNode = cast(ValueNode) currentNode;
405             if (valueNode) {
406                 throwPathNotFound();
407             }
408 
409             auto arrayPath = cast(ArrayPathSegment) currentPathSegment;
410             if (arrayPath) {
411                 auto arrayNode = cast(ArrayNode) currentNode;
412                 ifNotNull(arrayNode, {
413                     if (arrayNode.children.length < arrayPath.index) {
414                         throw new ConfigReadException(
415                             "Array index out of bounds: " ~ createExceptionPath(path));
416                     }
417 
418                     currentNode = arrayNode.children[arrayPath.index];
419                 });
420             }
421 
422             auto propertyPath = cast(PropertyPathSegment) currentPathSegment;
423             if (propertyPath) {
424                 auto objectNode = cast(ObjectNode) currentNode;
425                 ifNotNull(objectNode, {
426                     auto propertyNode = propertyPath.propertyName in objectNode.children;
427                     ifNotNullPointer(propertyNode, {
428                         currentNode = *propertyNode;
429                     });
430                 });
431             }
432 
433             currentPathSegment = path.getNextSegment();
434         }
435 
436         return currentNode;
437     }
438 
439     private string substituteVariables(ValueNode valueNode) {
440         auto value = valueNode.value;
441         if (value == null) {
442             return value;
443         }
444 
445         auto result = "";
446         auto isParsingEnvVar = false;
447         auto isParsingDefault = false;
448         auto escapeNextCharacter = false;
449         auto varName = "";
450         auto defaultVarValue = "";
451 
452         void addVarValueToResult() {
453             varName = varName.strip;
454 
455             if (varName.length == 0) {
456                 return;
457             }
458 
459             string[] exceptionMessageParts;
460 
461             if (substituteEnvironmentVariables) {
462                 exceptionMessageParts ~= "environment variable";
463                 auto envVarValue = environment.get(varName);
464                 if (envVarValue !is null) {
465                     result ~= envVarValue;
466                     return;
467                 }
468             }
469 
470             if (substituteConfigVariables) {
471                 exceptionMessageParts ~= "config path";
472                 auto configValue = get(varName, defaultVarValue);
473                 if (configValue.length > 0) {
474                     result ~= configValue;
475                     return;
476                 }
477             }
478 
479             if (isParsingDefault) {
480                 result ~= defaultVarValue.strip;
481                 return;
482             }
483 
484             auto exceptionMessageComponents = exceptionMessageParts.join(" or ");
485             throw new ConfigReadException(
486                 "No substitution found for " ~ exceptionMessageComponents ~ ": " ~ varName);
487         }
488 
489         foreach (size_t i, char c; value) {
490             if (escapeNextCharacter) {
491                 result ~= c;
492                 escapeNextCharacter = false;
493                 continue;
494             }
495 
496             if (c == '\\' && value.length.to!int - 1 > i && value[i + 1] == '$') {
497                 escapeNextCharacter = true;
498                 continue;
499             }
500 
501             if (c == '$') {
502                 isParsingEnvVar = true;
503                 continue;
504             }
505 
506             if (isParsingEnvVar) {
507                 if (c == '{') {
508                     continue;
509                 }
510 
511                 if (c == '}') {
512                     addVarValueToResult();
513                     isParsingEnvVar = false;
514                     isParsingDefault = false;
515                     varName = "";
516                     defaultVarValue = "";
517                     continue;
518                 }
519 
520                 if (isParsingDefault) {
521                     defaultVarValue ~= c;
522                     continue;
523                 }
524 
525                 if (c == ':') {
526                     isParsingDefault = true;
527                     continue;
528                 }
529 
530                 varName ~= c;
531                 continue;
532             }
533 
534             result ~= c;
535         }
536 
537         if (isParsingEnvVar) {
538             if (varName.length > 0) {
539                 addVarValueToResult();
540             } else {
541                 result ~= '$';
542             }
543         }
544 
545         return result;
546     }
547 
548     private ConfigNode insertAtPath(ConfigNode node, ConfigPath path, const string value) {
549         auto nextSegment = path.getNextSegment();
550         if (nextSegment is null) {
551             return new ValueNode(value);
552         }
553 
554         auto propertySegment = cast(PropertyPathSegment) nextSegment;
555         if (propertySegment is null) {
556             throw new Exception("Not yet implemented: cannot set array"); // todo
557         }
558 
559         auto objectNode = node is null ? new ObjectNode() : cast(ObjectNode) node;
560         if (objectNode is null) {
561             throw new Exception("Not yet implemented: cannot set array"); // todo
562         }
563 
564         auto childNodePtr = propertySegment.propertyName in objectNode.children;
565         ConfigNode childNode = childNodePtr !is null ? *childNodePtr : new ObjectNode();
566         objectNode.children[propertySegment.propertyName] = insertAtPath(childNode, path, value);
567         return objectNode;
568     }
569 }
570 
571 /** 
572  * The base class used by configuration factories for specific file types.
573  */
574 abstract class ConfigFactory {
575     /** 
576      * Loads a configuration from the specified path from disk.
577      *
578      * Params:
579      *   path = Path to file. OS dependent, but UNIX paths are generally working.
580      * Returns: The parsed configuration.
581      */
582     ConfigDictionary loadFile(string path) {
583         auto json = readText(path);
584         return parseConfig(json);
585     }
586 
587     /**
588      * Parse configuration from the given string.
589      *
590      * Params:
591      *   contents = Text contents of the config to be parsed.
592      * Returns: The parsed configuration.
593      */
594     ConfigDictionary parseConfig(string contents);
595 }
596 
597 /** 
598  * Load config from disk.
599  * A specific loader will be used based on the file's extension.
600  * Params:
601  *   configPath = Path to the configuration file.
602  * Returns: The loaded configuration.
603  * Throws: ConfigCreationException when the file's extension is unrecognized.
604  */
605 ConfigDictionary loadConfig(const string configPath) {
606     auto extension = configPath.extension.toLower;
607     if (extension == ".json") {
608         return loadJsonConfig(configPath);
609     }
610 
611     if (extension == ".properties") {
612         return loadJavaProperties(configPath);
613     }
614 
615     if (extension == ".ini") {
616         return loadIniConfig(configPath);
617     }
618 
619     throw new ConfigCreationException(
620         "File extension '" ~ extension ~ "' is not recognized as a supported config file format. Please use a specific function to load it, such as 'loadJsonConfig()'");
621 }
622 
623 version (unittest) {
624     import std.exception : assertThrown;
625     import std.math.operations : isClose;
626 
627     @("Dictionary creation")
628     unittest {
629         auto root = new ObjectNode([
630             "english": new ArrayNode([new ValueNode("one"), new ValueNode("two")]),
631             "spanish": new ArrayNode(new ValueNode("uno"), new ValueNode("dos"))
632         ]);
633 
634         auto config = new ConfigDictionary();
635         config.rootNode = root;
636     }
637 
638     @("Get value in config with empty root fails")
639     unittest {
640         auto config = new ConfigDictionary();
641 
642         assertThrown!ConfigPathNotFoundException(config.get("."));
643     }
644 
645     @("Get value in root with empty path")
646     unittest {
647         auto config = new ConfigDictionary(new ValueNode("hehehe"));
648 
649         assert(config.get("") == "hehehe");
650     }
651 
652     @("Get value in root with just a dot")
653     unittest {
654         auto config = new ConfigDictionary(new ValueNode("yup"));
655 
656         assert(config.get(".") == "yup");
657     }
658 
659     @("Get value in root fails when root is not a value")
660     unittest {
661         auto config = new ConfigDictionary(new ArrayNode());
662 
663         assertThrown!ConfigReadException(config.get("."));
664     }
665 
666     @("Get array value from root")
667     unittest {
668         auto config = new ConfigDictionary(new ArrayNode("aap", "noot", "mies"));
669 
670         assert(config.get("[0]") == "aap");
671         assert(config.get("[1]") == "noot");
672         assert(config.get("[2]") == "mies");
673     }
674 
675     @("Get value from object at root")
676     unittest {
677         auto config = new ConfigDictionary(new ObjectNode([
678                 "aap": "monkey",
679                 "noot": "nut",
680                 "mies": "mies" // It's a name!
681             ])
682         );
683 
684         assert(config.get("aap") == "monkey");
685         assert(config.get("noot") == "nut");
686         assert(config.get("mies") == "mies");
687     }
688 
689     @("Get value from object in object")
690     unittest {
691         auto config = new ConfigDictionary(
692             new ObjectNode([
693                     "server": new ObjectNode([
694                         "port": "8080"
695                     ])
696                 ])
697         );
698 
699         assert(config.get("server.port") == "8080");
700     }
701 
702     @("Get value from array in object")
703     unittest {
704         auto config = new ConfigDictionary(
705             new ObjectNode([
706                 "hostname": new ArrayNode(["google.com", "dlang.org"])
707             ])
708         );
709 
710         assert(config.get("hostname.[1]") == "dlang.org");
711     }
712 
713     @("Exception is thrown when array out of bounds when fetching from root")
714     unittest {
715         auto config = new ConfigDictionary(
716             new ArrayNode([
717                     "google.com", "dlang.org"
718                 ])
719         );
720 
721         assertThrown!ConfigReadException(config.get("[5]"));
722     }
723 
724     @("Exception is thrown when array out of bounds when fetching from object")
725     unittest {
726         auto config = new ConfigDictionary(
727             new ObjectNode([
728                 "hostname": new ArrayNode(["google.com", "dlang.org"])
729             ])
730         );
731 
732         assertThrown!ConfigReadException(config.get("hostname.[5]"));
733     }
734 
735     @("Exception is thrown when path does not exist")
736     unittest {
737         auto config = new ConfigDictionary(new ObjectNode(
738                 [
739                     "hostname": new ObjectNode(["cluster": new ValueNode("")])
740                 ])
741         );
742 
743         assertThrown!ConfigPathNotFoundException(config.get("hostname.cluster.spacey"));
744     }
745 
746     @("Exception is thrown when given path terminates too early")
747     unittest {
748         auto config = new ConfigDictionary(new ObjectNode(
749                 [
750                     "hostname": new ObjectNode(["cluster": new ValueNode(null)])
751                 ])
752         );
753 
754         assertThrown!ConfigReadException(config.get("hostname"));
755     }
756 
757     @("Exception is thrown when given path does not exist because config is an array")
758     unittest {
759         auto config = new ConfigDictionary(new ArrayNode());
760 
761         assertThrown!ConfigPathNotFoundException(config.get("hostname"));
762     }
763 
764     @("Get value from objects in array")
765     unittest {
766         auto config = new ConfigDictionary(new ArrayNode(
767                 new ObjectNode(["wrong": "yes"]),
768                 new ObjectNode(["wrong": "no"]),
769                 new ObjectNode(["wrong": "very"]),
770         ));
771 
772         assert(config.get("[1].wrong") == "no");
773     }
774 
775     @("Get value from config with mixed types")
776     unittest {
777         auto config = new ConfigDictionary(
778             new ObjectNode([
779                 "uno": cast(ConfigNode) new ValueNode("one"),
780                 "dos": cast(ConfigNode) new ArrayNode(["nope", "two"]),
781                 "tres": cast(ConfigNode) new ObjectNode(["thisone": "three"])
782             ])
783         );
784 
785         assert(config.get("uno") == "one");
786         assert(config.get("dos.[1]") == "two");
787         assert(config.get("tres.thisone") == "three");
788     }
789 
790     @("Ignore empty segments")
791     unittest {
792         auto config = new ConfigDictionary(
793             new ObjectNode(
794                 [
795                 "one": new ObjectNode(["two": new ObjectNode(["three": "four"])])
796             ])
797         );
798 
799         assert(config.get(".one..two...three....") == "four");
800     }
801 
802     @("Support conventional array indexing notation")
803     unittest {
804         auto config = new ConfigDictionary(
805             new ObjectNode(
806                 [
807                     "one": new ObjectNode([
808                         "two": new ArrayNode(["dino", "mino"])
809                     ])
810                 ])
811         );
812 
813         assert(config.get("one.two[1]") == "mino");
814     }
815 
816     @("Get and convert values")
817     unittest {
818         auto config = new ConfigDictionary(
819             new ObjectNode([
820                 "uno": new ValueNode("1223"),
821                 "dos": new ValueNode("true"),
822                 "tres": new ValueNode("Hi you"),
823                 "quatro": new ValueNode("1.3")
824             ])
825         );
826 
827         assert(config.get!int("uno") == 1223);
828         assert(config.get!bool("dos") == true);
829         assert(config.get!string("tres") == "Hi you");
830         assert(isClose(config.get!float("quatro"), 1.3));
831     }
832 
833     @("Get config from array")
834     unittest {
835         auto configOne = new ConfigDictionary(new ObjectNode(
836                 [
837                 "servers": new ArrayNode([
838                     new ObjectNode(["hostname": "lala.com"]),
839                     new ObjectNode(["hostname": "lele.com"])
840                 ])
841             ])
842         );
843 
844         auto config = configOne.getConfig("servers[0]");
845         assert(config.get("hostname") == "lala.com");
846     }
847 
848     @("Trim spaces in path segments")
849     unittest {
850         auto config = new ConfigDictionary(
851             new ObjectNode(["que": new ObjectNode(["pasa hombre": "not much"])])
852         );
853 
854         assert(config.get("  que.    pasa hombre   ") == "not much");
855     }
856 
857     @("Load configurations using the loadConfig convenience function")
858     unittest {
859         auto jsonConfig = loadConfig("testfiles/groot.json");
860         assert(jsonConfig.get("name") == "Groot");
861         assert(jsonConfig.get("traits[1]") == "tree");
862         assert(jsonConfig.get("age") == "8728");
863         assert(jsonConfig.get("taxNumber") == null);
864 
865         auto javaProperties = loadConfig("testfiles/groot.properties");
866         assert(javaProperties.get("name") == "Groot");
867         assert(javaProperties.get("age") == "8728");
868         assert(javaProperties.get("taxNumber") == "null");
869 
870         auto iniConfig = loadConfig("testfiles/groot.ini");
871         assert(iniConfig.get("groot.name") == "Groot");
872         assert(iniConfig.get("groot.age") == "8728");
873         assert(iniConfig.get("groot.taxNumber") == "null");
874     }
875 
876     @("Whitespace is preserved in values")
877     unittest {
878         auto config = new ConfigDictionary(new ObjectNode([
879                 "bla": "       blergh       "
880             ]));
881 
882         assert(config.get("bla") == "       blergh       ");
883     }
884 
885     @("Null value stays null, not string")
886     unittest {
887         auto config = new ConfigDictionary(new ValueNode(null));
888         assert(config.get(".") == null);
889     }
890 
891     @("Read value from environment variable")
892     unittest {
893         environment["MIRAGE_CONFIG_TEST_ENV_VAR"] = "is set!";
894         environment["MIRAGE_CONFIG_TEST_ENV_VAR_TWO"] = "is ready!";
895 
896         auto config = new ConfigDictionary(
897             new ObjectNode(
898                 [
899                 "withBrackets": new ValueNode("${MIRAGE_CONFIG_TEST_ENV_VAR}"),
900                 "withoutBrackets": new ValueNode("$MIRAGE_CONFIG_TEST_ENV_VAR"),
901                 "withWhiteSpace": new ValueNode("        ${MIRAGE_CONFIG_TEST_ENV_VAR}         "),
902                 "alsoWithWhiteSpace": new ValueNode("    $MIRAGE_CONFIG_TEST_ENV_VAR"),
903                 "whitespaceAtEnd": new ValueNode("$MIRAGE_CONFIG_TEST_ENV_VAR      "),
904                 "notSet": new ValueNode("${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR}"),
905                 "alsoNotSet": new ValueNode("$MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR"),
906                 "withDefault": new ValueNode("$MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:use default!"),
907                 "withDefaultAndBrackets": new ValueNode(
908                     "${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:use default!}"),
909                 "megaMix": new ValueNode("${MIRAGE_CONFIG_TEST_ENV_VAR_TWO} ${MIRAGE_CONFIG_TEST_ENV_VAR} ${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:go}!"),
910                 "typical": new ValueNode("${MIRAGE_CONFIG_TEST_HOSTNAME:localhost}:${MIRAGE_CONFIG_TEST_PORT:8080}"),
911                 "whitespaceInVar": new ValueNode("${MIRAGE_CONFIG_TEST_ENV_VAR   }"),
912                 "whitespaceInDefault": new ValueNode("${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:   bruh}"),
913                 "emptyDefault": new ValueNode("${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:}"),
914                 "emptyDefaultWhiteSpace": new ValueNode("${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:    }"),
915             ]),
916         SubstituteEnvironmentVariables.yes,
917         SubstituteConfigVariables.no
918         );
919 
920         assert(config.get("withBrackets") == "is set!");
921         assert(config.get("withoutBrackets") == "is set!");
922         assert(config.get("withWhiteSpace") == "        is set!         ");
923         assert(config.get("alsoWithWhiteSpace") == "    is set!");
924         assert(config.get("whitespaceAtEnd") == "is set!");
925         assertThrown!ConfigReadException(config.get("notSet")); // Environment variable not found
926         assertThrown!ConfigReadException(config.get("alsoNotSet")); // Environment variable not found
927         assert(config.get("withDefault") == "use default!");
928         assert(config.get("withDefaultAndBrackets") == "use default!");
929         assert(config.get("megaMix") == "is ready! is set! go!");
930         assert(config.get("typical") == "localhost:8080");
931         assert(config.get("whitespaceInVar") == "is set!");
932         assert(config.get("whitespaceInDefault") == "bruh");
933         assert(config.get("emptyDefault") == "");
934         assert(config.get("emptyDefaultWhiteSpace") == "");
935     }
936 
937     @("Don't read value from environment variables when disabled")
938     unittest {
939         environment.remove("MIRAGE_CONFIG_TEST_ENV_VAR");
940         environment.remove("MIRAGE_CONFIG_TEST_ENV_VAR_TWO");
941 
942         auto config = new ConfigDictionary(
943             new ObjectNode(
944                 [
945                 "withBrackets": new ValueNode("${MIRAGE_CONFIG_TEST_ENV_VAR}"),
946                 "withoutBrackets": new ValueNode("$MIRAGE_CONFIG_TEST_ENV_VAR"),
947                 "withWhiteSpace": new ValueNode("        ${MIRAGE_CONFIG_TEST_ENV_VAR}         "),
948                 "alsoWithWhiteSpace": new ValueNode("    $MIRAGE_CONFIG_TEST_ENV_VAR"),
949                 "tooMuchWhiteSpace": new ValueNode("$MIRAGE_CONFIG_TEST_ENV_VAR      "),
950                 "notSet": new ValueNode("${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR}"),
951                 "withDefault": new ValueNode("$MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:use default!"),
952                 "withDefaultAndBrackets": new ValueNode(
953                     "${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:use default!}"),
954                 "megaMix": new ValueNode("${MIRAGE_CONFIG_TEST_ENV_VAR_TWO} ${MIRAGE_CONFIG_TEST_ENV_VAR} ${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:go}!"),
955                 "typical": new ValueNode("${MIRAGE_CONFIG_TEST_HOSTNAME:localhost}:${MIRAGE_CONFIG_TEST_PORT:8080}"),
956             ]),
957         SubstituteEnvironmentVariables.no,
958         SubstituteConfigVariables.no
959         );
960 
961         assert(config.get("withBrackets") == "${MIRAGE_CONFIG_TEST_ENV_VAR}");
962         assert(config.get("withoutBrackets") == "$MIRAGE_CONFIG_TEST_ENV_VAR");
963         assert(config.get("withWhiteSpace") == "        ${MIRAGE_CONFIG_TEST_ENV_VAR}         ");
964         assert(config.get("alsoWithWhiteSpace") == "    $MIRAGE_CONFIG_TEST_ENV_VAR");
965         assert(config.get("tooMuchWhiteSpace") == "$MIRAGE_CONFIG_TEST_ENV_VAR      ");
966         assert(config.get("notSet") == "${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR}");
967         assert(config.get("withDefault") == "$MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:use default!");
968         assert(config.get(
969                 "withDefaultAndBrackets") == "${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:use default!}");
970         assert(config.get("megaMix") == "${MIRAGE_CONFIG_TEST_ENV_VAR_TWO} ${MIRAGE_CONFIG_TEST_ENV_VAR} ${MIRAGE_CONFIG_NOT_SET_TEST_ENV_VAR:go}!");
971         assert(config.get(
972                 "typical") == "${MIRAGE_CONFIG_TEST_HOSTNAME:localhost}:${MIRAGE_CONFIG_TEST_PORT:8080}");
973     }
974 
975     @("Get with default should return default")
976     unittest {
977         auto config = new ConfigDictionary();
978         assert(config.get("la.la.la", "not there") == "not there");
979         assert(config.get!int("do.re.mi.fa.so", 42) == 42);
980     }
981 
982     @("Substitute values from other config paths")
983     unittest {
984         auto config = new ConfigDictionary(
985             new ObjectNode([
986                 "greet": cast(ConfigNode) new ObjectNode([
987                         "env": "Hi"
988                     ]),
989                 "hi": cast(ConfigNode) new ValueNode("${greet.env} there!"),
990                 "oi": cast(ConfigNode) new ValueNode("${path.does.not.exist}"),
991             ]),
992         SubstituteEnvironmentVariables.no,
993         SubstituteConfigVariables.yes
994         );
995 
996         assert(config.get("hi") == "Hi there!");
997         assertThrown!ConfigReadException(config.get("oi"));
998     }
999 
1000     @("Do not substitute values from other config paths when disabled")
1001     unittest {
1002         auto config = new ConfigDictionary(
1003             new ObjectNode([
1004                 "greet": cast(ConfigNode) new ObjectNode([
1005                         "env": "Hi"
1006                     ]),
1007                 "hi": cast(ConfigNode) new ValueNode("${greet.env} there!"),
1008                 "oi": cast(ConfigNode) new ValueNode("${path.does.not.exist}"),
1009             ]),
1010         SubstituteEnvironmentVariables.no,
1011         SubstituteConfigVariables.no
1012         );
1013 
1014         assert(config.get("greet.env") == "Hi");
1015         assert(config.get("hi") == "${greet.env} there!");
1016         assert(config.get("oi") == "${path.does.not.exist}");
1017     }
1018 
1019     @("substitute values from both environment variables and config paths")
1020     unittest {
1021         environment["MIRAGE_CONFIG_TEST_ENV_VAR_THREE"] = "punch";
1022 
1023         auto config = new ConfigDictionary(
1024             new ObjectNode([
1025                 "one": new ValueNode("${MIRAGE_CONFIG_TEST_ENV_VAR_THREE}"),
1026                 "two": new ValueNode("${one}"),
1027             ]),
1028         SubstituteEnvironmentVariables.yes,
1029         SubstituteConfigVariables.yes
1030         );
1031 
1032         assert(config.get("two") == "punch");
1033     }
1034 
1035     @("Escaping avoids variable substitution")
1036     unittest {
1037         auto config = new ConfigDictionary(new ObjectNode([
1038                 "only dollar": "\\$ESCAPE_WITH_MY_LIFE",
1039                 "with brackets": "\\${YOU WON'T GET ME}",
1040             ])
1041         );
1042         assert(config.get("only dollar") == "$ESCAPE_WITH_MY_LIFE");
1043         assert(config.get("with brackets") == "${YOU WON'T GET ME}");
1044     }
1045 
1046     @("Wonky values")
1047     unittest {
1048         auto config = new ConfigDictionary(new ObjectNode([
1049                 "just a dollar": "$",
1050                 "final escape": "\\",
1051                 "escape with money": "\\$",
1052                 "brackets": "{}",
1053                 "empty bracket money": "${}",
1054                 "smackers": "$$$$$",
1055                 "weird bash escape thingy": "$$",
1056                 "escape room": "\\\\"
1057             ]));
1058 
1059         assert(config.get("just a dollar") == "$");
1060         assert(config.get("final escape") == "\\");
1061         assert(config.get("escape with money") == "$");
1062         assert(config.get("brackets") == "{}");
1063         assert(config.get("empty bracket money") == "");
1064         assert(config.get("smackers") == "$");
1065         assert(config.get("escape room") == "\\\\");
1066     }
1067 
1068     @("Set value at path when path does not exist")
1069     unittest {
1070         auto config = new ConfigDictionary();
1071         config.set("do.re.mi.fa.so", "buh");
1072 
1073         assert(config.get("do.re.mi.fa.so") == "buh");
1074     }
1075 
1076     @("Set value at path when object at path exists")
1077     unittest {
1078         auto config = new ConfigDictionary(
1079             new ObjectNode(
1080                 [
1081                 "one": new ObjectNode([
1082                     "two": new ObjectNode([
1083                             "mouseSound": "meep"
1084                         ])
1085                 ])
1086             ])
1087         );
1088 
1089         config.set("one.two.catSound", "meow");
1090 
1091         assert(config.get("one.two.mouseSound") == "meep");
1092         assert(config.get("one.two.catSound") == "meow");
1093     }
1094 
1095     @("Overwrite value at path")
1096     unittest {
1097         auto config = new ConfigDictionary(
1098             new ObjectNode(
1099                 [
1100                 "one": new ObjectNode([
1101                     "two": new ObjectNode([
1102                             "mouseSound": "meep"
1103                         ])
1104                 ])
1105             ])
1106         );
1107 
1108         config.set("one.two.mouseSound", "yo");
1109 
1110         assert(config.get("one.two.mouseSound") == "yo");
1111     }
1112 
1113     @("Diverge path completely")
1114     unittest {
1115         auto config = new ConfigDictionary(
1116             new ObjectNode(
1117                 [
1118                 "one": new ObjectNode([
1119                     "two": new ObjectNode([
1120                             "mouseSound": "meep"
1121                         ])
1122                 ])
1123             ])
1124         );
1125 
1126         config.set("I.am", "baboon");
1127 
1128         assert(config.get("one.two.mouseSound") == "meep");
1129         assert(config.get("I.am") == "baboon");
1130     }
1131 
1132     @("Setting the same value twice will use latest setting")
1133     unittest {
1134         auto config = new ConfigDictionary();
1135         config.set("key", "value");
1136         config.set("key", "alsoValue");
1137 
1138         assert(config.get("key") == "alsoValue");
1139     }
1140 }