lib/App/GHGen/Interactive.pm

Structural Coverage (Approximate)

TER1 (Statement): 100.00%
TER2 (Branch): 96.67%
TER3 (LCSAJ): 100.0% (5/5)
Approximate LCSAJ segments: 31

LCSAJ Legend

โ— Covered โ€” this LCSAJ path was executed during testing.

โ— Not covered โ€” this LCSAJ path was never executed. These are the paths to focus on.

Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:

        start โ†’ end โ†’ jump
        

Uncovered paths show [NOT COVERED] in the tooltip.

Mutant Testing Legend

Survived (tests missed this) Killed (tests detected this) No mutation
    1: package App::GHGen::Interactive;
    2: 
    3: use v5.36;
    4: use strict;
    5: use warnings;
    6: use Term::ANSIColor qw(colored);
    7: 
    8: use Exporter 'import';
    9: our @EXPORT_OK = qw(
   10: 	prompt_yes_no
   11: 	prompt_choice
   12: 	prompt_multiselect
   13: 	prompt_text
   14: 	customize_workflow
   15: );
   16: 
   17: our $VERSION = '0.10';
   18: 
   19: =head1 NAME
   20: 
   21: App::GHGen::Interactive - Interactive workflow customization
   22: 
   23: =head1 SYNOPSIS
   24: 
   25:     use App::GHGen::Interactive qw(customize_workflow);
   26: 
   27:     my $config = customize_workflow('perl');
   28:     # Returns hash of user choices
   29: 
   30: =encoding utf-8
   31: 
   32: =head1 FUNCTIONS
   33: 
   34: =head2 prompt_yes_no($question, $default)
   35: 
   36: Prompt the user for a yes/no answer and return a boolean.
   37: 
   38: =head3 Purpose
   39: 
   40: Print C<$question> followed by a bracket hint (C<[Y/n]> or C<[y/N]>),
   41: read one line from STDIN, and return C<1> for yes or C<0> for no.  An
   42: empty response uses C<$default>.
   43: 
   44: =head3 Arguments
   45: 
   46: =over 4
   47: 
   48: =item C<$question> (Str, required)
   49: 
   50: The question text to display.
   51: 
   52: =item C<$default> (Str, optional, default C<'y'>)
   53: 
   54: The default answer when the user presses Enter without typing.
   55: Must be C<'y'> or C<'n'>.
   56: 
   57: =back
   58: 
   59: =head3 Returns
   60: 
   61: C<1> when the answer is affirmative (C<y> or C<yes>, case-insensitive) or
   62: when the empty response maps to a C<'y'> default.
   63: 
   64: C<0> when the answer is negative (C<n> or C<no>, case-insensitive) or
   65: when the empty response maps to an C<'n'> default.
   66: 
   67: =head3 Side Effects
   68: 
   69: Reads one line from STDIN; prints to STDOUT.
   70: 
   71: =head3 Usage Example
   72: 
   73:     my $ok = prompt_yes_no("Enable coverage?", 'y');
   74: 
   75: =head3 API SPECIFICATION
   76: 
   77: =head4 Input
   78: 
   79:     {
   80:         question => { type => 'scalar', required => 1 },
   81:         default  => { type => 'scalar', default  => 'y' },
   82:     }
   83: 
   84: =head4 Output
   85: 
   86:     { type => 'scalar' }   # 1 or 0
   87: 
   88: =head3 FORMAL SPECIFICATION
   89: 
   90:     prompt_yes_no : ℤ* × {'y','n'} → 𝔹
   91: 
   92:     answer ≔ chomp(readline(STDIN))
   93:     result ≔
   94:         answer =~ /^y(?:es)?$/i → 1
   95:         answer =~ /^n(?:o)?$/i  → 0
   96:         answer = ""           → default = 'y' → 1  |  default = 'n' → 0
   97: 
   98: =cut
   99: 
  100: sub prompt_yes_no($question, $default = 'y') {
  101: 	my $prompt = $default eq 'y' ? '[Y/n]' : '[y/N]';
  102: 	print "$question $prompt: ";
  103: 	chomp(my $answer = <STDIN>);
  104: 
  105: 	return 1 if $answer =~ /^y(?:es)?$/i;

Mutants (Total: 2, Killed: 2, Survived: 0)

106: return 0 if $answer =~ /^n(?:o)?$/i;

Mutants (Total: 2, Killed: 2, Survived: 0)

107: return $default eq 'y' ? 1 : 0;

Mutants (Total: 2, Killed: 2, Survived: 0)

108: } 109: 110: =head2 prompt_choice($question, $choices, $default) 111: 112: Prompt the user to select one item from a numbered list. 113: 114: =head3 Purpose 115: 116: Display C<$question> followed by a numbered list of C<$choices>, read one 117: line from STDIN, and return the zero-based index of the selected item. 118: 119: =head3 Arguments 120: 121: =over 4 122: 123: =item C<$question> (Str, required) 124: 125: The selection prompt text. 126: 127: =item C<$choices> (ArrayRef[Str], required) 128: 129: The available options, displayed numbered from 1. 130: 131: =item C<$default> (Int, optional, default C<0>) 132: 133: Zero-based index of the pre-selected option shown in the prompt. 134: 135: =back 136: 137: =head3 Returns 138: 139: A zero-based integer index. Returns C<$default> when the user presses Enter 140: without input or when the input is out of range (less than 1 or greater than 141: the number of choices). 142: 143: =head3 Side Effects 144: 145: Reads one line from STDIN; prints to STDOUT. 146: 147: =head3 Usage Example 148: 149: my $idx = prompt_choice("Package manager?", ['npm','yarn','pnpm'], 0); 150: 151: =head3 API SPECIFICATION 152: 153: =head4 Input 154: 155: { 156: question => { type => 'scalar', required => 1 }, 157: choices => { type => 'arrayref', required => 1 }, 158: default => { type => 'scalar', default => 0 }, 159: } 160: 161: =head4 Output 162: 163: { type => 'scalar' } # integer 0 .. |choices|-1 164: 165: =head3 FORMAL SPECIFICATION 166: 167: prompt_choice : ℤ* × seq ℤ* × â„• → â„• 168: 169: answer ≔ chomp(readline(STDIN)) 170: result ≔ 171: answer = "" → default 172: answer ∈ â„• ∧ 1 ≤ answer ≤ |choices| → answer − 1 173: otherwise → default 174: 175: =cut 176: 177: sub prompt_choice($question, $choices, $default = 0) { โ—178 โ†’ 179 โ†’ 184 178: say $question; 179: for my $i (0 .. $#$choices) { 180: my $marker = $i == $default ? colored(['green'], '→') : ' ';

Mutants (Total: 1, Killed: 0, Survived: 1)
181: say " $marker " . ($i + 1) . ". $choices->[$i]"; 182: } 183: 184: print "\nEnter number [" . ($default + 1) . "]: "; 185: chomp(my $answer = <STDIN>); 186: 187: return $default if $answer eq '';

Mutants (Total: 2, Killed: 2, Survived: 0)

188: return $answer - 1 if $answer =~ /^\d+$/ && $answer >= 1 && $answer <= @$choices;

Mutants (Total: 8, Killed: 2, Survived: 6)
189: return $default;

Mutants (Total: 2, Killed: 2, Survived: 0)

190: } 191: 192: =head2 prompt_multiselect($question, $options, $defaults) 193: 194: Prompt the user to select zero or more items from a numbered list. 195: 196: =head3 Purpose 197: 198: Display C<$question> and a numbered list of C<$options>, accept 199: comma-separated numbers or the keyword C<all>, and return an array reference 200: of the selected option strings. 201: 202: =head3 Arguments 203: 204: =over 4 205: 206: =item C<$question> (Str, required) 207: 208: The multi-select prompt text. 209: 210: =item C<$options> (ArrayRef[Str], required) 211: 212: All available options, displayed numbered from 1. 213: 214: =item C<$defaults> (ArrayRef[Str], optional, default C<[]>) 215: 216: The pre-selected options (by value, not index). 217: 218: =back 219: 220: =head3 Returns 221: 222: An array reference of selected option strings. Possible values: 223: 224: =over 4 225: 226: =item * 227: 228: The full C<$options> list when the user types C<all>. 229: 230: =item * 231: 232: A subset derived from the comma/space-separated numbers the user entered. 233: 234: =item * 235: 236: C<$defaults> when the user presses Enter without typing. 237: 238: =back 239: 240: =head3 Side Effects 241: 242: Reads one line from STDIN; prints to STDOUT. 243: 244: =head3 Usage Example 245: 246: my $sel = prompt_multiselect("OS?", ['ubuntu-latest','macos-latest','windows-latest'], []); 247: 248: =head3 API SPECIFICATION 249: 250: =head4 Input 251: 252: { 253: question => { type => 'scalar', required => 1 }, 254: options => { type => 'arrayref', required => 1 }, 255: defaults => { type => 'arrayref', default => [] }, 256: } 257: 258: =head4 Output 259: 260: { type => 'arrayref' } 261: 262: =head3 FORMAL SPECIFICATION 263: 264: prompt_multiselect : ℤ* × seq ℤ* × seq ℤ* → seq ℤ* 265: 266: answer ≔ chomp(readline(STDIN)) 267: result ≔ 268: answer = "" → defaults 269: answer =~ /^all$/i → options 270: otherwise → [ options[n−1] ∣ n ∈ split(/[,\s]+/, answer), 1 ≤ n ≤ |options| ] 271: ?? defaults (when result = ∅) 272: 273: =cut 274: 275: sub prompt_multiselect($question, $options, $defaults = []) { โ—276 โ†’ 281 โ†’ 286 276: say $question; 277: say colored(['cyan'], "(Enter numbers separated by commas, or 'all')"); 278: 279: my %is_default = map { $_ => 1 } @$defaults; 280: 281: for my $i (0 .. $#$options) { 282: my $marker = $is_default{$options->[$i]} ? colored(['green'], '✓') : ' '; 283: say " $marker " . ($i + 1) . ". $options->[$i]"; 284: } 285: โ—286 โ†’ 291 โ†’ 295 286: print "\nEnter choices [" . join(',', map { $_+1 } 0..$#$defaults) . "]: "; 287: chomp(my $answer = <STDIN>); 288: 289: return $defaults if $answer eq '';

Mutants (Total: 2, Killed: 2, Survived: 0)

290: 291: if ($answer =~ /^all$/i) {

Mutants (Total: 1, Killed: 1, Survived: 0)

292: return $options;

Mutants (Total: 2, Killed: 2, Survived: 0)

293: } 294: โ—295 โ†’ 296 โ†’ 302 295: my @selected; 296: for my $num (split /[,\s]+/, $answer) { 297: if ($num =~ /^\d+$/ && $num >= 1 && $num <= @$options) {

Mutants (Total: 7, Killed: 7, Survived: 0)

298: push @selected, $options->[$num - 1]; 299: } 300: } 301: 302: return @selected ? \@selected : $defaults;

Mutants (Total: 2, Killed: 2, Survived: 0)

303: } 304: 305: =head2 prompt_text($question, $default) 306: 307: Prompt the user for a free-form text answer. 308: 309: =head3 Purpose 310: 311: Display C<$question> optionally followed by C<[$default]>, read one line 312: from STDIN, and return the user's answer or C<$default> when empty. 313: 314: =head3 Arguments 315: 316: =over 4 317: 318: =item C<$question> (Str, required) 319: 320: The prompt text. 321: 322: =item C<$default> (Str, optional, default C<''>) 323: 324: Returned as-is when the user presses Enter without typing anything. 325: 326: =back 327: 328: =head3 Returns 329: 330: The trimmed line the user typed, or C<$default> when the input is empty. 331: 332: =head3 Side Effects 333: 334: Reads one line from STDIN; prints to STDOUT. 335: 336: =head3 Usage Example 337: 338: my $name = prompt_text("Project name", 'my-project'); 339: 340: =head3 API SPECIFICATION 341: 342: =head4 Input 343: 344: { 345: question => { type => 'scalar', required => 1 }, 346: default => { type => 'scalar', default => '' }, 347: } 348: 349: =head4 Output 350: 351: { type => 'scalar' } 352: 353: =head3 FORMAL SPECIFICATION 354: 355: prompt_text : ℤ* × ℤ* → ℤ* 356: 357: answer ≔ chomp(readline(STDIN)) 358: result ≔ answer = "" → default | otherwise → answer 359: 360: =cut 361: 362: sub prompt_text($question, $default = '') { 363: my $prompt = $default ? "[$default]" : ''; 364: print "$question $prompt: "; 365: chomp(my $answer = <STDIN>); 366: 367: return $answer eq '' ? $default : $answer;

Mutants (Total: 2, Killed: 2, Survived: 0)

368: } 369: 370: =head2 customize_workflow($type) 371: 372: Drive an interactive customization session for the given workflow type. 373: 374: =head3 Purpose 375: 376: Display a series of prompts relevant to C<$type> and collect user preferences. 377: Dispatches to a private C<_customize_*> helper; returns an empty hash when 378: the type is not supported. 379: 380: =head3 Arguments 381: 382: =over 4 383: 384: =item C<$type> (Str, required) 385: 386: The workflow type to customise. Supported: C<perl>, C<node>, C<python>, 387: C<rust>, C<go>, C<ruby>, C<docker>, C<static>. 388: 389: =back 390: 391: =head3 Returns 392: 393: A hash reference of configuration key/value pairs collected from the user. 394: Returns an empty hash reference (C<{}>) when C<$type> is not recognised. 395: 396: =head3 Side Effects 397: 398: Reads multiple lines from STDIN; prints to STDOUT. 399: 400: =head3 Usage Example 401: 402: my $config = customize_workflow('perl'); 403: # $config->{enable_critic}, $config->{perl_versions}, etc. 404: 405: =head3 API SPECIFICATION 406: 407: =head4 Input 408: 409: { type => { type => 'scalar', required => 1 } } 410: 411: =head4 Output 412: 413: { type => 'hashref' } # empty or populated with type-specific keys 414: 415: =head3 FORMAL SPECIFICATION 416: 417: SupportedCustomTypes ≔ { perl, node, python, rust, go, ruby, docker, static } 418: 419: customize_workflow : ℤ* → Config 420: 421: t ∈ SupportedCustomTypes → _customize_t() 422: t ∉ SupportedCustomTypes → {} 423: 424: =cut 425: 426: sub customize_workflow($type) { โ—427 โ†’ 442 โ†’ 446 427: say ''; 428: say colored(['bold cyan'], "=== Workflow Customization: " . uc($type) . " ==="); 429: say ''; 430: 431: my %dispatch = ( 432: perl => \&_customize_perl, 433: node => \&_customize_node, 434: python => \&_customize_python, 435: rust => \&_customize_rust, 436: go => \&_customize_go, 437: ruby => \&_customize_ruby, 438: docker => \&_customize_docker, 439: static => \&_customize_static, 440: ); 441: 442: if (exists $dispatch{$type}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

443: return $dispatch{$type}->();

Mutants (Total: 2, Killed: 2, Survived: 0)

444: } 445: 446: return {}; 447: } 448: 449: sub _customize_perl() { 450: my %config; 451: 452: # Perl versions 453: say colored(['bold'], "Perl Versions to Test:"); 454: my @all_versions = qw(5.40 5.38 5.36 5.34 5.32 5.30 5.28 5.26 5.24 5.22); 455: my @default_versions = qw(5.40 5.38 5.36); 456: 457: $config{perl_versions} = prompt_multiselect( 458: "Which Perl versions?", 459: \@all_versions, 460: \@default_versions 461: ); 462: say ''; 463: 464: # Operating systems 465: say colored(['bold'], "Operating Systems:"); 466: my @all_os = ('ubuntu-latest', 'macos-latest', 'windows-latest'); 467: my @default_os = @all_os; 468: 469: $config{os} = prompt_multiselect( 470: "Which operating systems?", 471: \@all_os, 472: \@default_os 473: ); 474: say ''; 475: 476: # Code quality 477: say colored(['bold'], "Code Quality Tools:"); 478: $config{enable_linter} = prompt_yes_no( 479: "Enable syntax linting (perl -c on all matrix cells)?", 480: 'y' 481: ); 482: say ''; 483: 484: $config{enable_linter_unused} = prompt_yes_no( 485: "Enable unused-variable check (warnings::unused, latest+ubuntu only)?", 486: 'n' 487: ); 488: say ''; 489: 490: $config{enable_critic} = prompt_yes_no( 491: "Enable Perl::Critic?", 492: 'y' 493: ); 494: say ''; 495: 496: # Coverage 497: $config{enable_coverage} = prompt_yes_no( 498: "Enable test coverage (Devel::Cover)?", 499: 'y' 500: ); 501: say ''; 502: 503: # Branches 504: say colored(['bold'], "Branch Configuration:"); 505: my $branches = prompt_text( 506: "Branches to run on (comma-separated)", 507: 'main,master' 508: ); 509: $config{branches} = [split /,\s*/, $branches]; 510: say ''; 511: 512: return \%config;

Mutants (Total: 2, Killed: 2, Survived: 0)

513: } 514: 515: sub _customize_node() { 516: my %config; 517: 518: # Node versions 519: say colored(['bold'], "Node.js Versions to Test:"); 520: my @all_versions = qw(18.x 20.x 22.x 23.x); 521: my @default_versions = qw(20.x 22.x); 522: 523: $config{node_versions} = prompt_multiselect( 524: "Which Node.js versions?", 525: \@all_versions, 526: \@default_versions 527: ); 528: say ''; 529: 530: # Package manager 531: say colored(['bold'], "Package Manager:"); 532: my $pm_choice = prompt_choice( 533: "Which package manager?", 534: ['npm', 'yarn', 'pnpm'], 535: 0 536: ); 537: $config{package_manager} = ['npm', 'yarn', 'pnpm']->[$pm_choice]; 538: say ''; 539: 540: # Linting 541: $config{enable_lint} = prompt_yes_no( 542: "Enable linting?", 543: 'y' 544: ); 545: say ''; 546: 547: # Build step 548: $config{enable_build} = prompt_yes_no( 549: "Enable build step?", 550: 'y' 551: ); 552: say ''; 553: 554: # Branches 555: my $branches = prompt_text( 556: "Branches to run on (comma-separated)", 557: 'main,develop' 558: ); 559: $config{branches} = [split /,\s*/, $branches]; 560: say ''; 561: 562: return \%config;

Mutants (Total: 2, Killed: 2, Survived: 0)

563: } 564: 565: sub _customize_python() { 566: my %config; 567: 568: # Python versions 569: say colored(['bold'], "Python Versions to Test:"); 570: my @all_versions = qw(3.9 3.10 3.11 3.12 3.13); 571: my @default_versions = qw(3.11 3.12); 572: 573: $config{python_versions} = prompt_multiselect( 574: "Which Python versions?", 575: \@all_versions, 576: \@default_versions 577: ); 578: say ''; 579: 580: # Linting 581: say colored(['bold'], "Code Quality:"); 582: $config{enable_flake8} = prompt_yes_no( 583: "Enable flake8 linting?", 584: 'y' 585: ); 586: say ''; 587: 588: $config{enable_black} = prompt_yes_no( 589: "Enable black formatter check?", 590: 'n' 591: ); 592: say ''; 593: 594: # Coverage 595: $config{enable_coverage} = prompt_yes_no( 596: "Enable test coverage?", 597: 'y' 598: ); 599: say ''; 600: 601: # Branches 602: my $branches = prompt_text( 603: "Branches to run on (comma-separated)", 604: 'main,develop' 605: ); 606: $config{branches} = [split /,\s*/, $branches]; 607: say ''; 608: 609: return \%config;

Mutants (Total: 2, Killed: 2, Survived: 0)

610: } 611: 612: sub _customize_rust() { 613: my %config; 614: 615: say colored(['bold'], "Rust Workflow Options:"); 616: 617: $config{enable_fmt} = prompt_yes_no( 618: "Enable formatting check (cargo fmt)?", 619: 'y' 620: ); 621: say ''; 622: 623: $config{enable_clippy} = prompt_yes_no( 624: "Enable clippy linting?", 625: 'y' 626: ); 627: say ''; 628: 629: $config{enable_release} = prompt_yes_no( 630: "Build release binary?", 631: 'y' 632: ); 633: say ''; 634: 635: my $branches = prompt_text( 636: "Branches to run on (comma-separated)", 637: 'main' 638: ); 639: $config{branches} = [split /,\s*/, $branches]; 640: say ''; 641: 642: return \%config;

Mutants (Total: 2, Killed: 2, Survived: 0)

643: } 644: 645: sub _customize_go() { 646: my %config; 647: 648: say colored(['bold'], "Go Workflow Options:"); 649: 650: my $go_version = prompt_text( 651: "Go version", 652: '1.22' 653: ); 654: $config{go_version} = $go_version; 655: say ''; 656: 657: $config{enable_vet} = prompt_yes_no( 658: "Enable go vet?", 659: 'y' 660: ); 661: say ''; 662: 663: $config{enable_race} = prompt_yes_no( 664: "Enable race detector?", 665: 'y' 666: ); 667: say ''; 668: 669: $config{enable_coverage} = prompt_yes_no( 670: "Enable test coverage?", 671: 'y' 672: ); 673: say ''; 674: 675: my $branches = prompt_text( 676: "Branches to run on (comma-separated)", 677: 'main' 678: ); 679: $config{branches} = [split /,\s*/, $branches]; 680: say ''; 681: 682: return \%config;

Mutants (Total: 2, Killed: 2, Survived: 0)

683: } 684: 685: sub _customize_ruby() { 686: my %config; 687: 688: say colored(['bold'], "Ruby Versions to Test:"); 689: my @all_versions = qw(3.1 3.2 3.3); 690: my @default_versions = qw(3.2 3.3); 691: 692: $config{ruby_versions} = prompt_multiselect( 693: "Which Ruby versions?", 694: \@all_versions, 695: \@default_versions 696: ); 697: say ''; 698: 699: my $branches = prompt_text( 700: "Branches to run on (comma-separated)", 701: 'main' 702: ); 703: $config{branches} = [split /,\s*/, $branches]; 704: say ''; 705: 706: return \%config;

Mutants (Total: 2, Killed: 2, Survived: 0)

707: } 708: 709: sub _customize_docker() { 710: my %config; 711: 712: say colored(['bold'], "Docker Workflow Options:"); 713: 714: my $image_name = prompt_text( 715: "Docker image name (user/image)", 716: 'your-username/your-image' 717: ); 718: $config{image_name} = $image_name; 719: say ''; 720: 721: $config{push_on_pr} = prompt_yes_no( 722: 'Push images on pull requests?', 723: 'n' 724: ); 725: say ''; 726: 727: my $branches = prompt_text( 728: "Branches to run on (comma-separated)", 729: 'main' 730: ); 731: $config{branches} = [split /,\s*/, $branches]; 732: say ''; 733: 734: return \%config;

Mutants (Total: 2, Killed: 2, Survived: 0)

735: } 736: 737: sub _customize_static() { 738: my %config; 739: 740: say colored(['bold'], "Static Site Deployment:"); 741: 742: my $build_dir = prompt_text('Build output directory', './public'); 743: $config{build_dir} = $build_dir; 744: say ''; 745: 746: my $build_command = prompt_text("Build command", 'npm run build'); 747: $config{build_command} = $build_command; 748: say ''; 749: 750: return \%config;

Mutants (Total: 2, Killed: 2, Survived: 0)

751: } 752: 753: =head1 AUTHOR 754: 755: Nigel Horne E<lt>njh@nigelhorne.comE<gt> 756: 757: L<https://github.com/nigelhorne> 758: 759: =head1 COPYRIGHT AND LICENSE 760: 761: Copyright 2025-2026 Nigel Horne. 762: 763: Usage is subject to the GPL2 licence terms. 764: If you use it, 765: please let me know. 766: 767: =cut 768: 769: 1;