App-Project-Doctor

 view release on metacpan or  search on metacpan

lib/App/Project/Doctor/Fixer.pm  view on Meta::CPAN

package App::Project::Doctor::Fixer;

# The Fixer presents a numbered menu of auto-fixable findings, reads the
# user's answer from STDIN, and calls each selected fix coderef with the
# current Context.  In non-interactive mode (--fix flag) it applies all
# fixes immediately without prompting.

use strict;
use warnings;
use autodie qw(:all);

# croak dies at the caller's location; carp warns there.
use Carp qw(croak carp);
# Params::Get normalises @_ into a hashref before validate_strict sees it.
use Params::Get;
# validate_strict enforces parameter schemas and throws immediately on failure.
use Params::Validate::Strict qw(validate_strict);
# blessed() checks whether a reference is a blessed object.
use Scalar::Util qw(blessed);

our $VERSION = '0.02';

# ---------------------------------------------------------------------------
# Constructor
# ---------------------------------------------------------------------------

sub new {
	my $class = shift;

	# validate_strict with type => 'object' guarantees that report and context
	# are blessed references before we reach the isa() checks below.
	# The redundant blessed() call was removed; isa() alone is sufficient.
	my $args = validate_strict(
		schema => {
			report          => { type => 'object'                              },
			context         => { type => 'object'                              },
			non_interactive => { type => 'scalar', optional => 1, default => 0 },
		},
		args => Params::Get::get_params(undef, \@_) || {},
	);

	# isa() confirms the exact class; validate_strict only checked 'blessed'.
	croak 'report must be an App::Project::Doctor::Report'
		unless $args->{report}->isa('App::Project::Doctor::Report');
	croak 'context must be an App::Project::Doctor::Context'
		unless $args->{context}->isa('App::Project::Doctor::Context');

	# Store the validated args and return the new Fixer object.
	return bless $args, $class;
}

# ---------------------------------------------------------------------------
# Accessors  (read-only after construction)
# ---------------------------------------------------------------------------

# The Report whose fixable findings will be presented to the user.
sub report          { $_[0]->{report}          }
# The Context passed to each fix coderef so it can find files.
sub context         { $_[0]->{context}         }
# When true, all fixes are applied immediately without user prompting.
sub non_interactive { $_[0]->{non_interactive} }

# ---------------------------------------------------------------------------
# Public interface
# ---------------------------------------------------------------------------

=head2 run

Presents fixable findings, prompts (or auto-applies in non-interactive mode),
and calls each selected C<fix> coderef.  Returns the count of fixes applied.

=cut

sub run {
	my $self    = shift;
	# Collect only the findings that have an associated fix coderef.
	my @fixable = $self->report->fixable;
	# Nothing to do if no fixable findings were found.
	return 0 unless @fixable;
	# Choose the right mode: silent auto-apply vs. interactive prompt.
	return $self->non_interactive
		? $self->_apply_all(\@fixable)
		: $self->_interactive_loop(\@fixable);
}

# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------

# Purpose:    Print a numbered list of fixes to STDOUT.
# Entry:      $fixable is a non-empty arrayref of Finding objects.
# Exit:       Returns nothing; side-effect is printed output only.
# Side effects: Writes to STDOUT.
sub _print_fix_list {
	my $fixable = shift;
	print "\nSuggested fixes:\n";
	# Number each finding starting at 1 so the user can reference them by number.
	my $i = 0;
	printf "  [%d] %s\n", ++$i, $_->message for @{$fixable};
	return;
}

# Purpose:    Read the user's choice from STDIN and apply the selected fixes.
# Entry:      $fixable is a non-empty arrayref of Finding objects.
# Exit:       Integer count of fixes successfully applied.
# Side effects: Reads STDIN, writes STDOUT, may modify the filesystem via fix coderefs.
sub _interactive_loop {
	my ($self, $fixable) = @_;

	# Show the numbered list so the user knows what choices are available.
	_print_fix_list($fixable);
	print "\nWould you like me to apply them? [Y/n/1,3] ";

	# Read one line from the user; return 0 cleanly if STDIN is closed (e.g. in a pipe).
	my $answer = <STDIN>;
	return 0 unless defined $answer;
	chomp $answer;    # Remove the trailing newline before comparing.

	# Empty input or "yes" means apply everything.
	return $self->_apply_all($fixable)
		if $answer eq '' || $answer =~ /^y(?:es)?$/i;

	# Explicit "no" -- tell the user we skipped and return.
	if ($answer =~ /^n(?:o)?$/i) {
		print "No fixes applied.\n";
		return 0;
	}

	# A comma/space-separated list of numbers selects individual fixes.
	if ($answer =~ /^[\d,\s]+$/) {
		my $max = scalar @{$fixable};    # The highest valid index.
		my %seen;
		# Parse the numbers, clamp to valid range, and deduplicate.
		my @indices  = grep { $_ >= 1 && $_ <= $max && !$seen{$_}++ }
		               map  { int($_) }
		               split /[\s,]+/, $answer;
		# Convert 1-based user indices to 0-based array indices.
		my @selected = map { $fixable->[$_ - 1] } @indices;
		return $self->_apply_all(\@selected);
	}

	# Anything else is unrecognised; be explicit rather than guessing.
	print "Unrecognised input -- no fixes applied.\n";
	return 0;
}

# Purpose:    Call every fix coderef in the list and count the successes.
# Entry:      $fixable is an arrayref of Finding objects (may be empty).
# Exit:       Integer count of fixes that ran without throwing.
# Side effects: Calls fix coderefs (may create/modify files), writes to STDOUT on
#               success, calls carp for each failing fix.
sub _apply_all {
	my ($self, $fixable) = @_;
	my $count = 0;
	for my $f (@{$fixable}) {
		# Wrap the fix in eval so a single failure doesn't abort all remaining fixes.
		my $ok = eval { $f->fix->($self->context); 1 };
		if ($ok) {
			# Print confirmation so the user can see what changed.
			printf "  Applied: %s\n", $f->message;
			$count++;
		} else {
			# Report the failure but continue with the next fix.
			carp "Fix failed for '" . $f->message . "': $@";
		}
	}
	# Summary line always prints, even when count is 0.
	printf "\n%d fix(es) applied.\n", $count;
	return $count;
}

1;

__END__

=head1 NAME

App::Project::Doctor::Fixer - Interactive fix application loop

=head1 VERSION

0.02

=head1 SYNOPSIS

  use App::Project::Doctor::Fixer;

  my $fixer = App::Project::Doctor::Fixer->new(
      report  => $report,
      context => $ctx,
  );
  my $count = $fixer->run;

=head1 DESCRIPTION

Presents fixable findings from a report, reads the user's choice from STDIN
(C<Y> all, C<n> none, or C<1,3> index list), and calls each selected
finding's C<fix> coderef with the current context.

Set C<non_interactive =E<gt> 1> to apply all fixes without prompting
(C<--fix> mode).

=head1 CONSTRUCTOR

=head2 new( %args )

=head3 API SPECIFICATION

=head4 Input

  report          : App::Project::Doctor::Report   required (blessed, isa Report)
  context         : App::Project::Doctor::Context  required (blessed, isa Context)
  non_interactive : Bool                           default 0

=head4 Output

Blessed hashref of type C<App::Project::Doctor::Fixer>.

=head1 ACCESSORS

C<report>, C<context>, C<non_interactive> -- read-only.

=head1 METHODS

=head2 run

=head3 API SPECIFICATION

=head4 Input

None.

=head4 Output

Integer -- number of fixes successfully applied.

=head3 MESSAGES

  Code | Trigger               | Resolution
  -----|-----------------------|---------------------------------------
  F001 | A fix coderef throws  | Fix skipped; error logged via carp

=head3 FORMAL SPECIFICATION

  run : Fixer -> N
  run fixer ==
    let fixable = { f in findings (report fixer) | is_fixable f }
    in  if non_interactive fixer
        then apply_all fixable
        else apply_chosen fixable (prompt fixable)

=head1 LIMITATIONS

Reads from STDIN; use C<non_interactive =E<gt> 1> in automated pipelines.

Encapsulation of C<_interactive_loop>, C<_apply_all>, and C<_print_fix_list>
is enforced by convention only; a future migration to C<Sub::Private> in
enforce mode is tracked as a TODO.

=head1 AUTHOR

Nigel Horne C<< <njh@nigelhorne.com> >>

=head1 LICENSE

Copyright (C) 2026 Nigel Horne.
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.

=cut



( run in 0.990 second using v1.01-cache-2.11-cpan-6aa56a78535 )