1 /**
2  * User input validation.
3  *
4  * This module contains core functionality for veryfing values as well as some
5  * basic validators.
6  *
7  * Options and arguments (no flags, as they cannot take value) can have any number of validators attached.
8  * Validators are objects that verify user input after parsing values - every validator is run once
9  * per option/argument that it is attached to with complete vector of user input (e.g. for repeating values).
10  *
11  * Validators are ran in order they are defined aborting on first failure with `ValidationException`.
12  * Exception contains the validator that caused the exception (optionally) along with error message.
13  *
14  * Validators can be attached using `validate` method, or using helper methods in form of `accepts*`:
15  *
16  * ---
17  * new Program("test")
18  *   .add(new Option("t", "test", "description")
19  *       .acceptsValues(["test", "bar"])
20  *   )
21  *   // both are equivalent
22  *   .add(new Option("T", "test2", "description")
23  *       .validate(new EnumValidator(["test", "bar"]))
24  *   )
25  * ---
26  *
27  * ## Custom Validators
28  *
29  * To add custom validating logic, you can either create custom Validator class that implements `IValidator` interface
30  * or use `DelegateValidator` passing delegate with your validating logic:
31  *
32  * ---
33  * new Program("test")
34  *   .add(new Option("t", "test", "description")
35  *       .validateWith((entry, value) {
36  *           if (value == "test") throw new ValidationException("Value must be test");
37  *       })
38  *       .validateWith(arg => isNumericString(arg), "must be numeric")
39  *   )
40  * ---
41  *
42  * Validators already provided:
43  *   - `EnumValidator` (`acceptsValues`)
44  *   - `FileSystemValidator` (`acceptsFiles`, `acceptsDirectories`)
45  *   - `DelegateValidator` (`validateWith`, `validateEachWith`)
46  *
47  * See_Also:
48  *  IValidator, ValidationException
49  */
50 module commandr.validators;
51 
52 import commandr.option : IEntry, InvalidArgumentsException;
53 import commandr.utils : getEntryKindName, matchingCandidate;
54 import std.algorithm : canFind, any, each;
55 import std.array : join;
56 import std..string : format;
57 import std.file : exists, isDir, isFile;
58 import std.typecons : Nullable;
59 
60 
61 /**
62  * Validation error.
63  *
64  * This exception is thrown when an invalid value has been passed to an option/value
65  * that has validators assigned (manually or through accepts* functions).
66  *
67  * Exception is thrown on first validator failure. Validators are run in definition order.
68  *
69  * Because this exception extends `InvalidArgumentsException`, there's no need to
70  * catch it explicitly unless needed.
71  */
72 public class ValidationException: InvalidArgumentsException {
73     /**
74      * Validator that caused the error.
75      */
76     IValidator validator;
77 
78     /// Creates new instance of ValidationException
79     public this(IValidator validator, string msg) nothrow pure @safe @nogc {
80         super(msg);
81         this.validator = validator;
82     }
83 }
84 
85 /**
86  * Interface for validators.
87  */
88 public interface IValidator {
89     /**
90      * Checks whenever specified input is valid.
91      *
92      * Params:
93      *   entry - Information about checked entry.
94      *   values - Array of values to validate.
95      *
96      * Throws:
97      *   ValidationException
98      */
99     void validate(IEntry entry, string[] values);
100 }
101 
102 
103 /**
104  * Input whitelist check.
105  *
106  * Validates whenever input is contained in list of valid/accepted values.
107  *
108  * Examples:
109  * ---
110  * new Program("test")
111  *      .add(new Option("s", "scope", "working scope")
112  *          .acceptsValues(["user", "system"])
113  *      )
114  * ---
115  *
116  * See_Also:
117  *   acceptsValues
118  */
119 public class EnumValidator: IValidator {
120     // TODO: Throw InvalidValidatorException: InvalidProgramException on empty matches?
121     /**
122      * List of allowed values.
123      */
124     string[] allowedValues;
125 
126     /// Creates new instance of EnumValidator
127     public this(string[] values) nothrow pure @safe @nogc {
128         this.allowedValues = values;
129     }
130 
131     /// Validates input
132     public void validate(IEntry entry, string[] args) @safe {
133         foreach(arg; args) {
134             if (!allowedValues.canFind(arg)) {
135                 string suggestion = allowedValues.matchingCandidate(arg);
136                 if (suggestion) {
137                     suggestion = " (did you mean %s?)".format(suggestion);
138                 } else {
139                     suggestion = "";
140                 }
141 
142                 throw new ValidationException(this,
143                     "%s %s must be one of following values: %s%s".format(
144                         entry.getEntryKindName(), entry.name, allowedValues.join(", "), suggestion
145                     )
146                 );
147             }
148         }
149     }
150 }
151 
152 /**
153  * Helper function to define allowed values for an option or argument.
154  *
155  * This function is meant to be used with UFCS, so that it can be placed
156  * within option definition chain.
157  *
158  * Params:
159  *   entry - entry to define allowed values to
160  *   values - list of allowed values
161  *
162  * Examples:
163  * ---
164  * new Program("test")
165  *      .add(new Option("s", "scope", "working scope")
166  *          .acceptsValues(["user", "system"])
167  *      )
168  * ---
169  *
170  * See_Also:
171  *   EnumValidator
172  */
173 public T acceptsValues(T : IEntry)(T entry, string[] values) @safe {
174     return entry.validate(new EnumValidator(values));
175 }
176 
177 
178 /**
179  * Specified expected entry type for `FileSystemValidator`.
180  */
181 public enum FileType {
182     ///
183     Directory,
184 
185     ///
186     File
187 }
188 
189 
190 /**
191  * FileSystem validator.
192  *
193  * See_Also:
194  *  acceptsFiles, acceptsDirectories, acceptsPath
195  */
196 public class FileSystemValidator: IValidator {
197     /// Exists contraint
198     Nullable!bool exists;
199 
200     /// Entry type contraint
201     Nullable!FileType type;
202 
203     /**
204      * Creates new FileSystem validator.
205      *
206      * This constructor creates a `FileSystemValidator` that checks only
207      * whenever the path points to a existing (or not) item.
208      *
209      * Params:
210      *  exists - Whenever passed path should exist.
211      */
212     public this(bool exists) nothrow pure @safe @nogc {
213         this.exists = exists;
214     }
215 
216     /**
217      * Creates new FileSystem validator.
218      *
219      * This constructor creates a `FileSystemValidator` that checks
220      * whenever the path points to a existing item of specified type.
221      *
222      * Params:
223      *  type - Expected item type
224      */
225     public this(FileType type) {
226         this.exists = true;
227         this.type = type;
228     }
229 
230     /// Validates input
231     public void validate(IEntry entry, string[] args) {
232         foreach (arg; args) {
233             if (!this.exists.isNull) {
234                 validateExists(entry, arg, this.exists.get());
235             }
236 
237             if (!this.type.isNull) {
238                 validateType(entry, arg, this.type.get());
239             }
240         }
241     }
242 
243     private void validateExists(IEntry entry, string arg, bool exists) {
244         if (arg.exists() == exists) {
245             return;
246         }
247 
248         throw new ValidationException(this,
249             "%s %s value must point to a %s that %sexists".format(
250                 entry.getEntryKindName(),
251                 entry.name,
252                 this.type.isNull
253                     ? "file/directory"
254                     : this.type.get() == FileType.Directory ? "directory" : "file",
255                 exists ? "" : "not "
256             )
257         );
258     }
259 
260     private void validateType(IEntry entry, string arg, FileType type) {
261         switch (type) {
262             case FileType.File:
263                 if (!arg.isFile) {
264                     throw new ValidationException(this,
265                         "value specified in %s %s must be a valid file".format(
266                             entry.getEntryKindName(), entry.name,
267                         )
268                     );
269                 }
270                 break;
271 
272             case FileType.Directory:
273                 if (!arg.isDir) {
274                     throw new ValidationException(this,
275                         "value specified in %s %s must be a valid file".format(
276                             entry.getEntryKindName(), entry.name,
277                         )
278                     );
279                 }
280                 break;
281 
282             default:
283                 assert(0);
284         }
285     }
286 }
287 
288 /**
289  * Helper function to require passing a path pointing to existing file.
290  *
291  * This function is meant to be used with UFCS, so that it can be placed
292  * within option definition chain.
293  *
294  * Examples:
295  * ---
296  * new Program("test")
297  *      .add(new Option("c", "config", "path to config file")
298  *          .accpetsFiles()
299  *      )
300  * ---
301  *
302  * See_Also:
303  *   FileSystemValidator, acceptsDirectories, acceptsPaths
304  */
305 public T acceptsFiles(T: IEntry)(T entry) {
306     return entry.validate(new FileSystemValidator(FileType.File));
307 }
308 
309 
310 /**
311  * Helper function to require passing a path pointing to existing directory.
312  *
313  * This function is meant to be used with UFCS, so that it can be placed
314  * within option definition chain.
315  *
316  * Examples:
317  * ---
318  * new Program("ls")
319  *      .add(new Argument("directory", "directory to list")
320  *          .acceptsDirectories()
321  *      )
322  * ---
323  *
324  * See_Also:
325  *   FileSystemValidator, acceptsFiles, acceptsPaths
326  */
327 public T acceptsDirectories(T: IEntry)(T entry) {
328     return entry.validate(new FileSystemValidator(FileType.Directory));
329 }
330 
331 
332 /**
333  * Helper function to require passing a path pointing to existing file or directory.
334  *
335  * This function is meant to be used with UFCS, so that it can be placed
336  * within option definition chain.
337  *
338  * Params:
339  *   existing - whenever path target must exist
340  *
341  * Examples:
342  * ---
343  * new Program("rm")
344  *      .add(new Argument("target", "target to remove")
345  *          .acceptsPaths(true)
346  *      )
347  * ---
348  *
349  * See_Also:
350  *   FileSystemValidator, acceptsDirectories, acceptsFiles
351  */
352 public T acceptsPaths(T: IEntry)(T entry, bool existing) {
353     return entry.validate(new FileSystemValidator(existing));
354 }
355 
356 /**
357  * Validates input based on delegate.
358  *
359  * Delegate receives all arguments that `IValidator.validate` receives, that is
360  * information about entry being checked and an array of values to perform check on.
361  *
362  * For less verbose usage, check `validateWith` and `validateEachWith` helper functions.
363  *
364  * Examples:
365  * ---
366  * new Program("rm")
367  *      .add(new Argument("target", "target to remove")
368  *          .validate(new DelegateValidator((entry, args) {
369  *               foreach (arg; args) {
370  *                   if (arg == "foo") throw new ValidationException("invalid number"); // would throw with "invalid number"
371  *               }
372  *          }))
373  *          // or
374  *          .validateEachWith((entry, arg) {
375  *               if (arg == "5") throw new ValidationException("invalid number"); // would throw with "invalid number"
376  *          })
377  *          // or
378  *          .validateEachWith(arg => isGood(arg), "must be good") // would throw with "flag a must be good"
379  *      )
380  * ---
381  *
382  * See_Also:
383  *   validateWith, validateEachWith
384  */
385 public class DelegateValidator : IValidator {
386     /// Validator function type
387     alias ValidatorFunc = void delegate(IEntry, string[]);
388 
389     /// Validator function
390     ValidatorFunc validator;
391 
392     /// Creates instance of DelegateValidator
393     public this(ValidatorFunc validator) nothrow pure @safe @nogc {
394         this.validator = validator;
395     }
396 
397     /// Validates input
398     public void validate(IEntry entry, string[] args) {
399         this.validator(entry, args);
400     }
401 }
402 
403 /**
404  * Helper function to add custom validating delegate.
405  *
406  * This function is meant to be used with UFCS, so that it can be placed
407  * within option definition chain.
408  *
409  * In contrast with `validateEachWith`, this functions makes a single call to delegate with all values.
410  *
411  * Params:
412  *   validator - delegate performing validation of all values
413  *
414  * Examples:
415  * ---
416  * new Program("rm")
417  *      .add(new Argument("target", "target to remove")
418  *          .validateWith((entry, args) {
419  *              foreach (arg; args) {
420  *                  // do something
421  *              }
422  *          })
423  *      )
424  * ---
425  *
426  * See_Also:
427  *   DelegateValidator, validateEachWith
428  */
429 public T validateWith(T: IEntry)(T entry, DelegateValidator.ValidatorFunc validator) {
430     return entry.validate(new DelegateValidator(validator));
431 }
432 
433 
434 /**
435  * Helper function to add custom validating delegate.
436  *
437  * This function is meant to be used with UFCS, so that it can be placed
438  * within option definition chain.
439  *
440  * In contrast with `validateWith`, this functions makes call to delegate for every value.
441  *
442  * Params:
443  *   validator - delegate performing validation of single value
444  *
445  * Examples:
446  * ---
447  * new Program("rm")
448  *      .add(new Argument("target", "target to remove")
449  *          .validateEachWith((entry, arg) {
450  *              // do something
451  *          })
452  *      )
453  * ---
454  *
455  * See_Also:
456  *   DelegateValidator, validateWith
457  */
458 public T validateEachWith(T: IEntry)(T entry, void delegate(IEntry, string) validator) {
459     return validateWith!T(entry, (e, args) { args.each!(a => validator(e, a)); });
460 }
461 
462 
463 /**
464  * Helper function to add custom validating delegate.
465  *
466  * This function is meant to be used with UFCS, so that it can be placed
467  * within option definition chain.
468  *
469  * This function automatically prepends entry information to your error message,
470  * so that call to `new Option("", "foo", "").validateWith(a => a.isDir, "must be a directory")`
471  * on failure would throw `ValidationException` with message `option foo must be a directory`.
472  *
473  * Params:
474  *   validator - delegate performing validation, returning true on success
475  *   message - error message
476  *
477  * Examples:
478  * ---
479  * new Program("rm")
480  *      .add(new Argument("target", "target to remove")
481  *          .validateEachWith(arg => arg.isSymLink, "must be a symlink")
482  *      )
483  * ---
484  *
485  * See_Also:
486  *   DelegateValidator, validateWith
487  */
488 public T validateEachWith(T: IEntry)(T entry, bool delegate(string) validator, string errorMessage) {
489     return entry.validateEachWith((entry, arg) {
490         if (!validator(arg)) {
491             throw new ValidationException(null, "%s %s %s".format(entry.getEntryKindName(), entry.name, errorMessage));
492         }
493     });
494 }
495 
496 // enum
497 unittest {
498     import commandr.program;
499     import commandr.option;
500     import commandr.parser;
501     import std.exception : assertThrown, assertNotThrown;
502 
503     assertNotThrown!ValidationException(
504         new Program("test")
505             .add(new Option("t", "type", "foo")
506                 .acceptsValues(["a", "b"])
507             )
508             .parseArgsNoRef(["test"])
509     );
510 
511     assertNotThrown!ValidationException(
512         new Program("test")
513             .add(new Option("t", "type", "foo")
514                 .acceptsValues(["a", "b"])
515             )
516             .parseArgsNoRef(["test", "--type", "a"])
517     );
518 
519     assertNotThrown!ValidationException(
520         new Program("test")
521             .add(new Option("t", "type", "foo")
522                 .acceptsValues(["a", "b"])
523                 .repeating
524             )
525             .parseArgsNoRef(["test", "--type", "a", "--type", "b"])
526     );
527 
528     assertThrown!ValidationException(
529         new Program("test")
530             .add(new Option("t", "type", "foo")
531                 .acceptsValues(["a", "b"])
532                 .repeating
533             )
534             .parseArgsNoRef(["test", "--type", "c", "--type", "b"])
535     );
536 
537     assertThrown!ValidationException(
538         new Program("test")
539             .add(new Option("t", "type", "foo")
540                 .acceptsValues(["a", "b"])
541                 .repeating
542             )
543             .parseArgsNoRef(["test", "--type", "a", "--type", "z"])
544     );
545 }
546 
547 // delegate
548 unittest {
549     import commandr.program;
550     import commandr.option;
551     import commandr.parser;
552     import std.exception : assertThrown, assertNotThrown;
553     import std..string : isNumeric;
554 
555     assertNotThrown!ValidationException(
556         new Program("test")
557             .add(new Option("t", "type", "foo")
558                 .validateEachWith(a => isNumeric(a), "must be an integer")
559             )
560             .parseArgsNoRef(["test", "--type", "50"])
561     );
562 
563     assertThrown!ValidationException(
564         new Program("test")
565             .add(new Option("t", "type", "foo")
566                 .validateEachWith(a => isNumeric(a), "must be an integer")
567             )
568             .parseArgsNoRef(["test", "--type", "a"])
569     );
570 }