1 /**
2     Defines the behavior of the `dentist` command line client.
3 
4     Copyright: © 2018 Arne Ludwig <arne.ludwig@posteo.de>
5     License: Subject to the terms of the MIT license, as written in the
6              included LICENSE file.
7     Authors: Arne Ludwig <arne.ludwig@posteo.de>
8 */
9 module dentist.commandline;
10 
11 import darg :
12     ArgParseHelp,
13     Argument,
14     Help,
15     helpString,
16     MetaVar,
17     Multiplicity,
18     Option,
19     OptionFlag,
20     parseArgs,
21     usageString;
22 import dentist.common :
23     isTesting,
24     OutputCoordinate,
25     testingOnly;
26 import dentist.common.alignments :
27     coord_t,
28     id_t,
29     trace_point_t;
30 import dentist.common.binio : PileUpDb;
31 import dentist.common.scaffold : JoinPolicy;
32 import dentist.dazzler :
33     DaccordOptions,
34     DalignerOptions,
35     DamapperOptions,
36     getHiddenDbFiles,
37     getMaskFiles,
38     getTracePointDistance,
39     lasEmpty,
40     LasFilterAlignmentsOptions,
41     provideDamFileInWorkdir,
42     provideLasFileInWorkdir,
43     ProvideMethod,
44     provideMethods;
45 import dentist.swinfo :
46     copyright,
47     executableName,
48     description,
49     license,
50     version_;
51 import dentist.util.algorithm : staticPredSwitch;
52 import dentist.util.log;
53 import dentist.util.tempfile : mkdtemp;
54 import std.algorithm :
55     among,
56     each,
57     endsWith,
58     filter,
59     find,
60     map,
61     startsWith;
62 import std.conv;
63 import std.exception : enforce, ErrnoException;
64 import std.file : exists, FileException, getcwd, isDir, tempDir, remove, rmdirRecurse;
65 import std.format : format, formattedRead;
66 import std.math : ceil, floor, log_e = log;
67 import std.meta : AliasSeq, staticMap, staticSort;
68 import std.parallelism : defaultPoolThreads, totalCPUs;
69 import std.path : absolutePath, buildPath;
70 import std.range : only, takeOne;
71 import std.regex : ctRegex, matchFirst;
72 import std.stdio : File, stderr;
73 import std..string : join, tr, wrap;
74 import std.traits :
75     arity,
76     EnumMembers,
77     getSymbolsByUDA,
78     getUDAs,
79     isCallable,
80     isStaticArray,
81     Parameters,
82     ReturnType;
83 import std.typecons : BitFlags;
84 import transforms : camelCase, snakeCaseCT;
85 import vibe.data.json : serializeToJsonString;
86 
87 
88 /// Possible returns codes of the command line execution.
89 enum ReturnCode
90 {
91     ok,
92     commandlineError,
93     runtimeError,
94 }
95 
96 /// Possible returns codes of the command line execution.
97 mixin("enum DentistCommand {" ~
98     testingOnly!"translocateGaps," ~
99     testingOnly!"findClosableGaps," ~
100     "generateDazzlerOptions," ~
101     "maskRepetitiveRegions," ~
102     "showMask," ~
103     "collectPileUps," ~
104     "showPileUps," ~
105     "processPileUps," ~
106     "showInsertions," ~
107     "mergeInsertions," ~
108     "output," ~
109     testingOnly!"translateCoords," ~
110     testingOnly!"checkResults," ~
111 "}");
112 
113 struct TestingCommand
114 {
115     @disable this();
116 
117     static DentistCommand opDispatch(string command)() pure nothrow
118     {
119         static if (isTesting)
120             return mixin("DentistCommand." ~ command);
121         else
122             return cast(DentistCommand) size_t.max;
123     }
124 }
125 
126 enum dashCase(string camelCase) = camelCase.snakeCaseCT.tr("_", "-");
127 
128 private enum dentistCommands = staticMap!(
129     dashCase,
130     __traits(allMembers, DentistCommand),
131 );
132 
133 /// Start `dentist` with the given set of arguments.
134 ReturnCode run(in string[] args)
135 {
136     if (args.length == 1)
137     {
138         printBaseHelp();
139 
140         return ReturnCode.commandlineError;
141     }
142 
143     switch (args[1])
144     {
145     case "--version":
146         printVersion();
147 
148         return ReturnCode.ok;
149     case "-h":
150         goto case;
151     case "--help":
152         printBaseHelp();
153 
154         return ReturnCode.ok;
155     case "--usage":
156         stderr.write(usageString!BaseOptions(executableName));
157 
158         return ReturnCode.ok;
159     default:
160         break;
161     }
162 
163     string commandName;
164     try
165     {
166         commandName = parseCommandName(args);
167     }
168     catch (Exception e)
169     {
170         stderr.writeln("Error: " ~ (shouldLog(LogLevel.diagnostic)
171             ? e.to!string
172             : e.msg));
173         stderr.writeln();
174         stderr.write(usageString!BaseOptions(executableName));
175 
176         return ReturnCode.commandlineError;
177     }
178 
179     auto commandWithArgs = args[1 .. $];
180     DentistCommand command = parseArgs!BaseOptions([commandName]).command;
181 
182     try
183     {
184         final switch (command)
185         {
186             static foreach (caseCommand; EnumMembers!DentistCommand)
187             {
188                 case caseCommand:
189                     return runCommand!caseCommand(commandWithArgs);
190             }
191         }
192     }
193     catch (Exception e)
194     {
195         stderr.writeln(shouldLog(LogLevel.diagnostic)
196             ? e.to!string
197             : e.msg);
198 
199         return ReturnCode.runtimeError;
200     }
201 }
202 
203 unittest
204 {
205     import std.stdio : File;
206 
207     auto _stderr = stderr;
208     stderr = File("/dev/null", "w");
209 
210     scope (exit)
211     {
212         stderr = _stderr;
213     }
214 
215     assert(run([executableName, "--help"]) == ReturnCode.ok);
216     assert(run([executableName, "--usage"]) == ReturnCode.ok);
217     assert(run([executableName, "--version"]) == ReturnCode.ok);
218     assert(run([executableName, "foobar"]) == ReturnCode.commandlineError);
219     assert(run([executableName, "--foo"]) == ReturnCode.commandlineError);
220     assert(run([executableName]) == ReturnCode.commandlineError);
221 }
222 
223 string parseCommandName(in string[] args)
224 {
225     enforce!CLIException(!args[1].startsWith("-"), format!"Missing <command> '%s'"(args[1]));
226 
227     auto candidates = only(dentistCommands).filter!(cmd => cmd.startsWith(args[1]));
228 
229     enforce!CLIException(!candidates.empty, format!"Unkown <command> '%s'"(args[1]));
230 
231     auto dashCaseCommand = candidates.front;
232 
233     candidates.popFront();
234     enforce!CLIException(candidates.empty, format!"Ambiguous <command> '%s'"(args[1]));
235 
236     return dashCaseCommand.tr("-", "_").camelCase;
237 }
238 
239 private void printBaseHelp()
240 {
241     stderr.write(usageString!BaseOptions(executableName));
242     stderr.writeln();
243     stderr.writeln(description);
244     stderr.writeln();
245     stderr.write(helpString!BaseOptions);
246 }
247 
248 private void printVersion()
249 {
250     stderr.writeln(format!"%s %s"(executableName, version_));
251     stderr.writeln();
252     stderr.write(copyright);
253     stderr.writeln();
254     stderr.write(license);
255 }
256 
257 /// The set of options common to all stages.
258 mixin template HelpOption()
259 {
260     @Option("help", "h")
261     @Help("Prints this help.")
262     OptionFlag help;
263 
264     @Option("usage")
265     @Help("Print a short command summary.")
266     void requestUsage() pure
267     {
268         enforce!UsageRequested(false, "usage requested");
269     }
270 }
271 
272 class UsageRequested : Exception
273 {
274     pure nothrow @nogc @safe this(string msg, string file = __FILE__,
275             size_t line = __LINE__, Throwable nextInChain = null)
276     {
277         super(msg, file, line, nextInChain);
278     }
279 }
280 
281 /// Options for the different commands.
282 struct OptionsFor(DentistCommand command)
283 {
284     static enum needWorkdir = command.among(
285         TestingCommand.translocateGaps,
286         TestingCommand.findClosableGaps,
287         DentistCommand.maskRepetitiveRegions,
288         DentistCommand.showMask,
289         DentistCommand.collectPileUps,
290         DentistCommand.processPileUps,
291         DentistCommand.output,
292         TestingCommand.checkResults,
293     );
294 
295 
296     static if (command.among(
297         TestingCommand.translocateGaps,
298         TestingCommand.findClosableGaps,
299         TestingCommand.checkResults,
300     ))
301     {
302         @Argument("<in:true-assembly>")
303         @Help("the 'true' assembly in .dam format")
304         @Validate!(validateDB!".dam")
305         string trueAssemblyFile;
306         @Option()
307         string trueAssemblyDb;
308 
309         @PostValidate()
310         void hookProvideTrueAssemblyFileInWorkDir()
311         {
312             trueAssemblyDb = provideDamFileInWorkdir(trueAssemblyFile, provideMethod, workdir);
313         }
314     }
315 
316     static if (command.among(
317         TestingCommand.translocateGaps,
318     ))
319     {
320         @Argument("<in:short-read-assembly>")
321         @Help("short-read assembly in .dam format")
322         @Validate!(validateDB!".dam")
323         string shortReadAssemblyFile;
324         @Option()
325         string shortReadAssemblyDb;
326 
327         @PostValidate()
328         void hookProvideShortReadAssemblyFileInWorkDir()
329         {
330             shortReadAssemblyDb = provideDamFileInWorkdir(shortReadAssemblyFile, provideMethod, workdir);
331         }
332     }
333 
334     static if (command.among(
335         DentistCommand.maskRepetitiveRegions,
336         DentistCommand.showMask,
337         DentistCommand.collectPileUps,
338         DentistCommand.processPileUps,
339         DentistCommand.output,
340     ))
341     {
342         @Argument("<in:reference>")
343         @Help("reference assembly in .dam format")
344         @Validate!(validateDB!".dam")
345         string refFile;
346         @Option()
347         string refDb;
348 
349         @PostValidate()
350         void hookProvideRefFileInWorkDir()
351         {
352             refDb = provideDamFileInWorkdir(refFile, provideMethod, workdir);
353         }
354     }
355 
356     static if (command.among(
357         DentistCommand.maskRepetitiveRegions,
358         DentistCommand.collectPileUps,
359         DentistCommand.processPileUps,
360     ))
361     {
362         @Argument("<in:reads>")
363         @Help("set of PacBio reads in .dam format")
364         @Validate!validateDB
365         string readsFile;
366         @Option()
367         string readsDb;
368 
369         @PostValidate()
370         void hookProvideReadsFileInWorkDir()
371         {
372             readsDb = provideDamFileInWorkdir(readsFile, provideMethod, workdir);
373         }
374     }
375 
376     static if (command.among(
377         TestingCommand.checkResults,
378     ))
379     {
380         @Argument("<in:result>")
381         @Help("result assembly in .dam format")
382         @Validate!(validateDB!".dam")
383         string resultFile;
384         @Option()
385         string resultDb;
386 
387         @PostValidate()
388         void hookProvideResultFileInWorkDir()
389         {
390             resultDb = provideDamFileInWorkdir(resultFile, provideMethod, workdir);
391         }
392     }
393 
394     static if (command.among(
395         TestingCommand.translocateGaps,
396     ))
397     {
398         @Argument("<in:short-vs-true-read-alignment>")
399         @Help(q"{
400             locals alignments of the short-read assembly against the 'true'
401             assembly in form of a .las file as produced by `daligner`
402         }")
403         @Validate!((value, options) => validateLasFile(value, options.trueAssemblyFile, options.shortReadAssemblyFile))
404         string shortReadAssemblyAlignmentInputFile;
405         @Option()
406         string shortReadAssemblyAlignmentFile;
407 
408         @PostValidate()
409         void hookProvideShortReadAssemblyAlignmentInWorkDir()
410         {
411             shortReadAssemblyAlignmentFile = provideLasFileInWorkdir(
412                 shortReadAssemblyAlignmentInputFile,
413                 provideMethod,
414                 workdir,
415             );
416         }
417     }
418 
419     static if (command.among(
420         DentistCommand.maskRepetitiveRegions,
421     ))
422     {
423         @Argument("<in:self-alignment>")
424         @Help(q"{
425             local alignments of the reference against itself in form of a .las
426             file as produced by `daligner`
427         }")
428         @Validate!((value, options) => validateLasFile(value, options.refFile))
429         string selfAlignmentInputFile;
430         @Option()
431         string selfAlignmentFile;
432 
433         @PostValidate()
434         void hookProvideSelfAlignmentInWorkDir()
435         {
436             selfAlignmentFile = provideLasFileInWorkdir(
437                 selfAlignmentInputFile,
438                 provideMethod,
439                 workdir,
440             );
441         }
442     }
443 
444     static if (command.among(
445         DentistCommand.maskRepetitiveRegions,
446         DentistCommand.collectPileUps,
447         DentistCommand.processPileUps,
448     ))
449     {
450         @Argument("<in:ref-vs-reads-alignment>")
451         @Help(q"{
452             alignments chains of the reads against the reference in form of a .las
453             file as produced by `damapper`
454         }")
455         @Validate!((value, options) => validateLasFile(value, options.refFile, options.readsFile))
456         string readsAlignmentInputFile;
457         @Option()
458         string readsAlignmentFile;
459 
460         @PostValidate()
461         void hookProvideReadsAlignmentInWorkDir()
462         {
463             readsAlignmentFile = provideLasFileInWorkdir(
464                 readsAlignmentInputFile,
465                 provideMethod,
466                 workdir,
467             );
468         }
469     }
470 
471     static if (command.among(
472         TestingCommand.checkResults,
473     ))
474     {
475         @Argument("<in:result-vs-true-alignment>")
476         @Help(q"{
477             alignments chains of the result assembly against the 'true'
478             assembly in form of a .las file as produced by `damapper`
479         }")
480         @Validate!((value, options) => validateLasFile(value, options.trueAssemblyFile, options.resultFile))
481         string resultsAlignmentInputFile;
482         @Option()
483         string resultsAlignmentFile;
484 
485         @PostValidate()
486         void hookProvideResultsAlignmentInWorkDir()
487         {
488             resultsAlignmentFile = provideLasFileInWorkdir(
489                 resultsAlignmentInputFile,
490                 provideMethod,
491                 workdir,
492             );
493         }
494     }
495 
496     static if (command.among(
497         DentistCommand.showPileUps,
498         DentistCommand.processPileUps,
499     ))
500     {
501         @Argument("<in:pile-ups>")
502         @Help("read pile ups from <pile-ups>")
503         @Validate!validateFileExists
504         string pileUpsFile;
505     }
506 
507     static if (command.among(
508         DentistCommand.showMask,
509         DentistCommand.collectPileUps,
510         DentistCommand.processPileUps,
511     ))
512     {
513         @Argument("<in:repeat-mask>")
514         @Help("read <repeat-mask> generated by the `maskRepetitiveRegions` command")
515         @Validate!((value, options) => validateInputMask(options.refFile, value))
516         string repeatMask;
517     }
518 
519     static if (command.among(
520         TestingCommand.findClosableGaps,
521         TestingCommand.checkResults,
522     ))
523     {
524         @Argument("<in:mapped-regions-mask>")
525         @Help(q"{
526             read regions that were kept aka. output contigs from the Dazzler
527             mask. Given a path-like string without extension: the `dirname`
528             designates the directory to write the mask to. The mask comprises
529             two hidden files `.[REFERENCE].[MASK].{anno,data}`.
530         }")
531         @Validate!((value, options) => validateInputMask(options.trueAssemblyFile, value))
532         string mappedRegionsMask;
533     }
534 
535 
536     static if (command.among(
537         TestingCommand.findClosableGaps,
538     ))
539     {
540         @Argument("<in:reads-map>")
541         @Help(q"{
542             true alignment of the reads aka. reads map; this is produced by the
543             `-M` option of the `simulator` utility of the Dazzler tools.
544         }")
545         @Validate!validateFileExists
546         string readsMap;
547     }
548 
549     static if (command.among(
550         DentistCommand.showInsertions,
551         DentistCommand.output,
552     ))
553     {
554         @Argument("<in:insertions>")
555         @Help("read insertion information from <insertions> generated by the `merge-insertions` command")
556         @Validate!validateFileExists
557         string insertionsFile;
558     }
559 
560     static if (command.among(
561         TestingCommand.translateCoords,
562     ))
563     {
564         @Argument("<in:debug-graph>")
565         @Help(q"{
566             read the assembly graph from <debug-graph> generate
567             (see `--debug-graph` of the `output` command)
568         }")
569         @Validate!validateFileExists
570         string assemblyGraphFile;
571     }
572 
573     static if (command.among(
574         TestingCommand.translateCoords,
575     ))
576     {
577         @Argument("<coord-string>", Multiplicity.oneOrMore)
578         @Help(q"{
579             translate coordinate(s) given by <coord-string> of the result into
580             coordinates on the reference. Coordinates are always 1-based.
581             A <coord-string> the format `scaffold/<uint:scaffold-id>/<uint:coord>`
582             which describes a coordinate on `>scaffold-<scaffold-id>` starting
583             a the first base pair of the scaffold
584         }")
585         @Validate!validateCoordStrings
586         string[] coordStrings;
587 
588         OutputCoordinate[] outputCoordinates;
589 
590         @PostValidate()
591         void hookParseCoordStrings()
592         {
593             outputCoordinates.length = coordStrings.length;
594             foreach (i, coordString; coordStrings)
595                 outputCoordinates[i] = parseCoordString(coordString);
596         }
597     }
598 
599     static if (command.among(
600         TestingCommand.translocateGaps,
601     ))
602     {
603         @Argument("<out:mapped-regions-mask>")
604         @Help(q"{
605             write regions that were kept aka. output contigs into a Dazzler
606             mask. Given a path-like string without extension: the `dirname`
607             designates the directory to write the mask to. The mask comprises
608             two hidden files `.[REFERENCE].[MASK].{anno,data}`.
609         }")
610         @Validate!((value, options) => validateOutputMask(options.trueAssemblyFile, value))
611         string mappedRegionsMask;
612     }
613 
614     static if (command.among(
615         DentistCommand.collectPileUps,
616     ))
617     {
618         @Argument("<out:pile-ups>")
619         @Help("write inferred pile ups into <pile-ups>")
620         @Validate!validateFileWritable
621         string pileUpsFile;
622     }
623 
624     static if (command.among(
625         DentistCommand.maskRepetitiveRegions,
626     ))
627     {
628         @Argument("<out:repeat-mask>")
629         @Help(q"{
630             write inferred repeat mask into a Dazzler mask. Given a path-like
631             string without extension: the `dirname` designates the directory to
632             write the mask to. The mask comprises two hidden files
633             `.[REFERENCE].[MASK].{anno,data}`.
634         }")
635         @Validate!((value, options) => validateOutputMask(options.refFile, value))
636         string repeatMask;
637     }
638 
639     static if (command.among(
640         DentistCommand.processPileUps,
641     ))
642     {
643         @Argument("<out:insertions>")
644         @Help("write insertion information into <insertions>")
645         @Validate!validateFileWritable
646         string insertionsFile;
647     }
648 
649     static if (command.among(
650         DentistCommand.mergeInsertions,
651     ))
652     {
653         @Argument("<out:merged-insertions>")
654         @Help("write merged insertion information to <merged-insertions>")
655         @Validate!validateFileWritable
656         string mergedInsertionsFile;
657 
658         @Argument("<in:insertions>", Multiplicity.oneOrMore)
659         @Help("merge insertion information from <insertions>... generated by the `processPileUps` command")
660         @Validate!validateFilesExist
661         @Validate!"a.length >= 2"
662         string[] insertionsFiles;
663     }
664 
665     static if (command.among(
666         TestingCommand.translocateGaps,
667         DentistCommand.output,
668     ))
669     {
670         @Argument("<out:assembly>", Multiplicity.optional)
671         @Help("write output assembly to <assembly> (default: stdout)")
672         @Validate!(value => (value is null).execUnless!(() => validateFileWritable(value)))
673         string assemblyFile;
674     }
675 
676     mixin HelpOption;
677 
678     static if (command.among(
679         DentistCommand.processPileUps,
680     ))
681     {
682         @Option("batch", "b")
683         @MetaVar("<from>..<to>")
684         @Help(q"{
685             process only a subset of the pile ups in the given range (excluding <to>);
686             <from> and <to> are zero-based indices into the pile up DB
687         }")
688         void parsePileUpBatch(string batchString) pure
689         {
690             try
691             {
692                 batchString.formattedRead!"%d..%d"(pileUpBatch[0], pileUpBatch[1]);
693             }
694             catch (Exception e)
695             {
696                 throw new CLIException("ill-formatted batch range");
697             }
698         }
699 
700         @property id_t pileUpLength() inout
701         {
702             static id_t numPileUps;
703 
704             if (numPileUps == 0)
705             {
706                 numPileUps = PileUpDb.parse(pileUpsFile).length.to!id_t;
707             }
708 
709             return numPileUps;
710         }
711 
712 
713         @Option()
714         @Validate!validateBatchRange
715         id_t[2] pileUpBatch;
716 
717         static void validateBatchRange(id_t[2] pileUpBatch, OptionsFor!command options)
718         {
719             auto from = pileUpBatch[0];
720             auto to = pileUpBatch[1];
721 
722             enforce!CLIException(
723                 pileUpBatch == pileUpBatch.init ||
724                 (0 <= from && from < to && to <= options.pileUpLength),
725                 format!"invalid batch range; check that 0 <= <from> < <to> <= %d"(
726                         options.pileUpLength)
727             );
728         }
729 
730         @PostValidate()
731         void hookEnsurePresenceOfBatchRange()
732         {
733             if (pileUpBatch == pileUpBatch.init)
734             {
735                 pileUpBatch[1] = pileUpLength;
736             }
737         }
738 
739         @property id_t pileUpBatchSize() const pure nothrow
740         {
741             return pileUpBatch[1] - pileUpBatch[0];
742         }
743     }
744 
745     static if (command.among(
746         TestingCommand.checkResults,
747     ))
748     {
749         @Option("bucket-size", "b")
750         @Help(format!q"{
751             bucket size of the gap length histogram; use 0 to disable (default: %d)
752         }"(defaultValue!bucketSize))
753         coord_t bucketSize = 500;
754     }
755 
756     static if (command.among(
757         DentistCommand.processPileUps,
758     ))
759     {
760         @Option("daccord-threads")
761         @Help("use <uint> threads for `daccord` (defaults to floor(totalCpus / <threads>) )")
762         uint numDaccordThreads;
763 
764         @PostValidate(Priority.low)
765         void hookInitDaccordThreads()
766         {
767             if (numDaccordThreads == 0)
768             {
769                 numDaccordThreads = totalCPUs / numThreads;
770             }
771         }
772     }
773 
774     static if (command.among(
775         DentistCommand.output,
776     ))
777     {
778         @Option("debug-scaffold")
779         @MetaVar("<file>")
780         @Help("write the assembly scaffold to <file>; use `show-insertions` to inspect the result")
781         @Validate!(value => (value is null).execUnless!(() => validateFileWritable(value)))
782         string assemblyGraphFile;
783     }
784 
785 
786     static if (command.among(
787         TestingCommand.checkResults,
788     ))
789     {
790         @Option("only-contig")
791         @Help("restrict analysis to contig <uint> (experimental)")
792         id_t onlyContigId;
793     }
794 
795     static if (command.among(
796         TestingCommand.checkResults,
797     ))
798     {
799         @Option("debug-alignment")
800         @MetaVar("<file>")
801         @Help("write the result alignment to a tabular file <file>")
802         @Validate!(value => (value is null).execUnless!(() => validateFileWritable(value)))
803         string alignmentTabular;
804     }
805 
806     static if (command.among(
807         TestingCommand.checkResults,
808     ))
809     {
810         @Option("debug-gap-details")
811         @MetaVar("<file>")
812         @Help("write the statistics for every single gap to a tabular file <file>")
813         @Validate!(value => (value is null).execUnless!(() => validateFileWritable(value)))
814         string gapDetailsTabular;
815     }
816 
817     static if (command.among(
818         DentistCommand.collectPileUps,
819     ))
820     {
821         @Option("best-pile-up-margin")
822         @Help(q"{
823             given a set of possibly of conflicting pile ups, if the largest
824             has <double> times more reads than the second largest it is
825             considered unique
826         }")
827         @Validate!(value => enforce!CLIException(value > 1.0, "--best-pile-up-margin must be greater than 1.0"))
828         double bestPileUpMargin = 3.0;
829     }
830 
831     static if (command.among(
832         DentistCommand.output,
833     ))
834     {
835         @Option("extend-contigs")
836         @Help("if given extend contigs even if no spanning reads can be found")
837         OptionFlag shouldExtendContigs;
838     }
839 
840     static if (command.among(
841         TestingCommand.translocateGaps,
842         DentistCommand.output,
843     ))
844     {
845         @Option("fasta-line-width", "w")
846         @Help(format!"line width for ouput FASTA (default: %d)"(defaultValue!fastaLineWidth))
847         @Validate!(value => enforce!CLIException(value > 0, "fasta line width must be greater than zero"))
848         size_t fastaLineWidth = 50;
849     }
850 
851     static if (needWorkdir)
852     {
853         @Option("input-provide-method", "p")
854         @MetaVar(format!"{%-(%s,%)}"([provideMethods]))
855         @Help(format!q"{
856             use the given method to provide the input files in the working
857             directory (default: `%s`)
858         }"(defaultValue!provideMethod))
859         ProvideMethod provideMethod = ProvideMethod.symlink;
860     }
861 
862     static if (command.among(
863         DentistCommand.output,
864     ))
865     {
866         @Option("join-policy")
867         @Help(format!q"{
868             allow only joins (gap filling) in the given mode:
869             `scaffoldGaps` (only join gaps inside of scaffolds –
870             marked by `n`s in FASTA),
871             `scaffolds` (join gaps inside of scaffolds and try to join scaffolds),
872             `contigs` (break input into contigs and re-scaffold everything;
873             maintains scaffold gaps where new scaffolds are consistent)
874             (default: `%s`)
875         }"(defaultValue!joinPolicy))
876         JoinPolicy joinPolicy = JoinPolicy.scaffoldGaps;
877     }
878 
879     static if (command.among(
880         DentistCommand.showMask,
881         DentistCommand.showPileUps,
882         DentistCommand.showInsertions,
883         TestingCommand.translateCoords,
884         TestingCommand.checkResults,
885     ))
886     {
887         @Option("json", "j")
888         @Help("if given write the information in JSON format")
889         OptionFlag useJson;
890     }
891 
892     static if (needWorkdir)
893     {
894         @Option("keep-temp", "k")
895         @Help("keep the temporary files; outputs the exact location")
896         OptionFlag keepTemp;
897     }
898 
899     static if (command.among(
900         DentistCommand.showMask,
901         DentistCommand.collectPileUps,
902         DentistCommand.processPileUps,
903     ))
904     {
905         @Option("mask", "m")
906         @MetaVar("<string>...")
907         @Help("additional masks (see <in:repeat-mask>)")
908         void addMask(string mask) pure
909         {
910             additionalMasks ~= mask;
911         }
912 
913         @Option()
914         @Validate!((values, options) => validateInputMasks(options.refFile, values))
915         string[] additionalMasks;
916     }
917 
918     static if (command.among(
919         DentistCommand.maskRepetitiveRegions,
920     ))
921     {
922         @Option("max-coverage-reads")
923         @MetaVar("<uint>")
924         @Help(q"{
925             this is used to derive a repeat mask from the ref vs. reads alignment;
926             if the alignment coverage is larger than <uint> it will be
927             considered repetitive; a default value is derived from --read-coverage;
928             both options are mutually exclusive
929         }")
930         id_t maxCoverageReads;
931 
932         @Option()
933         id_t[2] coverageBoundsReads;
934 
935         @PostValidate(Priority.medium)
936         void setCoverageBoundsReads()
937         {
938             enforce!CLIException(
939                 maxCoverageReads != maxCoverageReads.init ||
940                 readCoverage != readCoverage.init,
941                 "must provide either --read-coverage or --acceptable-coverage-reads",
942             );
943             enforce!CLIException(
944                 (maxCoverageReads != maxCoverageReads.init) ^
945                 (readCoverage != readCoverage.init),
946                 "must not provide both --read-coverage and --acceptable-coverage-reads",
947             );
948 
949             id_t upperBound(double x)
950             {
951                 enum aReads = 1.65;
952                 enum bReads = 0.1650612;
953                 enum cReads = 5.9354533;
954 
955                 return to!id_t(x / log_e(log_e(log_e(bReads * x + cReads)/log_e(aReads))));
956             }
957 
958             if (readCoverage != readCoverage.init)
959                 maxCoverageReads = upperBound(readCoverage);
960 
961             coverageBoundsReads = [0, maxCoverageReads];
962         }
963     }
964 
965     static if (command.among(
966         DentistCommand.maskRepetitiveRegions,
967     ))
968     {
969         @Option("max-coverage-self")
970         @MetaVar("<uint>")
971         @Help(format!q"{
972             this is used to derive a repeat mask from the self alignment;
973             if the alignment coverage larger than <uint> it will be
974             considered repetitive (default: %d)
975         }"(defaultValue!maxCoverageSelf))
976         @Validate!(validatePositive!("max-coverage-self", id_t))
977         id_t maxCoverageSelf = 4;
978 
979         @Option()
980         id_t[2] coverageBoundsSelf;
981 
982         @PostValidate(Priority.medium)
983         void setCoverageBoundsSelf()
984         {
985             coverageBoundsSelf = [0, maxCoverageSelf];
986         }
987     }
988 
989     static if (command.among(
990         TestingCommand.findClosableGaps,
991         DentistCommand.generateDazzlerOptions,
992         DentistCommand.collectPileUps,
993         DentistCommand.processPileUps,
994     ))
995     {
996         @Option("min-anchor-length")
997         @Help(format!q"{
998             alignment need to have at least this length of unique anchoring sequence (default: %d)
999         }"(defaultValue!minAnchorLength))
1000         @Validate!(value => enforce!CLIException(value > 0, "minimum anchor length must be greater than zero"))
1001         @Validate!(
1002             (value, options) => enforce!CLIException(
1003                 value > options.tracePointDistance,
1004                 "minimum anchor length should be greater than --trace-point-spacing"
1005             ),
1006             is(typeof(OptionsFor!command().tracePointDistance)),
1007         )
1008         size_t minAnchorLength = 500;
1009     }
1010 
1011     static if (command.among(
1012         TestingCommand.checkResults,
1013     ))
1014     {
1015         @Option("min-insertion-length")
1016         @Help(format!q"{
1017             an insertion must have at least this num ber base pairs to be
1018             considered as (partial) insertion (default: %d)
1019         }"(defaultValue!minInsertionLength))
1020         @Validate!(value => enforce!CLIException(value > 0, "minimum insertion length must be greater than zero"))
1021         size_t minInsertionLength = 50;
1022     }
1023 
1024     static if (command.among(
1025         DentistCommand.processPileUps,
1026     ))
1027     {
1028         @Option("min-extension-length")
1029         @Help(format!q"{
1030             extensions must have at least <ulong> bps of consensus to be inserted (default: %d)
1031         }"(defaultValue!minExtensionLength))
1032         @Validate!(value => enforce!CLIException(value > 0, "minimum extension length must be greater than zero"))
1033         size_t minExtensionLength = 100;
1034     }
1035 
1036     static enum defaultMinSpanningReads = 3;
1037 
1038     static if (command.among(
1039         DentistCommand.processPileUps,
1040     ))
1041     {
1042         @Option("min-reads-per-pile-up")
1043         @Help(format!q"{
1044             pile ups must have at least <ulong> reads to be processed (default: %d)
1045         }"(defaultValue!minReadsPerPileUp))
1046         @Validate!(value => enforce!CLIException(value > 0, "min reads per pile up must be greater than zero"))
1047         size_t minReadsPerPileUp = defaultMinSpanningReads;
1048     }
1049 
1050     static if (command.among(
1051         TestingCommand.findClosableGaps,
1052         DentistCommand.collectPileUps,
1053     ))
1054     {
1055         @Option("min-spanning-reads", "s")
1056         @Help(format!q"{
1057             require at least <uint> spanning reads to close a gap (default: %d)
1058         }"(defaultValue!minSpanningReads))
1059         size_t minSpanningReads = defaultMinSpanningReads;
1060     }
1061 
1062     static if (command.among(
1063         DentistCommand.maskRepetitiveRegions,
1064     ))
1065     {
1066         @Option("read-coverage", "C")
1067         @Help(q"{
1068             this is used to provide good default values for --acceptable-coverage-reads;
1069             both options are mutually exclusive
1070         }")
1071         id_t readCoverage;
1072     }
1073 
1074     static if (command.among(
1075         DentistCommand.generateDazzlerOptions,
1076         DentistCommand.collectPileUps,
1077         DentistCommand.processPileUps,
1078     ))
1079     {
1080         @Option("reads-error")
1081         @Help("estimated error rate in reads")
1082         @Validate!(value => enforce!CLIException(
1083             0.0 < value && value < 1.0,
1084             "reads error rate must be in (0, 1)"
1085         ))
1086         double readsErrorRate = .15;
1087     }
1088 
1089     static if (command.among(
1090         DentistCommand.generateDazzlerOptions,
1091         DentistCommand.collectPileUps,
1092     ))
1093     {
1094         @Option("reference-error")
1095         @Help("estimated error rate in reference")
1096         @Validate!(value => enforce!CLIException(
1097             0.0 < value && value < 1.0,
1098             "reference error rate must be in (0, 1)"
1099         ))
1100         double referenceErrorRate = .01;
1101     }
1102 
1103     static if (command.among(
1104         DentistCommand.processPileUps,
1105         TestingCommand.checkResults,
1106     ))
1107     {
1108         @Option("threads", "T")
1109         @Help("use <uint> threads (defaults to the number of cores)")
1110         uint numThreads;
1111 
1112         @PostValidate(Priority.high)
1113         void hookInitThreads()
1114         {
1115             if (numThreads > 0)
1116                 defaultPoolThreads = numThreads - 1;
1117 
1118             numThreads = defaultPoolThreads + 1;
1119         }
1120     }
1121 
1122     static if (command.among(
1123         DentistCommand.collectPileUps,
1124         DentistCommand.processPileUps,
1125         DentistCommand.output,
1126         TestingCommand.checkResults,
1127     ))
1128     {
1129         @Option("trace-point-spacing", "s")
1130         @Help("trace point spacing used for the ref vs. reads alignment")
1131         trace_point_t tracePointDistance;
1132 
1133         @PostValidate()
1134         void hookEnsurePresenceOfTracePointDistance()
1135         {
1136             if (tracePointDistance > 0)
1137                 return;
1138 
1139             tracePointDistance = getTracePointDistance();
1140         }
1141     }
1142 
1143     @Option("verbose", "v")
1144     @Help("increase output to help identify problems; use up to three times")
1145     void increaseVerbosity() pure
1146     {
1147         ++verbosity;
1148     }
1149     @Option()
1150     @Validate!(value => enforce!CLIException(
1151         0 <= value && value <= 3,
1152         "verbosity must used 0-3 times"
1153     ))
1154     size_t verbosity = 0;
1155 
1156     static if (needWorkdir)
1157     {
1158         /**
1159             Last part of the working directory name. A directory in the temp
1160             directory as returned by `std.file.tmpDir` with the naming scheme will
1161             be created to hold all data for the computation.
1162         */
1163         enum workdirTemplate = format!"dentist-%s-XXXXXX"(command);
1164 
1165         /// This is a temporary directory to store all working data.
1166         @Option("workdir", "w")
1167         @Help("use <string> as a working directory")
1168         @Validate!(value => enforce!CLIException(
1169             value is null || value.isDir,
1170             format!"workdir is not a directory: %s"(value),
1171         ))
1172         string workdir;
1173 
1174         @PostValidate(Priority.high)
1175         void hookCreateWorkDir()
1176         {
1177             if (workdir !is null)
1178                 return;
1179 
1180             auto workdirTemplate = buildPath(tempDir(), workdirTemplate);
1181 
1182             workdir = mkdtemp(workdirTemplate);
1183         }
1184 
1185         @CleanUp(Priority.low)
1186         void hookCleanWorkDir() const
1187         {
1188             if (keepTemp)
1189                 return;
1190 
1191             try
1192             {
1193                 rmdirRecurse(workdir);
1194             }
1195             catch (Exception e)
1196             {
1197                 log(LogLevel.fatal, "Fatal: " ~ e.msg);
1198             }
1199         }
1200     }
1201 
1202     @PostValidate()
1203     void hookInitLogLevel()
1204     {
1205         switch (verbosity)
1206         {
1207         case 3:
1208             setLogLevel(LogLevel.debug_);
1209             break;
1210         case 2:
1211             setLogLevel(LogLevel.diagnostic);
1212             break;
1213         case 1:
1214             setLogLevel(LogLevel.info);
1215             break;
1216         case 0:
1217         default:
1218             setLogLevel(LogLevel.error);
1219             break;
1220         }
1221     }
1222 
1223     static if (
1224         is(typeof(OptionsFor!command().minAnchorLength)) &&
1225         is(typeof(OptionsFor!command().referenceErrorRate))
1226     ) {
1227         @property string[] selfAlignmentOptions() const
1228         {
1229             return [
1230                 DalignerOptions.identity,
1231                 format!(DalignerOptions.minAlignmentLength ~ "%d")(minAnchorLength),
1232                 format!(DalignerOptions.averageCorrelationRate ~ "%f")((1 - referenceErrorRate)^^2),
1233             ];
1234         }
1235     }
1236 
1237     static if (
1238         is(typeof(OptionsFor!command().referenceErrorRate)) &&
1239         is(typeof(OptionsFor!command().readsErrorRate))
1240     ) {
1241         @property string[] refVsReadsAlignmentOptions() const
1242         {
1243             return [
1244                 DamapperOptions.symmetric,
1245                 DamapperOptions.oneDirection,
1246                 DamapperOptions.bestMatches ~ ".7",
1247                 format!(DamapperOptions.averageCorrelationRate ~ "%f")((1 - referenceErrorRate) * (1 - readsErrorRate)),
1248             ];
1249         }
1250     }
1251 
1252     static if (
1253         is(typeof(OptionsFor!command().minAnchorLength)) &&
1254         is(typeof(OptionsFor!command().readsErrorRate))
1255     ) {
1256         @property string[] pileUpAlignmentOptions() const
1257         {
1258             return [
1259                 DalignerOptions.identity,
1260                 format!(DalignerOptions.minAlignmentLength ~ "%d")(minAnchorLength),
1261                 format!(DalignerOptions.averageCorrelationRate ~ "%f")((1 - readsErrorRate)^^2),
1262             ];
1263         }
1264     }
1265 
1266     static if (isTesting)
1267     {
1268         @property string[] trueAssemblyVsResultAlignmentOptions() const
1269         {
1270             return [
1271                 DamapperOptions.symmetric,
1272                 DamapperOptions.oneDirection,
1273                 DamapperOptions.averageCorrelationRate ~ ".7",
1274             ];
1275         }
1276     }
1277 
1278     static if (command.among(
1279         DentistCommand.processPileUps,
1280     ))
1281     {
1282         static struct ConsensusOptions
1283         {
1284             string[] daccordOptions;
1285             string[] dalignerOptions;
1286             string[] dbsplitOptions;
1287             string[] lasFilterAlignmentsOptions;
1288             string workdir;
1289         }
1290 
1291         @property auto consensusOptions() const
1292         {
1293             return const(ConsensusOptions)(
1294                 // daccordOptions
1295                 [
1296                     DaccordOptions.produceFullSequences,
1297                     DaccordOptions.numberOfThreads ~ numDaccordThreads.to!string,
1298                 ],
1299                 // dalignerOptions
1300                 pileUpAlignmentOptions,
1301                 // dbsplitOptions
1302                 [],
1303                 // lasFilterAlignmentsOptions
1304                 [
1305                     LasFilterAlignmentsOptions.errorThresold ~ (2.0 * readsErrorRate).to!string,
1306                 ],
1307                 // workdir
1308                 workdir,
1309             );
1310         }
1311     }
1312 
1313     static auto defaultValue(alias property)() pure nothrow
1314     {
1315         OptionsFor!command defaultOptions;
1316 
1317         return __traits(getMember, defaultOptions, property.stringof);
1318     }
1319 }
1320 
1321 unittest
1322 {
1323     static foreach (command; EnumMembers!DentistCommand)
1324     {
1325         static assert(is(OptionsFor!command));
1326     }
1327 }
1328 
1329 /// A short summary for each command to be output underneath the usage.
1330 template commandSummary(DentistCommand command)
1331 {
1332     static if (command == TestingCommand.translocateGaps)
1333         enum commandSummary = q"{
1334             Translocate gaps from first assembly to second assembly.
1335         }".wrap;
1336     else static if (command == TestingCommand.findClosableGaps)
1337         enum commandSummary = q"{
1338             Find which gaps are closable, ie. the true alignment of the reads
1339             provides sufficient spanning reads.
1340         }".wrap;
1341     else static if (command == DentistCommand.generateDazzlerOptions)
1342         enum commandSummary = q"{
1343             Generate a set of options to pass to `daligner` and `damapper`
1344             needed for the input alignments.
1345         }".wrap;
1346     else static if (command == DentistCommand.maskRepetitiveRegions)
1347         enum commandSummary = q"{
1348             Mask regions that have a alignment coverage that is out of bounds.
1349         }".wrap;
1350     else static if (command == DentistCommand.showMask)
1351         enum commandSummary = q"{
1352             Show a short summary of the mask.
1353         }".wrap;
1354     else static if (command == DentistCommand.collectPileUps)
1355         enum commandSummary = q"{
1356             Build pile ups.
1357         }".wrap;
1358     else static if (command == DentistCommand.showPileUps)
1359         enum commandSummary = q"{
1360             Show a short summary of the pile ups.
1361         }".wrap;
1362     else static if (command == DentistCommand.processPileUps)
1363         enum commandSummary = q"{
1364             Process pile ups.
1365         }".wrap;
1366     else static if (command == DentistCommand.showInsertions)
1367         enum commandSummary = q"{
1368             Show a short summary of the insertions.
1369         }".wrap;
1370     else static if (command == DentistCommand.mergeInsertions)
1371         enum commandSummary = q"{
1372             Merge multiple insertions files into a single one.
1373         }".wrap;
1374     else static if (command == DentistCommand.output)
1375         enum commandSummary = q"{
1376             Write output.
1377         }".wrap;
1378     else static if (command == TestingCommand.translateCoords)
1379         enum commandSummary = q"{
1380             Translate coordinates of result assembly to coordinates of
1381             input assembly.
1382         }".wrap;
1383     else static if (command == TestingCommand.checkResults)
1384         enum commandSummary = q"{
1385             Check results of some gap closing procedure.
1386         }".wrap;
1387     else
1388         static assert(0, "missing commandSummary for " ~ command.to!string);
1389 }
1390 
1391 unittest
1392 {
1393     static foreach (command; EnumMembers!DentistCommand)
1394     {
1395         static assert(is(typeof(commandSummary!command)));
1396     }
1397 }
1398 
1399 /// This describes the basic, ie. non-command-specific, options of `dentist`.
1400 struct BaseOptions
1401 {
1402     mixin HelpOption;
1403 
1404     @Option("version")
1405     @Help("Print software version.")
1406     OptionFlag version_;
1407 
1408     @Argument("<command>")
1409     @Help(format!q"{
1410         Execute <command>. Available commands are: %-(%s, %). Use
1411         `dentist <command> --help` to get help for a specific command.
1412         <command> may be abbreviated by using a unique prefix of the full
1413         command string.
1414     }"([dentistCommands]))
1415     DentistCommand command;
1416 
1417     @Argument("<options...>", Multiplicity.optional)
1418     @Help("Command specific options")
1419     string commandOptions;
1420 }
1421 
1422 class CLIException : Exception
1423 {
1424     pure nothrow @nogc @safe this(string msg, string file = __FILE__,
1425             size_t line = __LINE__, Throwable nextInChain = null)
1426     {
1427         super(msg, file, line, nextInChain);
1428     }
1429 }
1430 
1431 private
1432 {
1433     ReturnCode runCommand(DentistCommand command)(in string[] args)
1434     {
1435         alias Options = OptionsFor!command;
1436         enum commandName = command.to!string.snakeCaseCT.tr("_", "-");
1437         enum usage = usageString!Options(executableName ~ " " ~ commandName);
1438 
1439         Options options;
1440 
1441         try
1442         {
1443             options = processOptions(parseArgs!Options(args[1 .. $]));
1444         }
1445         catch (ArgParseHelp e)
1446         {
1447             // Help was requested
1448             stderr.write(usage);
1449             stderr.writeln();
1450             stderr.writeln(commandSummary!command);
1451             stderr.writeln();
1452             stderr.write(helpString!Options);
1453 
1454             return ReturnCode.ok;
1455         }
1456         catch (UsageRequested e)
1457         {
1458             stderr.write(usage);
1459 
1460             return ReturnCode.ok;
1461         }
1462         catch (Exception e)
1463         {
1464             stderr.writeln("Error: " ~ (shouldLog(LogLevel.diagnostic)
1465                 ? e.to!string
1466                 : e.msg));
1467             stderr.writeln();
1468             stderr.write(usage);
1469 
1470             return ReturnCode.commandlineError;
1471         }
1472 
1473         const finalOptions = options;
1474         logInfo(finalOptions.serializeToJsonString());
1475 
1476         scope (exit) cast(void) cleanUp(finalOptions);
1477 
1478         try
1479         {
1480             mixin("import dentist.commands." ~ command.to!string ~ " : execute;");
1481             execute(finalOptions);
1482 
1483             return ReturnCode.ok;
1484         }
1485         catch (Exception e)
1486         {
1487             stderr.writeln("Error: " ~ (shouldLog(LogLevel.diagnostic)
1488                 ? e.to!string
1489                 : e.msg));
1490 
1491             return ReturnCode.runtimeError;
1492         }
1493     }
1494 
1495     enum getUDA(alias symbol, T) = getUDAs!(symbol, T)[0];
1496 
1497     struct Validate(alias _validate, bool isEnabled = true) {
1498         static if (isEnabled)
1499             alias validate = _validate;
1500         else
1501             alias validate = __truth;
1502 
1503         static bool __truth(T)(T) { return true; }
1504     }
1505 
1506     enum Priority
1507     {
1508         low,
1509         medium,
1510         high,
1511     }
1512 
1513     struct PostValidate {
1514         Priority priority;
1515     }
1516 
1517     struct CleanUp {
1518         Priority priority;
1519     }
1520 
1521     template cmpPriority(T)
1522     {
1523         enum cmpPriority(alias a, alias b) = getUDA!(a, T).priority > getUDA!(b, T).priority;
1524     }
1525 
1526     unittest
1527     {
1528         struct Tester
1529         {
1530             @PostValidate(Priority.low)
1531             void priorityLow() { }
1532 
1533             @PostValidate(Priority.medium)
1534             void priorityMedium() { }
1535 
1536             @PostValidate(Priority.high)
1537             void priorityHigh() { }
1538         }
1539 
1540         alias compare = cmpPriority!PostValidate;
1541 
1542         static assert(compare!(
1543             Tester.priorityHigh,
1544             Tester.priorityLow,
1545         ));
1546         static assert(!compare!(
1547             Tester.priorityLow,
1548             Tester.priorityHigh,
1549         ));
1550         static assert(!compare!(
1551             Tester.priorityMedium,
1552             Tester.priorityMedium,
1553         ));
1554     }
1555 
1556     Options processOptions(Options)(Options options)
1557     {
1558         static foreach (alias symbol; getSymbolsByUDA!(Options, Validate))
1559         {{
1560             alias validate = getUDAs!(symbol, Validate)[0].validate;
1561             auto value = __traits(getMember, options, symbol.stringof);
1562             alias Value = typeof(value);
1563             alias Validator = typeof(validate);
1564 
1565 
1566             static if (is(typeof(validate(value))))
1567                 cast(void) validate(value);
1568             else static if (is(typeof(validate(value, options))))
1569                 cast(void) validate(value, options);
1570             else
1571                 static assert(0, format!q"{
1572                     validator for %s.%s should have a signature of
1573                     `void (T value);` or `void (T value, Options options);` -
1574                     maybe the validator does not compile?
1575                 }"(Options.stringof, symbol.stringof).wrap(size_t.max));
1576         }}
1577 
1578         alias postValidateQueue = staticSort!(
1579             cmpPriority!PostValidate,
1580             getSymbolsByUDA!(Options, PostValidate),
1581         );
1582 
1583         static foreach (alias symbol; postValidateQueue)
1584         {
1585             mixin("options." ~ __traits(identifier, symbol) ~ "();");
1586         }
1587 
1588         return options;
1589     }
1590 
1591     unittest
1592     {
1593         import std.exception : assertThrown;
1594 
1595         struct Tester
1596         {
1597             @Validate!(value => enforce!Exception(value == 1))
1598             int a = 1;
1599 
1600             @Validate!((value, options) => enforce!Exception(value == 2 * options.a))
1601             int b = 2;
1602 
1603             string[] calls;
1604 
1605             @PostValidate(Priority.low)
1606             void priorityLow() {
1607                 calls ~= "priorityLow";
1608             }
1609 
1610             @PostValidate(Priority.medium)
1611             void priorityMedium() {
1612                 calls ~= "priorityMedium";
1613             }
1614 
1615             @PostValidate(Priority.high)
1616             void priorityHigh() {
1617                 calls ~= "priorityHigh";
1618             }
1619         }
1620 
1621         Tester options;
1622 
1623         options = processOptions(options);
1624 
1625         assert(options.calls == [
1626             "priorityHigh",
1627             "priorityMedium",
1628             "priorityLow",
1629         ]);
1630 
1631         options.a = 2;
1632 
1633         assertThrown!Exception(processOptions(options));
1634     }
1635 
1636     Options cleanUp(Options)(Options options)
1637     {
1638         alias cleanUpQueue = staticSort!(
1639             cmpPriority!CleanUp,
1640             getSymbolsByUDA!(Options, CleanUp),
1641         );
1642 
1643         static foreach (alias symbol; cleanUpQueue)
1644         {
1645             mixin("options." ~ __traits(identifier, symbol) ~ "();");
1646         }
1647 
1648         return options;
1649     }
1650 
1651     unittest
1652     {
1653         import std.exception : assertThrown;
1654 
1655         struct Tester
1656         {
1657             string[] calls;
1658 
1659             @CleanUp(Priority.low)
1660             void priorityLow() {
1661                 calls ~= "priorityLow";
1662             }
1663 
1664             @CleanUp(Priority.medium)
1665             void priorityMedium() {
1666                 calls ~= "priorityMedium";
1667             }
1668 
1669             @CleanUp(Priority.high)
1670             void priorityHigh() {
1671                 calls ~= "priorityHigh";
1672             }
1673         }
1674 
1675         Tester options;
1676 
1677         options = cleanUp(options);
1678 
1679         assert(options.calls == [
1680             "priorityHigh",
1681             "priorityMedium",
1682             "priorityLow",
1683         ]);
1684     }
1685 
1686     void parseRange(alias dest, string msg = "ill-formatted range")(in string rangeString) pure
1687             if (isStaticArray!(typeof(dest)) && dest.length == 2)
1688     {
1689         try
1690         {
1691             rangeString[].formattedRead!"%d..%d"(dest[0], dest[1]);
1692         }
1693         catch (Exception e)
1694         {
1695             throw new CLIException(msg);
1696         }
1697     }
1698 
1699     DestType parseRange(DestType, string msg = "ill-formatted range")(in string rangeString) pure
1700             if (isStaticArray!DestType && DestType.init.length == 2)
1701     {
1702         try
1703         {
1704             DestType dest;
1705 
1706             rangeString[].formattedRead!"%d..%d"(dest[0], dest[1]);
1707 
1708             return dest;
1709         }
1710         catch (Exception e)
1711         {
1712             throw new CLIException(msg);
1713         }
1714     }
1715 
1716     void validatePositive(string option, V)(V value)
1717     {
1718         enforce!CLIException(
1719             0 < value,
1720             option ~ " must be greater than zero",
1721         );
1722     }
1723 
1724     void validateCoverageBounds(DestType, string option)(in string coverageBoundsString)
1725     {
1726         auto coverageBounds = parseRange!DestType(coverageBoundsString);
1727         auto from = coverageBounds[0];
1728         auto to = coverageBounds[1];
1729 
1730         enforce!CLIException(
1731             coverageBounds == coverageBounds.init || 0 <= from && from < to,
1732             "invalid coverage bounds (--" ~ option ~ "); check that 0 <= <from> < <to>"
1733         );
1734     }
1735 
1736     void validateFilesExist(string msg = null)(in string[] files)
1737     {
1738         foreach (file; files)
1739         {
1740             static if (msg is null)
1741                 validateFileExists(file);
1742             else
1743                 validateFileExists!msg(file);
1744         }
1745     }
1746 
1747     void validateFileExists(string msg = "cannot open file `%s`")(in string file)
1748     {
1749         enforce!CLIException(file.exists, format!msg(file));
1750     }
1751 
1752     import std.meta : allSatisfy, staticMap;
1753     import std.traits : isSomeString;
1754 
1755     alias typeOf(alias T) = typeof(T);
1756 
1757     void validateFileExtension(
1758         string msg = "expected %-(%s, %) but got %s",
1759         extensions...
1760     )(in string file)
1761             if (allSatisfy!(isSomeString, staticMap!(typeOf, extensions)))
1762     {
1763         enum defaultMsg = "expected %-(%s, %) but got %s";
1764 
1765         enforce!CLIException(
1766             file.endsWith(extensions),
1767             format!(msg !is null ? msg : defaultMsg)([extensions], file)
1768         );
1769     }
1770 
1771     void validateDB(string extension = null)(in string dbFile)
1772         if (extension is null || extension.among(".dam", ".db"))
1773     {
1774         static if (extension is null)
1775             enum extensions = AliasSeq!(".dam", ".db");
1776         else
1777             enum extensions = AliasSeq!(extension);
1778 
1779         validateFileExtension!(null, extensions)(dbFile);
1780         validateFileExists(dbFile);
1781 
1782         foreach (hiddenDbFile; getHiddenDbFiles(dbFile))
1783         {
1784             validateFileExists!"cannot open hidden database file `%s`"(hiddenDbFile);
1785         }
1786     }
1787 
1788     void validateLasFile(in string lasFile, in string dbA, in string dbB=null)
1789     {
1790         auto cwd = getcwd.absolutePath;
1791 
1792         enforce!CLIException(
1793             lasFile.endsWith(".las"),
1794             format!"expected .las file, got `%s`"(lasFile),
1795         );
1796         validateFileExists(lasFile);
1797         enforce!CLIException(
1798             !lasEmpty(lasFile, dbA, dbB, cwd),
1799             format!"empty alignment file `%s`"(lasFile),
1800         );
1801     }
1802 
1803     void validateInputMasks(in string dbFile, in string[] maskDestinations)
1804     {
1805         foreach (maskDestination; maskDestinations)
1806             validateInputMask(dbFile, maskDestination);
1807     }
1808 
1809     void validateInputMask(in string dbFile, in string maskDestination)
1810     {
1811         foreach (maskFile; getMaskFiles(dbFile, maskDestination))
1812         {
1813             validateFileExists!"cannot open hidden mask file `%s`"(maskFile);
1814         }
1815     }
1816 
1817     void validateOutputMask(in string dbFile, in string maskDestination)
1818     {
1819         foreach (maskFile; getMaskFiles(dbFile, maskDestination))
1820         {
1821             validateFileWritable!"cannot write hidden mask file `%s`: %s"(maskFile);
1822         }
1823     }
1824 
1825     void validateFileWritable(string msg = "cannot open file `%s` for writing: %s")(string fileName)
1826     {
1827         auto deleteAfterwards = !fileName.exists;
1828 
1829         try
1830         {
1831             cast(void) File(fileName, "a");
1832         }
1833         catch (ErrnoException e)
1834         {
1835             throw new CLIException(format!msg(fileName, e.msg));
1836         }
1837 
1838         if (deleteAfterwards)
1839         {
1840             try
1841             {
1842                 remove(fileName);
1843             }
1844             catch (FileException e)
1845             {
1846                 logJsonWarn(
1847                     "info", "failed to delete file after testing",
1848                     "error", e.toString(),
1849                     "file", fileName,
1850                 );
1851             }
1852         }
1853     }
1854 
1855     void validateCoordStrings(string[] coordStrings)
1856     {
1857         foreach (coordString; coordStrings)
1858             cast(void) parseCoordString(coordString);
1859     }
1860 
1861     OutputCoordinate parseCoordString(string coordString)
1862     {
1863         enum coordRegex = ctRegex!`^(scaffold/(?P<scaffoldId>\d+)/)?(contig/(?P<contigId>\d+)/)?(?P<coord>\d+)$`;
1864         OutputCoordinate coord;
1865 
1866         auto matches = coordString.matchFirst(coordRegex);
1867 
1868         enforce!CLIException(cast(bool) matches, "ill-formatted coord-string");
1869 
1870         coord.coord = matches["coord"].to!(typeof(coord.coord));
1871         enforce!CLIException(coord.coord > 0, "<coord> is 1-based");
1872 
1873         if (matches["contigId"] != "")
1874         {
1875             coord.contigId = matches["contigId"].to!(typeof(coord.contigId));
1876             enforce!CLIException(coord.contigId > 0, "<contig-id> is 1-based");
1877         }
1878 
1879         if (matches["scaffoldId"] != "")
1880         {
1881             coord.scaffoldId = matches["scaffoldId"].to!(typeof(coord.scaffoldId));
1882             enforce!CLIException(coord.scaffoldId > 0, "<scaffold-id> is 1-based");
1883         }
1884 
1885         version (unittest) { } else
1886             enforce!CLIException(
1887                 coord.originType == OutputCoordinate.OriginType.scaffold,
1888                 "not yet implemented; use format `scaffold/<uint:scaffold-id>/<uint:coord>`",
1889             );
1890 
1891         return coord;
1892     }
1893 
1894     unittest
1895     {
1896         import std.exception : assertThrown;
1897 
1898         {
1899             auto coord = parseCoordString(`scaffold/1125/42`);
1900             assert(coord.scaffoldId == 1125);
1901             assert(coord.contigId == 0);
1902             assert(coord.coord == 42);
1903             assert(coord.idx == 41);
1904             assert(coord.originType == OutputCoordinate.OriginType.scaffold);
1905         }
1906         {
1907             auto coord = parseCoordString(`scaffold/1125/contig/13/42`);
1908             assert(coord.scaffoldId == 1125);
1909             assert(coord.contigId == 13);
1910             assert(coord.coord == 42);
1911             assert(coord.idx == 41);
1912             assert(coord.originType == OutputCoordinate.OriginType.scaffoldContig);
1913         }
1914         {
1915             auto coord = parseCoordString(`contig/7/42`);
1916             assert(coord.scaffoldId == 0);
1917             assert(coord.contigId == 7);
1918             assert(coord.coord == 42);
1919             assert(coord.idx == 41);
1920             assert(coord.originType == OutputCoordinate.OriginType.contig);
1921         }
1922         {
1923             auto coord = parseCoordString(`42`);
1924             assert(coord.scaffoldId == 0);
1925             assert(coord.contigId == 0);
1926             assert(coord.coord == 42);
1927             assert(coord.idx == 41);
1928             assert(coord.originType == OutputCoordinate.OriginType.global);
1929         }
1930         {
1931             assertThrown!CLIException(parseCoordString(`scaffold/0/42`));
1932             assertThrown!CLIException(parseCoordString(`scaffold/1125/0`));
1933             assertThrown!CLIException(parseCoordString(`scaffold/0/contig/1/42`));
1934             assertThrown!CLIException(parseCoordString(`scaffold/1125/contig/0/42`));
1935             assertThrown!CLIException(parseCoordString(`scaffold/1125/contig/1/0`));
1936             assertThrown!CLIException(parseCoordString(`contig/0/42`));
1937             assertThrown!CLIException(parseCoordString(`contig/7/0`));
1938             assertThrown!CLIException(parseCoordString(`0`));
1939         }
1940     }
1941 
1942     void execIf(alias fun)(bool test)
1943     {
1944         if (test)
1945             fun();
1946     }
1947 
1948     void execUnless(alias fun)(bool test)
1949     {
1950         if (!test)
1951             fun();
1952     }
1953 }