1 /// Contains services that are used to easily load, modify, and store the program's configuration.
2 module jaster.cli.config;
3 
4 private
5 {
6     import std.typecons : Flag;
7     import std.traits   : isCopyable;
8     import jaster.ioc;
9 }
10 
11 alias WasExceptionThrown  = Flag!"wasAnExceptionThrown?";
12 alias SaveOnSuccess       = Flag!"configSaveOnSuccess";
13 alias RollbackOnFailure   = Flag!"configRollbackOnError";
14 
15 /++
16  + The simplest interface for configuration.
17  +
18  + This doesn't care about how data is loaded, stored, or saved. It simply provides
19  + a bare-bones interface to accessing data, without needing to worry about the nitty-gritty stuff.
20  + ++/
21 interface IConfig(T)
22 if(is(T == struct) || is(T == class))
23 {
24     public
25     {
26         /// Loads the configuration. This should overwrite any unsaved changes.
27         void load();
28 
29         /// Saves the configuration.
30         void save();
31 
32         /// Returns: The current value for this configuration.
33         @property
34         T value();
35 
36         /// Sets the configuration's value.
37         @property
38         void value(T value);
39     }
40 
41     public final
42     {
43         /++
44          + Edit the value of this configuration using the provided `editFunc`, optionally
45          + saving if no exceptions are thrown, and optionally rolling back any changes in the case an exception $(B is) thrown.
46          +
47          + Notes:
48          +  Exceptions can be caught during either `editFunc`, or a call to `save`.
49          +
50          +  Functionally, "rolling back on success" simply means the configuration's `value[set]` property is never used.
51          +
52          +  This has a consequence - if your `editFunc` modifies the internal state of the value in a way that takes immediate effect on
53          +  the original value (e.g. the value is a class type, so all changes will affect the original value), then "rolling back" won't
54          +  be able to prevent any data changes.
55          +
56          +  Therefor, it's best to use structs for your configuration types if you're wanting to make use of "rolling back".
57          +
58          +  If an error occurs, then `UserIO.verboseException` is used to display the exception.
59          +
60          +  $(B Ensure your lambda parameter is marked `scope ref`, otherwise you'll get a compiler error.)
61          +
62          + Params:
63          +  editFunc = The function that will edit the configuration's value.
64          +  rollback = If `RollbackOnFailure.yes`, then should an error occur, the configuration's value will be left unchanged.
65          +  save     = If `SaveOnSuccess.yes`, then if no errors occur, a call to `save` will be made.
66          +
67          + Returns:
68          +  `WasExceptionThrown` to denote whether an error occured or not.
69          + ++/
70         WasExceptionThrown edit(
71             void delegate(scope ref T value) editFunc,
72             RollbackOnFailure rollback = RollbackOnFailure.yes,
73             SaveOnSuccess save = SaveOnSuccess.no
74         )
75         {
76             const uneditedValue = this.value;
77             T     value         = uneditedValue; // So we can update the value in the event of `rollback.no`.
78             try
79             {
80                 editFunc(value); // Pass a temporary, so in the event of an error, changes shouldn't be half-committed.
81 
82                 this.value = value;                
83                 if(save)
84                     this.save();
85 
86                 return WasExceptionThrown.no;
87             }
88             catch(Exception ex)
89             {
90                 import jaster.cli.userio : UserIO;
91                 UserIO.verboseException(ex);
92 
93                 this.value = (rollback) ? uneditedValue : value;
94                 return WasExceptionThrown.yes;
95             }
96         }
97 
98         /// Exactly the same as `edit`, except with the `save` parameter set to `yes`.
99         void editAndSave(void delegate(scope ref T value) editFunc)
100         {
101             this.edit(editFunc, RollbackOnFailure.yes, SaveOnSuccess.yes);
102         }
103 
104         /// Exactly the same as `edit`, except with the `save` parameter set to `yes`, and `rollback` set to `no`.
105         void editAndSaveNoRollback(void delegate(scope ref T value) editFunc)
106         {
107             this.edit(editFunc, RollbackOnFailure.no, SaveOnSuccess.yes);
108         }
109 
110         /// Exactly the same as `edit`, except with the `rollback` paramter set to `no`.
111         void editNoRollback(void delegate(scope ref T value) editFunc)
112         {
113             this.edit(editFunc, RollbackOnFailure.no, SaveOnSuccess.no);
114         }
115     }
116 }
117 ///
118 unittest
119 {
120     // This is mostly a unittest for testing, not as an example, but may as well show it as an example anyway.
121     static struct Conf
122     {
123         string str;
124         int num;
125     }
126 
127     auto config = new InMemoryConfig!Conf();
128 
129     // Default: Rollback on failure, don't save on success.
130     // First `edit` fails, so no data should be commited.
131     // Second `edit` passes, so data is edited.
132     // Test to ensure only the second `edit` committed changes.
133     assert(config.edit((scope ref v) { v.str = "Hello"; v.num = 420; throw new Exception(""); }) == WasExceptionThrown.yes);
134     assert(config.edit((scope ref v) { v.num = 21; })                                            == WasExceptionThrown.no);
135     assert(config.value == Conf(null, 21));
136 
137     // Reset value, check that we didn't actually call `save` yet.
138     config.load();
139     assert(config.value == Conf.init);
140 
141     // Test editAndSave. Save on success, rollback on failure.
142     // No longer need to test rollback's pass case, as that's now proven to work.
143     config.editAndSave((scope ref v) { v.str = "Lalafell"; });
144     config.value = Conf.init;
145     config.load();
146     assert(config.value.str == "Lalafell");
147 
148     // Reset value
149     config.value = Conf.init;
150     config.save();
151 
152     // Test editNoRollback, and then we'll have tested the pass & fail cases for saving and rollbacks.
153     config.editNoRollback((scope ref v) { v.str = "Grubby"; throw new Exception(""); });
154     assert(config.value.str == "Grubby", config.value.str);
155 }
156 
157 /++
158  + A template that evaluates to a bool which determines whether the given `Adapter` can successfully
159  + compile all the code needed to serialise and deserialise the `For` type.
160  +
161  + Adapters:
162  +  Certain `IConfig` implementations may provide a level of flexibliity in the sense that they will offload the responsiblity
163  +  of serialising/deserialising the configuration onto something called an `Adapter`.
164  +
165  +  For the most part, these `Adapters` are likely to simply be that: an adapter for an already existing serialisation library.
166  +
167  +  Adapters require two static functions, with the following or compatible signatures:
168  +
169  +  ```
170  +  const(ubyte[]) serialise(For)(For value);
171  +
172  +  For deserialise(For)(const(ubyte[]) value);
173  +  ```
174  +
175  + Builtin Adapters:
176  +  Please note that any adapter that uses a third party library will only be compiled if your own project includes aforementioned library.
177  +
178  +  For example, `AsdfConfigAdapter` requires the asdf library, so will only be available if your dub project includes asdf (or specify the `Have_asdf` version).
179  +
180  +  e.g. if you want to use `AsdfConfigAdapter`, use a simple `dub add asdf` in your own project and then you're good to go.
181  +
182  +  JCLI provides the following adapters by default:
183  +
184  +  * `AsdfConfigAdapter` - An adapter for the asdf serialisation library. asdf is marked as an optional package.
185  +
186  + Notes:
187  +  If for whatever reason the given `Adapter` cannot compile when being used with the `For` type, this template
188  +  will attempt to instigate an error message from the compiler as to why.
189  +
190  +  If this template is being used inside a `static assert`, and fails, then the above attempt to provide an error message as to
191  +  why the compliation failed will not be shown, as the `static assert is false` error is thrown before the compile has a chance to collect any other error message.
192  +
193  +  In such a case, please temporarily rewrite the `static assert` into storing the result of this template into an `enum`, as that should then allow
194  +  the compiler to generate the error message.
195  + ++/
196 template isConfigAdapterFor(Adapter, For)
197 {
198     static if(isConfigAdapterForImpl!(Adapter, For))
199         enum isConfigAdapterFor = true;
200     else
201     {
202         alias _ErrorfulInstansiation = showAdapterCompilerErrors!(Adapter, For);
203         enum isConfigAdapterFor = false;
204     }
205 }
206 
207 private enum isConfigAdapterForImpl(Adapter, For) = 
208     __traits(compiles, { const ubyte[] data = Adapter.serialise!For(For.init); })
209  && __traits(compiles, { const ubyte[] data; For value = Adapter.deserialise!For(data); });
210 
211 private void showAdapterCompilerErrors(Adapter, For)()
212 {
213     const ubyte[] data = Adapter.serialise!For(For.init);
214     For value = Adapter.deserialise!For(data);
215 }
216 
217 /// A very simple `IConfig` that simply stores the value in memory. This is mostly only useful for testing.
218 final class InMemoryConfig(For) : IConfig!For
219 if(isCopyable!For)
220 {
221     private For _savedValue;
222     private For _value;
223 
224     public override
225     {
226         void save()
227         {
228             this._savedValue = this._value;
229         }
230 
231         void load()
232         {
233             this._value = this._savedValue;
234         }
235 
236         @property
237         For value()
238         {
239             return this._value;
240         }
241 
242         @property
243         void value(For newValue)
244         {
245             this._value = newValue;
246         }
247     }
248 }
249 
250 /++
251  + Returns:
252  +  A Singleton `ServiceInfo` describing an `InMemoryConfig` that stores the `For` type.
253  + ++/
254 ServiceInfo addInMemoryConfig(For)()
255 {
256     return ServiceInfo.asSingleton!(IConfig!For, InMemoryConfig!For);
257 }
258 
259 /// ditto.
260 ServiceInfo[] addInMemoryConfig(For)()
261 {
262     services ~= addInMemoryConfig!For();
263     return services;
264 }
265 
266 /++
267  + An `IConfig` with adapter support that uses the filesystem to store/retrieve its configuration value.
268  +
269  + Notes:
270  +  This class will ensure the directory for the file exists.
271  +
272  +  This class will always create a backup ".bak" before every write attempt. It however does not
273  +  attempt to restore this file in the event of an error.
274  +
275  +  If this class' config file doesn't exist, then `load` is no-op, leaving the `value` as `For.init`
276  +
277  + See_Also:
278  +  The docs for `isConfigAdapterFor` to learn more about configs with adapter support.
279  +
280  +  `addFileConfig`
281  + ++/
282 final class AdaptableFileConfig(For, Adapter) : IConfig!For
283 if(isConfigAdapterFor!(Adapter, For) && isCopyable!For)
284 {
285     private For _value;
286     private string _path;
287 
288     /++
289      + Throws:
290      +  `Exception` if the given `path` is invalid, after being converted into an absolute path.
291      +
292      + Params:
293      +  path = The file path to store the configuration file at. This can be relative or absolute.
294      + ++/
295     this(string path)
296     {
297         import std.exception : enforce;
298         import std.path : absolutePath, isValidPath;
299 
300         this._path = path.absolutePath();
301         enforce(isValidPath(this._path), "The path '"~this._path~"' is invalid");
302     }
303 
304     public override
305     {
306         void save()
307         {
308             import std.file      : write, exists, mkdirRecurse, copy;
309             import std.path      : dirName, extension, setExtension;
310 
311             const pathDir = this._path.dirName;
312             if(!exists(pathDir))
313                 mkdirRecurse(pathDir);
314 
315             const backupExt = this._path.extension ~ ".bak";
316             const backupPath = this._path.setExtension(backupExt);
317             if(exists(this._path))
318                 copy(this._path, backupPath);
319 
320             const ubyte[] data = Adapter.serialise!For(this._value);
321             write(this._path, data);
322         }
323 
324         void load()
325         {
326             import std.file : exists, read;
327 
328             if(!this._path.exists)
329                 return;
330 
331             this._value = Adapter.deserialise!For(cast(const ubyte[])read(this._path));
332         }
333 
334         @property
335         For value()
336         {
337             return this._value;
338         }
339 
340         @property
341         void value(For newValue)
342         {
343             this._value = newValue;
344         }
345     }
346 }
347 
348 /++
349  + Note:
350  +  The base type of the resulting service is `IConfig!For`, so ensure that your dependency injected code asks for
351  +  `IConfig!For` instead of `AdapatableFileConfig!(For, Adapter)`.
352  +
353  + Returns:
354  +  A Singleton `ServiceInfo` describing an `AdapatableFileConfig` that serialises the given `For` type, into a file
355  +  using the provided `Adapter` type.
356  + ++/
357 ServiceInfo addFileConfig(For, Adapter)(string fileName)
358 {
359     return ServiceInfo.asSingleton!(
360         IConfig!For, 
361         AdaptableFileConfig!(For, Adapter)
362     )(
363         (ref _)
364         { 
365             auto config = new AdaptableFileConfig!(For, Adapter)(fileName);
366             config.load();
367 
368             return config;
369         }
370     );
371 }
372 
373 /// ditto.
374 ServiceInfo[] addFileConfig(For, Adapter)(ref ServiceInfo[] services, string fileName)
375 {
376     services ~= addFileConfig!(For, Adapter)(fileName);
377     return services;
378 }