File Coverage

File:blib/lib/App/GHGen/Interactive.pm
Coverage:98.2%

linestmtbrancondsubtimecode
1package App::GHGen::Interactive;
2
3
4
4
541
5
use v5.36;
4
4
4
4
5
3
25
use strict;
5
4
4
4
4
3
61
use warnings;
6
4
4
4
794
12965
2199
use Term::ANSIColor qw(colored);
7
8
4
4
4
13
6
3044
use Exporter 'import';
9our @EXPORT_OK = qw(
10        prompt_yes_no
11        prompt_choice
12        prompt_multiselect
13        prompt_text
14        customize_workflow
15);
16
17our $VERSION = '0.10';
18
19 - 98
=head1 NAME

App::GHGen::Interactive - Interactive workflow customization

=head1 SYNOPSIS

    use App::GHGen::Interactive qw(customize_workflow);

    my $config = customize_workflow('perl');
    # Returns hash of user choices

=encoding utf-8

=head1 FUNCTIONS

=head2 prompt_yes_no($question, $default)

Prompt the user for a yes/no answer and return a boolean.

=head3 Purpose

Print C<$question> followed by a bracket hint (C<[Y/n]> or C<[y/N]>),
read one line from STDIN, and return C<1> for yes or C<0> for no.  An
empty response uses C<$default>.

=head3 Arguments

=over 4

=item C<$question> (Str, required)

The question text to display.

=item C<$default> (Str, optional, default C<'y'>)

The default answer when the user presses Enter without typing.
Must be C<'y'> or C<'n'>.

=back

=head3 Returns

C<1> when the answer is affirmative (C<y> or C<yes>, case-insensitive) or
when the empty response maps to a C<'y'> default.

C<0> when the answer is negative (C<n> or C<no>, case-insensitive) or
when the empty response maps to an C<'n'> default.

=head3 Side Effects

Reads one line from STDIN; prints to STDOUT.

=head3 Usage Example

    my $ok = prompt_yes_no("Enable coverage?", 'y');

=head3 API SPECIFICATION

=head4 Input

    {
        question => { type => 'scalar', required => 1 },
        default  => { type => 'scalar', default  => 'y' },
    }

=head4 Output

    { type => 'scalar' }   # 1 or 0

=head3 FORMAL SPECIFICATION

    prompt_yes_no : ℤ* × {'y','n'} → 𝔹

    answer ≔ chomp(readline(STDIN))
    result ≔
        answer =~ /^y(?:es)?$/i → 1
        answer =~ /^n(?:o)?$/i  â†’ 0
        answer = ""           â†’ default = 'y' → 1  |  default = 'n' → 0

=cut
99
100
27
27
27
27
3288
14
19
9
sub prompt_yes_no($question, $default = 'y') {
101
27
24
        my $prompt = $default eq 'y' ? '[Y/n]' : '[y/N]';
102
27
31
        print "$question $prompt: ";
103
27
26
        chomp(my $answer = <STDIN>);
104
105
27
53
        return 1 if $answer =~ /^y(?:es)?$/i;
106
10
24
        return 0 if $answer =~ /^n(?:o)?$/i;
107
4
12
        return $default eq 'y' ? 1 : 0;
108}
109
110 - 175
=head2 prompt_choice($question, $choices, $default)

Prompt the user to select one item from a numbered list.

=head3 Purpose

Display C<$question> followed by a numbered list of C<$choices>, read one
line from STDIN, and return the zero-based index of the selected item.

=head3 Arguments

=over 4

=item C<$question> (Str, required)

The selection prompt text.

=item C<$choices> (ArrayRef[Str], required)

The available options, displayed numbered from 1.

=item C<$default> (Int, optional, default C<0>)

Zero-based index of the pre-selected option shown in the prompt.

=back

=head3 Returns

A zero-based integer index.  Returns C<$default> when the user presses Enter
without input or when the input is out of range (less than 1 or greater than
the number of choices).

=head3 Side Effects

Reads one line from STDIN; prints to STDOUT.

=head3 Usage Example

    my $idx = prompt_choice("Package manager?", ['npm','yarn','pnpm'], 0);

=head3 API SPECIFICATION

=head4 Input

    {
        question => { type => 'scalar',  required => 1 },
        choices  => { type => 'arrayref', required => 1 },
        default  => { type => 'scalar',  default  => 0 },
    }

=head4 Output

    { type => 'scalar' }   # integer 0 .. |choices|-1

=head3 FORMAL SPECIFICATION

    prompt_choice : ℤ* × seq ℤ* × â„• → â„•

    answer ≔ chomp(readline(STDIN))
    result ≔
        answer = ""                       â†’ default
        answer ∈ â„• ∧ 1 ≤ answer ≤ |choices| → answer − 1
        otherwise                         â†’ default

=cut
176
177
12
12
12
12
12
10331
8
9
9
6
sub prompt_choice($question, $choices, $default = 0) {
178
12
44
    say $question;
179
12
21
    for my $i (0 .. $#$choices) {
180
36
81
        my $marker = $i == $default ? colored(['green'], '→') : ' ';
181
36
270
        say "  $marker " . ($i + 1) . ". $choices->[$i]";
182    }
183
184
12
25
    print "\nEnter number [" . ($default + 1) . "]: ";
185
12
14
    chomp(my $answer = <STDIN>);
186
187
12
21
    return $default if $answer eq '';
188
9
39
    return $answer - 1 if $answer =~ /^\d+$/ && $answer >= 1 && $answer <= @$choices;
189
4
14
    return $default;
190}
191
192 - 273
=head2 prompt_multiselect($question, $options, $defaults)

Prompt the user to select zero or more items from a numbered list.

=head3 Purpose

Display C<$question> and a numbered list of C<$options>, accept
comma-separated numbers or the keyword C<all>, and return an array reference
of the selected option strings.

=head3 Arguments

=over 4

=item C<$question> (Str, required)

The multi-select prompt text.

=item C<$options> (ArrayRef[Str], required)

All available options, displayed numbered from 1.

=item C<$defaults> (ArrayRef[Str], optional, default C<[]>)

The pre-selected options (by value, not index).

=back

=head3 Returns

An array reference of selected option strings.  Possible values:

=over 4

=item *

The full C<$options> list when the user types C<all>.

=item *

A subset derived from the comma/space-separated numbers the user entered.

=item *

C<$defaults> when the user presses Enter without typing.

=back

=head3 Side Effects

Reads one line from STDIN; prints to STDOUT.

=head3 Usage Example

    my $sel = prompt_multiselect("OS?", ['ubuntu-latest','macos-latest','windows-latest'], []);

=head3 API SPECIFICATION

=head4 Input

    {
        question => { type => 'scalar',  required => 1 },
        options  => { type => 'arrayref', required => 1 },
        defaults => { type => 'arrayref', default  => [] },
    }

=head4 Output

    { type => 'arrayref' }

=head3 FORMAL SPECIFICATION

    prompt_multiselect : ℤ* × seq ℤ* × seq ℤ* → seq ℤ*

    answer ≔ chomp(readline(STDIN))
    result ≔
        answer = ""           â†’ defaults
        answer =~ /^all$/i   â†’ options
        otherwise            â†’ [ options[n−1] ∣ n ∈ split(/[,\s]+/, answer), 1 ≤ n ≤ |options| ]
                                   ?? defaults (when result = ∅)

=cut
274
275
13
13
13
13
13
5447
9
7
8
8
sub prompt_multiselect($question, $options, $defaults = []) {
276
13
21
    say $question;
277
13
18
    say colored(['cyan'], "(Enter numbers separated by commas, or 'all')");
278
279
13
25
187
26
    my %is_default = map { $_ => 1 } @$defaults;
280
281
13
18
    for my $i (0 .. $#$options) {
282
55
89
        my $marker = $is_default{$options->[$i]} ? colored(['green'], '✓') : ' ';
283
55
341
        say "  $marker " . ($i + 1) . ". $options->[$i]";
284    }
285
286
13
25
18
37
    print "\nEnter choices [" . join(',', map { $_+1 } 0..$#$defaults) . "]: ";
287
13
23
    chomp(my $answer = <STDIN>);
288
289
13
15
    return $defaults if $answer eq '';
290
291
11
16
    if ($answer =~ /^all$/i) {
292
2
5
        return $options;
293    }
294
295
9
5
    my @selected;
296
9
19
    for my $num (split /[,\s]+/, $answer) {
297
19
54
        if ($num =~ /^\d+$/ && $num >= 1 && $num <= @$options) {
298
17
17
            push @selected, $options->[$num - 1];
299        }
300    }
301
302
9
23
    return @selected ? \@selected : $defaults;
303}
304
305 - 360
=head2 prompt_text($question, $default)

Prompt the user for a free-form text answer.

=head3 Purpose

Display C<$question> optionally followed by C<[$default]>, read one line
from STDIN, and return the user's answer or C<$default> when empty.

=head3 Arguments

=over 4

=item C<$question> (Str, required)

The prompt text.

=item C<$default> (Str, optional, default C<''>)

Returned as-is when the user presses Enter without typing anything.

=back

=head3 Returns

The trimmed line the user typed, or C<$default> when the input is empty.

=head3 Side Effects

Reads one line from STDIN; prints to STDOUT.

=head3 Usage Example

    my $name = prompt_text("Project name", 'my-project');

=head3 API SPECIFICATION

=head4 Input

    {
        question => { type => 'scalar', required => 1 },
        default  => { type => 'scalar', default  => '' },
    }

=head4 Output

    { type => 'scalar' }

=head3 FORMAL SPECIFICATION

    prompt_text : ℤ* × ℤ* → ℤ*

    answer ≔ chomp(readline(STDIN))
    result ≔ answer = "" → default  |  otherwise → answer

=cut
361
362
15
15
15
15
2751
6
12
4
sub prompt_text($question, $default = '') {
363
15
20
    my $prompt = $default ? "[$default]" : '';
364
15
18
    print "$question $prompt: ";
365
15
15
    chomp(my $answer = <STDIN>);
366
367
15
24
    return $answer eq '' ? $default : $answer;
368}
369
370 - 424
=head2 customize_workflow($type)

Drive an interactive customization session for the given workflow type.

=head3 Purpose

Display a series of prompts relevant to C<$type> and collect user preferences.
Dispatches to a private C<_customize_*> helper; returns an empty hash when
the type is not supported.

=head3 Arguments

=over 4

=item C<$type> (Str, required)

The workflow type to customise.  Supported: C<perl>, C<node>, C<python>,
C<rust>, C<go>, C<ruby>, C<docker>, C<static>.

=back

=head3 Returns

A hash reference of configuration key/value pairs collected from the user.
Returns an empty hash reference (C<{}>) when C<$type> is not recognised.

=head3 Side Effects

Reads multiple lines from STDIN; prints to STDOUT.

=head3 Usage Example

    my $config = customize_workflow('perl');
    # $config->{enable_critic}, $config->{perl_versions}, etc.

=head3 API SPECIFICATION

=head4 Input

    { type => { type => 'scalar', required => 1 } }

=head4 Output

    { type => 'hashref' }   # empty or populated with type-specific keys

=head3 FORMAL SPECIFICATION

    SupportedCustomTypes ≔ { perl, node, python, rust, go, ruby, docker, static }

    customize_workflow : ℤ* → Config

    t ∈ SupportedCustomTypes → _customize_t()
    t ∉ SupportedCustomTypes → {}

=cut
425
426
10
10
10
14791
10
7
sub customize_workflow($type) {
427
10
45
    say '';
428
10
29
    say colored(['bold cyan'], "=== Workflow Customization: " . uc($type) . " ===");
429
10
230
    say '';
430
431
10
34
    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
10
17
    if (exists $dispatch{$type}) {
443
8
9
        return $dispatch{$type}->();
444    }
445
446
2
6
    return {};
447}
448
449
1
1
1
0
sub _customize_perl() {
450
1
1
    my %config;
451
452    # Perl versions
453
1
2
    say colored(['bold'], "Perl Versions to Test:");
454
1
16
    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
1
2
    my @default_versions = qw(5.40 5.38 5.36);
456
457
1
2
    $config{perl_versions} = prompt_multiselect(
458        "Which Perl versions?",
459        \@all_versions,
460        \@default_versions
461    );
462
1
1
    say '';
463
464    # Operating systems
465
1
2
    say colored(['bold'], "Operating Systems:");
466
1
14
    my @all_os = ('ubuntu-latest', 'macos-latest', 'windows-latest');
467
1
1
    my @default_os = @all_os;
468
469
1
2
    $config{os} = prompt_multiselect(
470        "Which operating systems?",
471        \@all_os,
472        \@default_os
473    );
474
1
1
    say '';
475
476    # Code quality
477
1
2
    say colored(['bold'], "Code Quality Tools:");
478
1
13
    $config{enable_linter} = prompt_yes_no(
479        "Enable syntax linting (perl -c on all matrix cells)?",
480        'y'
481    );
482
1
1
    say '';
483
484
1
1
    $config{enable_linter_unused} = prompt_yes_no(
485        "Enable unused-variable check (warnings::unused, latest+ubuntu only)?",
486        'n'
487    );
488
1
2
    say '';
489
490
1
1
    $config{enable_critic} = prompt_yes_no(
491        "Enable Perl::Critic?",
492        'y'
493    );
494
1
1
    say '';
495
496    # Coverage
497
1
1
    $config{enable_coverage} = prompt_yes_no(
498        "Enable test coverage (Devel::Cover)?",
499        'y'
500    );
501
1
1
    say '';
502
503    # Branches
504
1
2
    say colored(['bold'], "Branch Configuration:");
505
1
13
    my $branches = prompt_text(
506        "Branches to run on (comma-separated)",
507        'main,master'
508    );
509
1
2
    $config{branches} = [split /,\s*/, $branches];
510
1
2
    say '';
511
512
1
4
    return \%config;
513}
514
515
1
1
1
1
sub _customize_node() {
516
1
1
    my %config;
517
518    # Node versions
519
1
1
    say colored(['bold'], "Node.js Versions to Test:");
520
1
16
    my @all_versions = qw(18.x 20.x 22.x 23.x);
521
1
1
    my @default_versions = qw(20.x 22.x);
522
523
1
2
    $config{node_versions} = prompt_multiselect(
524        "Which Node.js versions?",
525        \@all_versions,
526        \@default_versions
527    );
528
1
1
    say '';
529
530    # Package manager
531
1
2
    say colored(['bold'], "Package Manager:");
532
1
14
    my $pm_choice = prompt_choice(
533        "Which package manager?",
534        ['npm', 'yarn', 'pnpm'],
535        0
536    );
537
1
2
    $config{package_manager} = ['npm', 'yarn', 'pnpm']->[$pm_choice];
538
1
2
    say '';
539
540    # Linting
541
1
1
    $config{enable_lint} = prompt_yes_no(
542        "Enable linting?",
543        'y'
544    );
545
1
1
    say '';
546
547    # Build step
548
1
1
    $config{enable_build} = prompt_yes_no(
549        "Enable build step?",
550        'y'
551    );
552
1
1
    say '';
553
554    # Branches
555
1
1
    my $branches = prompt_text(
556        "Branches to run on (comma-separated)",
557        'main,develop'
558    );
559
1
2
    $config{branches} = [split /,\s*/, $branches];
560
1
1
    say '';
561
562
1
4
    return \%config;
563}
564
565
1
1
1
1
sub _customize_python() {
566
1
1
    my %config;
567
568    # Python versions
569
1
1
    say colored(['bold'], "Python Versions to Test:");
570
1
15
    my @all_versions = qw(3.9 3.10 3.11 3.12 3.13);
571
1
2
    my @default_versions = qw(3.11 3.12);
572
573
1
1
    $config{python_versions} = prompt_multiselect(
574        "Which Python versions?",
575        \@all_versions,
576        \@default_versions
577    );
578
1
2
    say '';
579
580    # Linting
581
1
1
    say colored(['bold'], "Code Quality:");
582
1
14
    $config{enable_flake8} = prompt_yes_no(
583        "Enable flake8 linting?",
584        'y'
585    );
586
1
1
    say '';
587
588
1
1
    $config{enable_black} = prompt_yes_no(
589        "Enable black formatter check?",
590        'n'
591    );
592
1
1
    say '';
593
594    # Coverage
595
1
1
    $config{enable_coverage} = prompt_yes_no(
596        "Enable test coverage?",
597        'y'
598    );
599
1
1
    say '';
600
601    # Branches
602
1
2
    my $branches = prompt_text(
603        "Branches to run on (comma-separated)",
604        'main,develop'
605    );
606
1
2
    $config{branches} = [split /,\s*/, $branches];
607
1
1
    say '';
608
609
1
4
    return \%config;
610}
611
612
1
1
1
1
sub _customize_rust() {
613
1
1
    my %config;
614
615
1
2
    say colored(['bold'], "Rust Workflow Options:");
616
617
1
15
    $config{enable_fmt} = prompt_yes_no(
618        "Enable formatting check (cargo fmt)?",
619        'y'
620    );
621
1
2
    say '';
622
623
1
1
    $config{enable_clippy} = prompt_yes_no(
624        "Enable clippy linting?",
625        'y'
626    );
627
1
2
    say '';
628
629
1
1
    $config{enable_release} = prompt_yes_no(
630        "Build release binary?",
631        'y'
632    );
633
1
1
    say '';
634
635
1
2
    my $branches = prompt_text(
636        "Branches to run on (comma-separated)",
637        'main'
638    );
639
1
3
    $config{branches} = [split /,\s*/, $branches];
640
1
2
    say '';
641
642
1
3
    return \%config;
643}
644
645
1
1
1
1
sub _customize_go() {
646
1
1
    my %config;
647
648
1
11
    say colored(['bold'], "Go Workflow Options:");
649
650
1
15
    my $go_version = prompt_text(
651        "Go version",
652        '1.22'
653    );
654
1
1
    $config{go_version} = $go_version;
655
1
2
    say '';
656
657
1
1
    $config{enable_vet} = prompt_yes_no(
658        "Enable go vet?",
659        'y'
660    );
661
1
1
    say '';
662
663
1
2
    $config{enable_race} = prompt_yes_no(
664        "Enable race detector?",
665        'y'
666    );
667
1
1
    say '';
668
669
1
2
    $config{enable_coverage} = prompt_yes_no(
670        "Enable test coverage?",
671        'y'
672    );
673
1
2
    say '';
674
675
1
2
    my $branches = prompt_text(
676        "Branches to run on (comma-separated)",
677        'main'
678    );
679
1
2
    $config{branches} = [split /,\s*/, $branches];
680
1
1
    say '';
681
682
1
4
    return \%config;
683}
684
685
1
1
1
1
sub _customize_ruby() {
686
1
1
    my %config;
687
688
1
2
    say colored(['bold'], "Ruby Versions to Test:");
689
1
15
    my @all_versions = qw(3.1 3.2 3.3);
690
1
1
    my @default_versions = qw(3.2 3.3);
691
692
1
2
    $config{ruby_versions} = prompt_multiselect(
693        "Which Ruby versions?",
694        \@all_versions,
695        \@default_versions
696    );
697
1
1
    say '';
698
699
1
1
    my $branches = prompt_text(
700        "Branches to run on (comma-separated)",
701        'main'
702    );
703
1
1
    $config{branches} = [split /,\s*/, $branches];
704
1
2
    say '';
705
706
1
3
    return \%config;
707}
708
709
1
1
1
0
sub _customize_docker() {
710
1
1
    my %config;
711
712
1
2
    say colored(['bold'], "Docker Workflow Options:");
713
714
1
14
    my $image_name = prompt_text(
715        "Docker image name (user/image)",
716        'your-username/your-image'
717    );
718
1
2
    $config{image_name} = $image_name;
719
1
1
    say '';
720
721
1
2
        $config{push_on_pr} = prompt_yes_no(
722                'Push images on pull requests?',
723                'n'
724        );
725
1
2
        say '';
726
727
1
1
        my $branches = prompt_text(
728                "Branches to run on (comma-separated)",
729                'main'
730        );
731
1
2
        $config{branches} = [split /,\s*/, $branches];
732
1
2
        say '';
733
734
1
3
        return \%config;
735}
736
737
1
1
1
0
sub _customize_static() {
738
1
1
        my %config;
739
740
1
2
        say colored(['bold'], "Static Site Deployment:");
741
742
1
14
        my $build_dir = prompt_text('Build output directory', './public');
743
1
2
        $config{build_dir} = $build_dir;
744
1
1
        say '';
745
746
1
1
        my $build_command = prompt_text("Build command", 'npm run build');
747
1
2
        $config{build_command} = $build_command;
748
1
1
        say '';
749
750
1
3
        return \%config;
751}
752
753 - 767
=head1 AUTHOR

Nigel Horne E<lt>njh@nigelhorne.comE<gt>

L<https://github.com/nigelhorne>

=head1 COPYRIGHT AND LICENSE

Copyright 2025-2026 Nigel Horne.

Usage is subject to the GPL2 licence terms.
If you use it,
please let me know.

=cut
768
7691;