1 /**
2 Copyright: Copyright (c) 2017, Joakim Brännström. All rights reserved.
3 License: MPL-2
4 Author: Joakim Brännström (joakim.brannstrom@gmx.com)
5 
6 This Source Code Form is subject to the terms of the Mozilla Public License,
7 v.2.0. If a copy of the MPL was not distributed with this file, You can obtain
8 one at http://mozilla.org/MPL/2.0/.
9 
10 This module is responsible for converting the users CLI arguments to
11 configuration of how the mutation plugin should behave.
12 */
13 module dextool.plugin.mutate.frontend.argparser;
14 
15 import core.time : dur;
16 import logger = std.experimental.logger;
17 import std.algorithm : joiner, sort, map, filter;
18 import std.array : empty, array, appender;
19 import std.exception : collectException;
20 import std.traits : EnumMembers;
21 
22 import toml : TOMLDocument;
23 
24 public import dextool.plugin.mutate.backend : Mutation;
25 public import dextool.plugin.mutate.type;
26 import dextool.plugin.mutate.config;
27 import dextool.type : AbsolutePath, Path, ExitStatusType, DirName;
28 
29 version (unittest) {
30     import unit_threaded.assertions;
31 }
32 
33 @safe:
34 
35 /// Extract and cleanup user input from the command line.
36 struct ArgParser {
37     import std.typecons : Nullable;
38     import std.conv : ConvException;
39     import std.getopt : GetoptResult, getopt, defaultGetoptPrinter;
40     import dextool.type : FileName;
41 
42     /// Minimal data needed to bootstrap the configuration.
43     MiniConfig miniConf;
44 
45     ConfigAdmin admin;
46     ConfigAnalyze analyze;
47     ConfigCompileDb compileDb;
48     ConfigCompiler compiler;
49     ConfigMutationTest mutationTest;
50     ConfigReport report;
51     ConfigWorkArea workArea;
52     ConfigGenerate generate;
53 
54     struct Data {
55         string[] inFiles;
56 
57         AbsolutePath db;
58 
59         bool help;
60         ExitStatusType exitStatus = ExitStatusType.Ok;
61 
62         MutationKind[] mutation;
63 
64         ToolMode toolMode;
65 
66         Mutation.Status to_status;
67     }
68 
69     Data data;
70     alias data this;
71 
72     private GetoptResult help_info;
73 
74     alias GroupF = void delegate(string[]) @system;
75     GroupF[string] groups;
76 
77     /// Returns: a config object with default values.
78     static ArgParser make() @safe {
79         import dextool.compilation_db : defaultCompilerFlagFilter, CompileCommandFilter;
80 
81         ArgParser r;
82         r.compileDb.flagFilter = CompileCommandFilter(defaultCompilerFlagFilter, 0);
83         return r;
84     }
85 
86     /// Convert the configuration to a TOML file.
87     string toTOML() @trusted {
88         import std.ascii : newline;
89         import std.conv : to;
90         import std.format : format;
91         import std.utf : toUTF8;
92 
93         auto app = appender!(string[])();
94 
95         app.put("[workarea]");
96         app.put("# path used as the root for accessing files");
97         app.put(
98                 "# dextool will not modify files outside the root when it perform mutation testing");
99         app.put(`# root = "."`);
100         app.put("# restrict analysis to files in this directory tree");
101         app.put("# this make it possible to only mutate certain parts of an application");
102         app.put("# use relative paths that are inside the root");
103         app.put("# restrict = []");
104         app.put(null);
105 
106         app.put("[analyze]");
107         app.put("# exclude files in these directory tree(s) from analysis");
108         app.put("# relative paths are relative to workarea.root");
109         app.put("# exclude = []");
110         app.put(
111                 "# limit the number of threads used when analysing. Default is as many as there are cores available");
112         app.put("# threads = 1");
113         app.put("# prune (remove) files from the database that aren't found during the analyze");
114         app.put(`# prune = true`);
115         app.put(null);
116 
117         app.put("[database]");
118         app.put("# path to where to store the sqlite3 database");
119         app.put(`# db = "dextool_mutate.sqlite3"`);
120         app.put(null);
121 
122         app.put("[compiler]");
123         app.put("# extra flags to pass on to the compiler such as the C++ standard");
124         app.put(format(`# extra_flags = [%(%s, %)]`, compiler.extraFlags));
125         app.put("# toggle this to force system include paths to use -I instead of -isystem");
126         app.put("# force_system_includes = true");
127         app.put(
128                 "# use this compilers system includes instead of the one used in the compile_commands.json");
129         app.put(format(`# use_compiler_system_includes = "%s"`, compiler.useCompilerSystemIncludes.length == 0
130                 ? "/path/to/c++" : compiler.useCompilerSystemIncludes.value));
131         app.put(null);
132 
133         app.put("[compile_commands]");
134         app.put("# search for compile_commands.json in this paths");
135         if (compileDb.dbs.length == 0)
136             app.put(`# search_paths = ["./compile_commands.json"]`);
137         else
138             app.put(format("search_paths = %s", compileDb.rawDbs));
139         app.put("# flags to remove when analyzing a file in the DB");
140         app.put(format("# filter = [%(%s, %)]", compileDb.flagFilter.filter));
141         app.put("# compiler arguments to skip from the beginning. Needed when the first argument is NOT a compiler but rather a wrapper");
142         app.put(format("# skip_compiler_args = %s", compileDb.flagFilter.skipCompilerArgs));
143         app.put(null);
144 
145         app.put("[mutant_test]");
146         app.put("# (required) program used to run the test suite");
147         app.put(`# the arguments for test_cmd can be an array of multiple test commands`);
148         app.put(`# 1. ["test1.sh", "test2.sh"]`);
149         app.put(`# 2. [["test1.sh", "-x"], "test2.sh"]`);
150         app.put(`test_cmd = "test.sh"`);
151         app.put(
152                 `# find, recursively, all executables in the directory tree(s) and add them as test_cmds`);
153         app.put(`# use this as a convenience to specifying the binaries manually`);
154         app.put(`# test_cmd_dir = ["./foo/bar"]`);
155         app.put(`# flags to add to all executables found in test_cmd_dir`);
156         app.put(`# test_cmd_dir_flag = ["--gtest_filter", "-*foo"]`);
157         app.put("# timeout to use for the test suite (msecs)");
158         app.put("# test_cmd_timeout = 1000");
159         app.put("# (required) program used to build the application");
160         app.put(`# the arguments for build_cmd can be an array: ["build.sh", "-x"]`);
161         app.put(`build_cmd = "build.sh"`);
162         app.put(
163                 "# program used to analyze the output from the test suite for test cases that killed the mutant");
164         app.put(`# analyze_cmd = "analyze.sh"`);
165         app.put("# builtin analyzer of output from testing frameworks to find failing test cases");
166         app.put(format("# analyze_using_builtin = [%(%s, %)]",
167                 [EnumMembers!TestCaseAnalyzeBuiltin].map!(a => a.to!string)));
168         app.put("# determine in what order mutations are chosen");
169         app.put(format("# order = %(%s|%)", [EnumMembers!MutationOrder].map!(a => a.to!string)));
170         app.put("# how to behave when new test cases are found");
171         app.put(format("# detected_new_test_case = %(%s|%)",
172                 [EnumMembers!(ConfigMutationTest.NewTestCases)].map!(a => a.to!string)));
173         app.put("# how to behave when test cases are detected as having been removed");
174         app.put("# should the test and the gathered statistics be remove too?");
175         app.put(format("# detected_dropped_test_case = %(%s|%)",
176                 [EnumMembers!(ConfigMutationTest.RemovedTestCases)].map!(a => a.to!string)));
177         app.put("# how the oldest mutants should be treated.");
178         app.put("# It is recommended to test them again.");
179         app.put("# Because you may have changed the test suite so mutants that where previously killed by the test suite now survive.");
180         app.put(format("# oldest_mutants = %(%s|%)",
181                 [EnumMembers!(ConfigMutationTest.OldMutant)].map!(a => a.to!string)));
182         app.put("# How many of the oldest mutants to do the above with");
183         app.put("# oldest_mutants_nr = 10");
184         app.put("# limit the number of threads used when running tests in parallel. Default is as many as there are cores available");
185         app.put("# parallel_test = 1");
186         app.put(null);
187 
188         app.put("[report]");
189         app.put("# default style to use");
190         app.put(format("# style = %(%s|%)", [EnumMembers!ReportKind].map!(a => a.to!string)));
191         app.put(null);
192 
193         app.put("[test_group]");
194         app.put("# subgroups with a description and pattern. Example:");
195         app.put("# [test_group.uc1]");
196         app.put(`# description = "use case 1"`);
197         app.put(`# pattern = "uc_1.*"`);
198         app.put(`# see for regex syntax: http://dlang.org/phobos/std_regex.html`);
199         app.put(null);
200 
201         return app.data.joiner(newline).toUTF8;
202     }
203 
204     void parse(string[] args) {
205         import std.format : format;
206 
207         static import std.getopt;
208 
209         const db_help = "sqlite3 database to use (default: dextool_mutate.sqlite3)";
210         const restrict_help = "restrict mutation to the files in this directory tree (default: .)";
211         const out_help = "path used as the root for mutation/reporting of files (default: .)";
212         const conf_help = "load configuration (default: .dextool_mutate.toml)";
213 
214         // not used but need to be here. The one used is in MiniConfig.
215         string conf_file;
216         string db;
217 
218         void analyzerG(string[] args) {
219             string[] compile_dbs;
220             string[] exclude_files;
221             bool noPrune;
222 
223             data.toolMode = ToolMode.analyzer;
224             // dfmt off
225             help_info = getopt(args, std.getopt.config.keepEndOfOptions,
226                    "compile-db", "Retrieve compilation parameters from the file", &compile_dbs,
227                    "c|config", conf_help, &conf_file,
228                    "db", db_help, &db,
229                    "diff-from-stdin", "restrict testing to the mutants in the diff", &analyze.unifiedDiffFromStdin,
230                    "fast-db-store", "improve the write speed of the analyze result (may corrupt the database)", &analyze.fastDbStore,
231                    "file-exclude", "exclude files in these directory tree from the analysis (default: none)", &exclude_files,
232                    "in", "Input file to parse (default: all files in the compilation database)", &data.inFiles,
233                    "no-prune", "do not prune the database of files that aren't found during the analyze", &noPrune,
234                    "out", out_help, &workArea.rawRoot,
235                    "restrict", restrict_help, &workArea.rawRestrict,
236                    "threads", "number of threads to use for analysing files (default: CPU cores available)", &analyze.poolSize,
237                    );
238             // dfmt on
239 
240             analyze.prune = !noPrune;
241             updateCompileDb(compileDb, compile_dbs);
242             if (!exclude_files.empty)
243                 analyze.rawExclude = exclude_files;
244         }
245 
246         void generateMutantG(string[] args) {
247             data.toolMode = ToolMode.generate_mutant;
248             // dfmt off
249             help_info = getopt(args, std.getopt.config.keepEndOfOptions,
250                    "c|config", conf_help, &conf_file,
251                    "db", db_help, &db,
252                    "out", out_help, &workArea.rawRoot,
253                    "restrict", restrict_help, &workArea.rawRestrict,
254                    std.getopt.config.required, "id", "mutate the source code as mutant ID", &generate.mutationId,
255                    );
256             // dfmt on
257         }
258 
259         void testMutantsG(string[] args) {
260             import std.datetime : Clock;
261 
262             string[] mutationTester;
263             string mutationCompile;
264             string mutationTestCaseAnalyze;
265             long mutationTesterRuntime;
266             string maxRuntime;
267             string[] testConstraint;
268             int maxAlive = -1;
269 
270             // set the seed here so if the user specify the CLI the rolling
271             // seed is overridden.
272             mutationTest.pullRequestSeed += Clock.currTime.isoWeek + Clock.currTime.year;
273 
274             data.toolMode = ToolMode.test_mutants;
275             // dfmt off
276             help_info = getopt(args, std.getopt.config.keepEndOfOptions,
277                    "L", "restrict testing to the requested files and lines (<file>:<start>-<end>)", &testConstraint,
278                    "build-cmd", "program used to build the application", &mutationCompile,
279                    "c|config", conf_help, &conf_file,
280                    "db", db_help, &db,
281                    "diff-from-stdin", "restrict testing to the mutants in the diff", &mutationTest.unifiedDiffFromStdin,
282                    "dry-run", "do not write data to the filesystem", &mutationTest.dryRun,
283                    "max-alive", "stop after NR alive mutants is found (only effective with -L or --diff-from-stdin)", &maxAlive,
284                    "max-runtime", format("max time to run the mutation testing for (default: %s)", mutationTest.maxRuntime), &maxRuntime,
285                    "mutant", "kind of mutation to test " ~ format("[%(%s|%)]", [EnumMembers!MutationKind]), &data.mutation,
286                    "order", "determine in what order mutants are chosen " ~ format("[%(%s|%)]", [EnumMembers!MutationOrder]), &mutationTest.mutationOrder,
287                    "out", out_help, &workArea.rawRoot,
288                    "pull-request-seed", "seed used when randomly choosing mutants to test in a pull request", &mutationTest.pullRequestSeed,
289                    "restrict", restrict_help, &workArea.rawRestrict,
290                    "test-case-analyze-builtin", "builtin analyzer of output from testing frameworks to find failing test cases", &mutationTest.mutationTestCaseBuiltin,
291                    "test-case-analyze-cmd", "program used to find what test cases killed the mutant", &mutationTestCaseAnalyze,
292                    "test-cmd", "program used to run the test suite", &mutationTester,
293                    "test-timeout", "timeout to use for the test suite (msecs)", &mutationTesterRuntime,
294                    );
295             // dfmt on
296 
297             if (maxAlive > 0)
298                 mutationTest.maxAlive = maxAlive;
299             if (mutationTester.length != 0)
300                 mutationTest.mutationTester = mutationTester.map!(a => ShellCommand.fromString(a))
301                     .array;
302             if (mutationCompile.length != 0)
303                 mutationTest.mutationCompile = ShellCommand.fromString(mutationCompile);
304             if (mutationTestCaseAnalyze.length != 0)
305                 mutationTest.mutationTestCaseAnalyze = ShellCommand.fromString(
306                         mutationTestCaseAnalyze);
307             if (mutationTesterRuntime != 0)
308                 mutationTest.mutationTesterRuntime = mutationTesterRuntime.dur!"msecs";
309             if (!maxRuntime.empty)
310                 mutationTest.maxRuntime = parseDuration(maxRuntime);
311             mutationTest.constraint = parseUserTestConstraint(testConstraint);
312         }
313 
314         void reportG(string[] args) {
315             string[] compile_dbs;
316             string logDir;
317 
318             data.toolMode = ToolMode.report;
319             // dfmt off
320             help_info = getopt(args, std.getopt.config.keepEndOfOptions,
321                    "compile-db", "Retrieve compilation parameters from the file", &compile_dbs,
322                    "c|config", conf_help, &conf_file,
323                    "db", db_help, &db,
324                    "diff-from-stdin", "report alive mutants in the areas indicated as changed in the diff", &report.unifiedDiff,
325                    "level", "the report level of the mutation data " ~ format("[%(%s|%)]", [EnumMembers!ReportLevel]), &report.reportLevel,
326                    "logdir", "Directory to write log files to (default: .)", &logDir,
327                    "mutant", "kind of mutation to report " ~ format("[%(%s|%)]", [EnumMembers!MutationKind]), &data.mutation,
328                    "out", out_help, &workArea.rawRoot,
329                    "restrict", restrict_help, &workArea.rawRestrict,
330                    "section", "sections to include in the report " ~ format("[%(%s|%)]", [EnumMembers!ReportSection]), &report.reportSection,
331                    "section-tc_stat-num", "number of test cases to report", &report.tcKillSortNum,
332                    "section-tc_stat-sort", "sort order when reporting test case kill stat " ~ format("[%(%s|%)]", [EnumMembers!ReportKillSortOrder]), &report.tcKillSortOrder,
333                    "style", "kind of report to generate " ~ format("[%(%s|%)]", [EnumMembers!ReportKind]), &report.reportKind,
334                    );
335             // dfmt on
336 
337             if (report.reportSection.length != 0 && report.reportLevel != ReportLevel.summary) {
338                 logger.error("Combining --section and --level is not supported");
339                 help_info.helpWanted = true;
340             }
341 
342             if (logDir.empty)
343                 logDir = ".";
344             report.logDir = logDir.Path.AbsolutePath;
345 
346             updateCompileDb(compileDb, compile_dbs);
347         }
348 
349         void adminG(string[] args) {
350             bool dump_conf;
351             bool init_conf;
352             data.toolMode = ToolMode.admin;
353             // dfmt off
354             help_info = getopt(args, std.getopt.config.keepEndOfOptions,
355                 "c|config", conf_help, &conf_file,
356                 "db", db_help, &db,
357                 "dump-config", "dump the detailed configuration used", &dump_conf,
358                 "init", "create an initial config to use", &init_conf,
359                 "mutant", "mutants to operate on " ~ format("[%(%s|%)]", [EnumMembers!MutationKind]), &data.mutation,
360                 "operation", "administrative operation to perform " ~ format("[%(%s|%)]", [EnumMembers!AdminOperation]), &admin.adminOp,
361                 "test-case-regex", "regex to use when removing test cases", &admin.testCaseRegex,
362                 "status", "change mutants with this state to the value specified by --to-status " ~ format("[%(%s|%)]", [EnumMembers!(Mutation.Status)]), &admin.mutantStatus,
363                 "to-status", "reset mutants to state (default: unknown) " ~ format("[%(%s|%)]", [EnumMembers!(Mutation.Status)]), &admin.mutantToStatus,
364                 "id", "specify mutant by Id", &admin.mutationId,
365                 "rationale", "rationale for marking mutant", &admin.mutantRationale,
366                 "out", out_help, &workArea.rawRoot,
367                 );
368             // dfmt on
369 
370             if (dump_conf)
371                 data.toolMode = ToolMode.dumpConfig;
372             else if (init_conf)
373                 data.toolMode = ToolMode.initConfig;
374         }
375 
376         groups["analyze"] = &analyzerG;
377         groups["generate"] = &generateMutantG;
378         groups["test"] = &testMutantsG;
379         groups["report"] = &reportG;
380         groups["admin"] = &adminG;
381 
382         if (args.length < 2) {
383             logger.error("Missing command");
384             help = true;
385             exitStatus = ExitStatusType.Errors;
386             return;
387         }
388 
389         const string cg = args[1];
390         string[] subargs = args[0 .. 1];
391         if (args.length > 2)
392             subargs ~= args[2 .. $];
393 
394         if (auto f = cg in groups) {
395             try {
396                 // trusted: not any external input.
397                 () @trusted { (*f)(subargs); }();
398                 help = help_info.helpWanted;
399             } catch (std.getopt.GetOptException ex) {
400                 logger.error(ex.msg);
401                 help = true;
402                 exitStatus = ExitStatusType.Errors;
403             } catch (Exception ex) {
404                 logger.error(ex.msg);
405                 help = true;
406                 exitStatus = ExitStatusType.Errors;
407             }
408         } else {
409             logger.error("Unknown command: ", cg);
410             help = true;
411             exitStatus = ExitStatusType.Errors;
412             return;
413         }
414 
415         import std.algorithm : find;
416         import std.range : drop;
417 
418         if (db.length != 0)
419             data.db = AbsolutePath(FileName(db));
420         else if (data.db.length == 0)
421             data.db = "dextool_mutate.sqlite3".Path.AbsolutePath;
422 
423         if (workArea.rawRoot.empty) {
424             workArea.rawRoot = ".";
425         }
426         workArea.outputDirectory = workArea.rawRoot.Path.AbsolutePath;
427 
428         if (workArea.rawRestrict.empty) {
429             workArea.rawRestrict = [workArea.rawRoot];
430         }
431         workArea.restrictDir = workArea.rawRestrict.map!(a => AbsolutePath(FileName(a),
432                 DirName(workArea.outputDirectory))).array;
433 
434         analyze.exclude = analyze.rawExclude.map!(a => AbsolutePath(FileName(a),
435                 DirName(workArea.outputDirectory))).array;
436 
437         compiler.extraFlags = compiler.extraFlags ~ args.find("--").drop(1).array();
438     }
439 
440     /**
441      * Trusted:
442      * The only input is a static string and data derived from getopt itselt.
443      * Assuming that getopt in phobos behave well.
444      */
445     void printHelp() @trusted {
446         import std.ascii : newline;
447         import std.stdio : writeln;
448 
449         string base_help = "Usage: dextool mutate COMMAND [options]";
450 
451         switch (toolMode) with (ToolMode) {
452         case none:
453             writeln("commands: ", newline,
454                     groups.byKey.array.sort.map!(a => "  " ~ a).joiner(newline));
455             break;
456         case analyzer:
457             base_help = "Usage: dextool mutate analyze [options] [-- CFLAGS...]";
458             break;
459         case generate_mutant:
460             break;
461         case test_mutants:
462             logger.infof("--test-case-analyze-builtin possible values: %(%s|%)",
463                     [EnumMembers!TestCaseAnalyzeBuiltin]);
464             logger.infof(
465                     "--max-runtime supported units are [weeks, days, hours, minutes, seconds, msecs]");
466             logger.infof(`example: --max-runtime "1 hours 30 minutes"`);
467             break;
468         case report:
469             break;
470         case admin:
471             break;
472         default:
473             break;
474         }
475 
476         defaultGetoptPrinter(base_help, help_info.options);
477     }
478 }
479 
480 /// Update the config from the users input.
481 void updateCompileDb(ref ConfigCompileDb db, string[] compile_dbs) {
482     if (compile_dbs.length != 0)
483         db.rawDbs = compile_dbs;
484     db.dbs = db.rawDbs
485         .filter!(a => a.length != 0)
486         .map!(a => Path(a).AbsolutePath)
487         .array;
488 }
489 
490 /** Print a help message conveying how files in the compilation database will
491  * be analyzed.
492  *
493  * It must be enough information that the user can adjust `--out` and `--restrict`.
494  */
495 void printFileAnalyzeHelp(ref ArgParser ap) @safe {
496     static void printPath(string user, AbsolutePath real_path) {
497         logger.info("  User input: ", user);
498         logger.info("  Real path: ", real_path);
499     }
500 
501     logger.infof("Reading compilation database:\n%-(%s\n%)", ap.compileDb.dbs);
502 
503     logger.info(
504             "Analyze and mutation of files will only be done on those inside this directory root");
505     printPath(ap.workArea.rawRoot, ap.workArea.outputDirectory);
506     logger.info(ap.workArea.rawRestrict.length != 0,
507             "Restricting mutation to files in the following directory tree(s)");
508 
509     assert(ap.workArea.rawRestrict.length == ap.workArea.restrictDir.length);
510     foreach (idx; 0 .. ap.workArea.rawRestrict.length) {
511         if (ap.workArea.rawRestrict[idx] == ap.workArea.rawRoot)
512             continue;
513         printPath(ap.workArea.rawRestrict[idx], ap.workArea.restrictDir[idx]);
514     }
515 
516     logger.info(!ap.analyze.exclude.empty,
517             "Excluding files inside the following directory tree(s) from analysis");
518     foreach (idx; 0 .. ap.analyze.exclude.length) {
519         printPath(ap.analyze.rawExclude[idx], ap.analyze.exclude[idx]);
520     }
521 }
522 
523 /** Load the configuration from file.
524  *
525  * Example of a TOML configuration
526  * ---
527  * [defaults]
528  * check_name_standard = true
529  * ---
530  */
531 void loadConfig(ref ArgParser rval) @trusted {
532     import std.file : exists, readText;
533     import toml;
534 
535     if (!exists(rval.miniConf.confFile))
536         return;
537 
538     static auto tryLoading(string configFile) {
539         auto txt = readText(configFile);
540         auto doc = parseTOML(txt);
541         return doc;
542     }
543 
544     TOMLDocument doc;
545     try {
546         doc = tryLoading(rval.miniConf.confFile);
547     } catch (Exception e) {
548         logger.warning("Unable to read the configuration from ", rval.miniConf.confFile);
549         logger.warning(e.msg);
550         rval.data.exitStatus = ExitStatusType.Errors;
551         return;
552     }
553 
554     rval = loadConfig(rval, doc);
555 }
556 
557 ArgParser loadConfig(ArgParser rval, ref TOMLDocument doc) @trusted {
558     import std.conv : to;
559     import std.path : dirName, buildPath;
560     import toml;
561 
562     alias Fn = void delegate(ref ArgParser c, ref TOMLValue v);
563     Fn[string] callbacks;
564 
565     static ShellCommand toShellCommand(ref TOMLValue v, string errorMsg) {
566         if (v.type == TOML_TYPE.STRING) {
567             return ShellCommand.fromString(v.str);
568         } else if (v.type == TOML_TYPE.ARRAY) {
569             return ShellCommand(v.array.map!(a => a.str).array);
570         }
571         logger.warning(errorMsg);
572         return ShellCommand.init;
573     }
574 
575     static ShellCommand[] toShellCommands(ref TOMLValue v, string errorMsg) {
576         import std.format : format;
577 
578         if (v.type == TOML_TYPE.STRING) {
579             return [ShellCommand.fromString(v.str)];
580         } else if (v.type == TOML_TYPE.ARRAY) {
581             return v.array.map!(a => toShellCommand(a,
582                     format!"%s: failed to parse as an array"(errorMsg))).array;
583         }
584         logger.warning(errorMsg);
585         return ShellCommand[].init;
586     }
587 
588     callbacks["analyze.exclude"] = (ref ArgParser c, ref TOMLValue v) {
589         c.analyze.rawExclude = v.array.map!(a => a.str).array;
590     };
591     callbacks["analyze.threads"] = (ref ArgParser c, ref TOMLValue v) {
592         c.analyze.poolSize = cast(int) v.integer;
593     };
594     callbacks["analyze.prune"] = (ref ArgParser c, ref TOMLValue v) {
595         c.analyze.prune = v == true;
596     };
597 
598     callbacks["workarea.root"] = (ref ArgParser c, ref TOMLValue v) {
599         c.workArea.rawRoot = v.str;
600     };
601     callbacks["workarea.restrict"] = (ref ArgParser c, ref TOMLValue v) {
602         c.workArea.rawRestrict = v.array.map!(a => a.str).array;
603     };
604 
605     callbacks["database.db"] = (ref ArgParser c, ref TOMLValue v) {
606         c.db = v.str.Path.AbsolutePath;
607     };
608 
609     callbacks["compile_commands.search_paths"] = (ref ArgParser c, ref TOMLValue v) {
610         c.compileDb.rawDbs = v.array.map!"a.str".array;
611     };
612     callbacks["compile_commands.filter"] = (ref ArgParser c, ref TOMLValue v) {
613         import dextool.type : FilterClangFlag;
614 
615         c.compileDb.flagFilter.filter = v.array.map!(a => FilterClangFlag(a.str)).array;
616     };
617     callbacks["compile_commands.skip_compiler_args"] = (ref ArgParser c, ref TOMLValue v) {
618         c.compileDb.flagFilter.skipCompilerArgs = cast(int) v.integer;
619     };
620 
621     callbacks["compiler.extra_flags"] = (ref ArgParser c, ref TOMLValue v) {
622         c.compiler.extraFlags = v.array.map!(a => a.str).array;
623     };
624     callbacks["compiler.force_system_includes"] = (ref ArgParser c, ref TOMLValue v) {
625         c.compiler.forceSystemIncludes = v == true;
626     };
627     callbacks["compiler.use_compiler_system_includes"] = (ref ArgParser c, ref TOMLValue v) {
628         c.compiler.useCompilerSystemIncludes = v.str;
629     };
630 
631     callbacks["mutant_test.test_cmd"] = (ref ArgParser c, ref TOMLValue v) {
632         c.mutationTest.mutationTester = toShellCommands(v,
633                 "config: failed to parse mutant_test.test_cmd");
634     };
635     callbacks["mutant_test.test_cmd_dir"] = (ref ArgParser c, ref TOMLValue v) {
636         c.mutationTest.testCommandDir = v.array.map!(a => Path(a.str)).array;
637     };
638     callbacks["mutant_test.test_cmd_dir_flag"] = (ref ArgParser c, ref TOMLValue v) {
639         c.mutationTest.testCommandDirFlag = v.array.map!(a => a.str).array;
640     };
641     callbacks["mutant_test.test_cmd_timeout"] = (ref ArgParser c, ref TOMLValue v) {
642         c.mutationTest.mutationTesterRuntime = v.integer.dur!"msecs";
643     };
644     callbacks["mutant_test.build_cmd"] = (ref ArgParser c, ref TOMLValue v) {
645         c.mutationTest.mutationCompile = toShellCommand(v,
646                 "config: failed to parse mutant_test.build_cmd");
647     };
648     callbacks["mutant_test.analyze_cmd"] = (ref ArgParser c, ref TOMLValue v) {
649         c.mutationTest.mutationTestCaseAnalyze = toShellCommand(v,
650                 "config: failed to parse mutant_test.analyze_cmd");
651     };
652     callbacks["mutant_test.analyze_using_builtin"] = (ref ArgParser c, ref TOMLValue v) {
653         c.mutationTest.mutationTestCaseBuiltin = v.array.map!(
654                 a => a.str.to!TestCaseAnalyzeBuiltin).array;
655     };
656     callbacks["mutant_test.order"] = (ref ArgParser c, ref TOMLValue v) {
657         c.mutationTest.mutationOrder = v.str.to!MutationOrder;
658     };
659     callbacks["mutant_test.detected_new_test_case"] = (ref ArgParser c, ref TOMLValue v) {
660         try {
661             c.mutationTest.onNewTestCases = v.str.to!(ConfigMutationTest.NewTestCases);
662         } catch (Exception e) {
663             logger.info("Available alternatives: ",
664                     [EnumMembers!(ConfigMutationTest.NewTestCases)]);
665         }
666     };
667     callbacks["mutant_test.detected_dropped_test_case"] = (ref ArgParser c, ref TOMLValue v) {
668         try {
669             c.mutationTest.onRemovedTestCases = v.str.to!(ConfigMutationTest.RemovedTestCases);
670         } catch (Exception e) {
671             logger.info("Available alternatives: ",
672                     [EnumMembers!(ConfigMutationTest.RemovedTestCases)]);
673         }
674     };
675     callbacks["mutant_test.oldest_mutants"] = (ref ArgParser c, ref TOMLValue v) {
676         try {
677             c.mutationTest.onOldMutants = v.str.to!(ConfigMutationTest.OldMutant);
678         } catch (Exception e) {
679             logger.info("Available alternatives: ", [
680                     EnumMembers!(ConfigMutationTest.OldMutant)
681                     ]);
682         }
683     };
684     callbacks["mutant_test.oldest_mutants_nr"] = (ref ArgParser c, ref TOMLValue v) {
685         c.mutationTest.oldMutantsNr = v.integer;
686     };
687     callbacks["mutant_test.parallel_test"] = (ref ArgParser c, ref TOMLValue v) {
688         c.mutationTest.testPoolSize = cast(int) v.integer;
689     };
690 
691     callbacks["report.style"] = (ref ArgParser c, ref TOMLValue v) {
692         c.report.reportKind = v.str.to!ReportKind;
693     };
694 
695     void iterSection(ref ArgParser c, string sectionName) {
696         if (auto section = sectionName in doc) {
697             // specific configuration from section members
698             foreach (k, v; *section) {
699                 if (auto cb = (sectionName ~ "." ~ k) in callbacks) {
700                     try {
701                         (*cb)(c, v);
702                     } catch (Exception e) {
703                         logger.error(e.msg).collectException;
704                     }
705                 } else {
706                     logger.infof("Unknown key '%s' in configuration section '%s'", k, sectionName);
707                 }
708             }
709         }
710     }
711 
712     iterSection(rval, "analyze");
713     iterSection(rval, "workarea");
714     iterSection(rval, "database");
715     iterSection(rval, "compiler");
716     iterSection(rval, "compile_commands");
717     iterSection(rval, "mutant_test");
718     iterSection(rval, "report");
719 
720     parseTestGroups(rval, doc);
721 
722     return rval;
723 }
724 
725 void parseTestGroups(ref ArgParser c, ref TOMLDocument doc) @trusted {
726     import toml;
727 
728     if ("test_group" !in doc)
729         return;
730 
731     foreach (k, s; *("test_group" in doc)) {
732         if (s.type != TOML_TYPE.TABLE)
733             continue;
734 
735         string desc;
736         if (auto v = "description" in s)
737             desc = v.str;
738         if (auto v = "pattern" in s) {
739             string re = v.str;
740             c.report.testGroups ~= TestGroup(k, desc, re);
741         }
742     }
743 }
744 
745 @("shall populate the test, build and analyze command of an ArgParser from a TOML document")
746 @system unittest {
747     import std.format : format;
748     import toml : parseTOML;
749 
750     immutable txt = `
751 [mutant_test]
752 test_cmd = %s
753 build_cmd = %s
754 analyze_cmd = %s
755 `;
756 
757     {
758         auto doc = parseTOML(format!txt(`"test.sh"`, `"build.sh"`, `"analyze.sh"`));
759         auto ap = loadConfig(ArgParser.init, doc);
760         ap.mutationTest.mutationTester.shouldEqual([ShellCommand(["test.sh"])]);
761         ap.mutationTest.mutationCompile.shouldEqual(ShellCommand(["build.sh"]));
762         ap.mutationTest.mutationTestCaseAnalyze.shouldEqual(ShellCommand([
763                     "analyze.sh"
764                 ]));
765     }
766 
767     {
768         auto doc = parseTOML(format!txt(`[["test1.sh"], ["test2.sh"]]`,
769                 `["build.sh", "-y"]`, `["analyze.sh", "-z"]`));
770         auto ap = loadConfig(ArgParser.init, doc);
771         ap.mutationTest.mutationTester.shouldEqual([
772                 ShellCommand(["test1.sh"]), ShellCommand(["test2.sh"])
773                 ]);
774         ap.mutationTest.mutationCompile.shouldEqual(ShellCommand([
775                     "build.sh", "-y"
776                 ]));
777         ap.mutationTest.mutationTestCaseAnalyze.shouldEqual(ShellCommand([
778                     "analyze.sh", "-z"
779                 ]));
780     }
781 
782     {
783         auto doc = parseTOML(format!txt(`[["test1.sh", "-x"], ["test2.sh", "-y"]]`,
784                 `"build.sh"`, `"analyze.sh"`));
785         auto ap = loadConfig(ArgParser.init, doc);
786         ap.mutationTest.mutationTester.shouldEqual([
787                 ShellCommand(["test1.sh", "-x"]), ShellCommand([
788                         "test2.sh", "-y"
789                     ])
790                 ]);
791     }
792 }
793 
794 @("shall set the thread analyze limitation from the configuration")
795 @system unittest {
796     import toml : parseTOML;
797 
798     immutable txt = `
799 [analyze]
800 threads = 42
801 `;
802     auto doc = parseTOML(txt);
803     auto ap = loadConfig(ArgParser.init, doc);
804     ap.analyze.poolSize.shouldEqual(42);
805 }
806 
807 @("shall set how many tests are executed in parallel from the configuration")
808 @system unittest {
809     import toml : parseTOML;
810 
811     immutable txt = `
812 [mutant_test]
813 parallel_test = 42
814 `;
815     auto doc = parseTOML(txt);
816     auto ap = loadConfig(ArgParser.init, doc);
817     ap.mutationTest.testPoolSize.shouldEqual(42);
818 }
819 
820 @("shall deactivate prune of old files when analyze")
821 @system unittest {
822     import toml : parseTOML;
823 
824     immutable txt = `
825 [analyze]
826 prune = false
827 `;
828     auto doc = parseTOML(txt);
829     auto ap = loadConfig(ArgParser.init, doc);
830     ap.analyze.prune.shouldBeFalse;
831 }
832 
833 /// Minimal config to setup path to config file.
834 struct MiniConfig {
835     /// Value from the user via CLI, unmodified.
836     string rawConfFile;
837 
838     /// The configuration file that has been loaded
839     AbsolutePath confFile;
840 
841     bool shortPluginHelp;
842 }
843 
844 /// Returns: minimal config to load settings and setup working directory.
845 MiniConfig cliToMiniConfig(string[] args) @trusted nothrow {
846     import std.file : exists;
847     static import std.getopt;
848 
849     immutable default_conf = ".dextool_mutate.toml";
850 
851     MiniConfig conf;
852 
853     try {
854         std.getopt.getopt(args, std.getopt.config.keepEndOfOptions, std.getopt.config.passThrough,
855                 "c|config", "none not visible to the user", &conf.rawConfFile,
856                 "short-plugin-help", "not visible to the user", &conf.shortPluginHelp);
857         if (conf.rawConfFile.length == 0)
858             conf.rawConfFile = default_conf;
859         conf.confFile = Path(conf.rawConfFile).AbsolutePath;
860     } catch (Exception e) {
861         logger.trace(conf).collectException;
862         logger.error(e.msg).collectException;
863     }
864 
865     return conf;
866 }
867 
868 auto parseDuration(string timeSpec) {
869     import std.conv : to;
870     import std.string : split;
871     import std.datetime : Duration, dur;
872     import std.range : chunks;
873 
874     Duration d;
875     const parts = timeSpec.split;
876 
877     if (parts.length % 2 != 0) {
878         logger.warning("Invalid time specification because either the number or unit is missing");
879         return d;
880     }
881 
882     foreach (const p; parts.chunks(2)) {
883         const nr = p[0].to!long;
884         bool validUnit;
885         immutable Units = [
886             "msecs", "seconds", "minutes", "hours", "days", "weeks"
887         ];
888         static foreach (Unit; Units) {
889             if (p[1] == Unit) {
890                 d += nr.dur!Unit;
891                 validUnit = true;
892             }
893         }
894         if (!validUnit) {
895             logger.warningf("Invalid unit '%s'. Valid are %-(%s, %).", p[1], Units);
896             return d;
897         }
898     }
899 
900     return d;
901 }
902 
903 @("shall parse a string to a duration")
904 unittest {
905     const expected = 1.dur!"weeks" + 1.dur!"days" + 3.dur!"hours"
906         + 2.dur!"minutes" + 5.dur!"seconds" + 9.dur!"msecs";
907     const d = parseDuration("1 weeks 1 days 3 hours 2 minutes 5 seconds 9 msecs");
908     d.should == expected;
909 }
910 
911 auto parseUserTestConstraint(string[] raw) {
912     import std.conv : to;
913     import std.regex : regex, matchFirst;
914     import std.typecons : tuple;
915     import dextool.plugin.mutate.type : TestConstraint;
916 
917     TestConstraint rval;
918     const re = regex(`(?P<file>.*):(?P<start>\d*)-(?P<end>\d*)`);
919 
920     foreach (r; raw.map!(a => tuple!("user", "match")(a, matchFirst(a, re)))) {
921         if (r.match.empty) {
922             logger.warning("Unable to parse ", r.user);
923             continue;
924         }
925 
926         const start = r.match["start"].to!uint;
927         const end = r.match["end"].to!uint + 1;
928 
929         if (start > end) {
930             logger.warningf("Unable to parse %s because start (%s) must be less than end (%s)",
931                     r.user, r.match["start"], r.match["end"]);
932             continue;
933         }
934 
935         foreach (const l; start .. end)
936             rval.value[Path(r.match["file"])] ~= Line(l);
937     }
938 
939     return rval;
940 }
941 
942 @("shall parse a test restriction")
943 unittest {
944     const r = parseUserTestConstraint([
945             "foo/bar:1-10", "smurf bar/i oknen:ea,ting:33-45"
946             ]);
947 
948     Path("foo/bar").shouldBeIn(r.value);
949     r.value[Path("foo/bar")][0].should == Line(1);
950     r.value[Path("foo/bar")][9].should == Line(10);
951 
952     Path("smurf bar/i oknen:ea,ting").shouldBeIn(r.value);
953     r.value[Path("smurf bar/i oknen:ea,ting")][0].should == Line(33);
954     r.value[Path("smurf bar/i oknen:ea,ting")][12].should == Line(45);
955 }