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 }