| File: | blib/lib/App/Project/Doctor/Finding.pm |
| Coverage: | 98.7% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::Project::Doctor::Finding; | |||||
| 2 | ||||||
| 3 | # A Finding is a single diagnostic result produced by a check plugin. | |||||
| 4 | # It carries a severity level (error/warning/pass/info), a human-readable | |||||
| 5 | # message, an optional automated fix coderef, and optional file/line location. | |||||
| 6 | ||||||
| 7 | 5 5 5 | 565867 5 81 | use strict; | |||
| 8 | 5 5 5 | 7 4 127 | use warnings; | |||
| 9 | 5 5 5 | 12 2 21 | use autodie qw(:all); | |||
| 10 | ||||||
| 11 | # croak reports errors at the caller's location rather than inside this module. | |||||
| 12 | 5 5 5 | 10604 6 172 | use Carp qw(croak carp); | |||
| 13 | # Params::Get normalises @_ into a hashref, handling both hash and hashref args. | |||||
| 14 | 5 5 5 | 9 5 92 | use Params::Get; | |||
| 15 | # validate_strict enforces the parameter schema and throws on any violation. | |||||
| 16 | 5 5 5 | 14 4 74 | use Params::Validate::Strict qw(validate_strict); | |||
| 17 | # Readonly creates truly immutable constants -- assigning to them throws at runtime. | |||||
| 18 | 5 5 5 | 9 1 1347 | use Readonly; | |||
| 19 | ||||||
| 20 | our $VERSION = '0.02'; | |||||
| 21 | ||||||
| 22 | # --------------------------------------------------------------------------- | |||||
| 23 | # Constants | |||||
| 24 | # --------------------------------------------------------------------------- | |||||
| 25 | ||||||
| 26 | # Maps each severity to the short bracketed icon shown in text-report lines. | |||||
| 27 | Readonly::Hash my %SEVERITY_ICON => ( | |||||
| 28 | error => '[X]', # Something is broken and must be fixed | |||||
| 29 | warning => '[!]', # Something is suspicious and should be reviewed | |||||
| 30 | pass => '[v]', # This item is healthy | |||||
| 31 | info => '[i]', # Informational; no action required | |||||
| 32 | ); | |||||
| 33 | ||||||
| 34 | # Used by new() to reject unknown severity strings before storing them. | |||||
| 35 | # The keys here must exactly match the keys in %SEVERITY_ICON above. | |||||
| 36 | Readonly::Hash my %VALID_SEVERITY => map { $_ => 1 } qw(error warning pass info); | |||||
| 37 | ||||||
| 38 | # --------------------------------------------------------------------------- | |||||
| 39 | # Constructor | |||||
| 40 | # --------------------------------------------------------------------------- | |||||
| 41 | ||||||
| 42 | sub new { | |||||
| 43 | 386 | 100327 | my $class = shift; # The package name, e.g. 'App::Project::Doctor::Finding' | |||
| 44 | ||||||
| 45 | # Check 'message' before running validate_strict so the caller gets our | |||||
| 46 | # descriptive error rather than a generic Params::Validate type error. | |||||
| 47 | 386 | 618 | my %raw = @_; | |||
| 48 | croak 'message must be a non-empty string' | |||||
| 49 | 386 | 860 | unless defined $raw{message} && length $raw{message}; | |||
| 50 | ||||||
| 51 | # validate_strict applies defaults, enforces types, and throws immediately | |||||
| 52 | # if anything is wrong. It never returns undef -- it either returns the | |||||
| 53 | # validated hashref or dies. | |||||
| 54 | 380 | 1831 | my $args = validate_strict( | |||
| 55 | schema => { | |||||
| 56 | severity => { type => 'scalar', optional => 1, default => 'info' }, | |||||
| 57 | message => { type => 'scalar' }, | |||||
| 58 | detail => { type => 'scalar', optional => 1, default => '' }, | |||||
| 59 | fix => { type => 'coderef', optional => 1 }, | |||||
| 60 | check_name => { type => 'scalar', optional => 1, default => 'Unknown' }, | |||||
| 61 | file => { type => 'scalar', optional => 1, default => '' }, | |||||
| 62 | line => { type => 'integer', optional => 1, min => 1 }, | |||||
| 63 | }, | |||||
| 64 | args => Params::Get::get_params(undef, \@_) || {}, | |||||
| 65 | ); | |||||
| 66 | ||||||
| 67 | # validate_strict only checks that severity is a scalar; we also need to | |||||
| 68 | # confirm it is one of our four known values. | |||||
| 69 | croak "Invalid severity '$args->{severity}'" | |||||
| 70 | 370 | 69773 | unless $VALID_SEVERITY{ $args->{severity} }; | |||
| 71 | ||||||
| 72 | # Bless the validated args hashref and return the new object. | |||||
| 73 | 363 | 1853 | return bless $args, $class; | |||
| 74 | } | |||||
| 75 | ||||||
| 76 | # --------------------------------------------------------------------------- | |||||
| 77 | # Accessors (all read-only -- attributes are set once in new() and never changed) | |||||
| 78 | # --------------------------------------------------------------------------- | |||||
| 79 | ||||||
| 80 | # The importance level of this finding: error, warning, pass, or info. | |||||
| 81 | 468 | 7529 | sub severity { $_[0]->{severity} } | |||
| 82 | # The short human-readable description of the problem or result. | |||||
| 83 | 238 | 1956 | sub message { $_[0]->{message} } | |||
| 84 | # An optional longer explanation; empty string when not set. | |||||
| 85 | 29 | 53 | sub detail { $_[0]->{detail} } | |||
| 86 | # The optional coderef called by Fixer to resolve the issue. | |||||
| 87 | 48 | 685 | sub fix { $_[0]->{fix} } | |||
| 88 | # The name of the check that produced this finding (e.g. 'Tests'). | |||||
| 89 | 64 | 85 | sub check_name { $_[0]->{check_name} } | |||
| 90 | # Relative path to the affected file; empty string when not applicable. | |||||
| 91 | 19 | 77 | sub file { $_[0]->{file} } | |||
| 92 | # Line number in the affected file; undef when not applicable. | |||||
| 93 | 25 | 74 | sub line { $_[0]->{line} } | |||
| 94 | ||||||
| 95 | # --------------------------------------------------------------------------- | |||||
| 96 | # Public methods | |||||
| 97 | # --------------------------------------------------------------------------- | |||||
| 98 | ||||||
| 99 - 103 | =head2 is_fixable Returns 1 when this finding carries an automated fix coderef, 0 otherwise. =cut | |||||
| 104 | ||||||
| 105 | # Return exactly 1 or 0 (not just truthy/falsy) so type-checked callers are happy. | |||||
| 106 | 152 | 696 | sub is_fixable { defined $_[0]->{fix} ? 1 : 0 } | |||
| 107 | ||||||
| 108 | # has_fix is a synonym for is_fixable kept for backward compatibility. | |||||
| 109 | # Both methods are part of the public API and must always agree. | |||||
| 110 | 3 | 11 | sub has_fix { defined $_[0]->{fix} ? 1 : 0 } | |||
| 111 | ||||||
| 112 - 116 | =head2 icon Returns the bracketed ASCII status icon for this finding's severity. =cut | |||||
| 117 | ||||||
| 118 | # Severity is always valid here because new() checked it against %VALID_SEVERITY, | |||||
| 119 | # and all valid severities have a matching entry in %SEVERITY_ICON. | |||||
| 120 | 16 | 1123 | sub icon { $SEVERITY_ICON{ $_[0]->{severity} } } | |||
| 121 | ||||||
| 122 - 127 | =head2 to_hash Serialises the finding to a plain hashref for JSON encoding. The C<fix> coderef is omitted. =cut | |||||
| 128 | ||||||
| 129 | sub to_hash { | |||||
| 130 | 13 | 22 | my $self = shift; | |||
| 131 | # Build the base hashref with all fields that are always present. | |||||
| 132 | 13 | 20 | my %h = ( | |||
| 133 | severity => $self->severity, | |||||
| 134 | message => $self->message, | |||||
| 135 | detail => $self->detail, | |||||
| 136 | check_name => $self->check_name, | |||||
| 137 | file => $self->file, | |||||
| 138 | ); | |||||
| 139 | # Only include 'line' when it was actually set; absent means "unknown location". | |||||
| 140 | 13 | 19 | $h{line} = $self->line if defined $self->line; | |||
| 141 | 13 | 51 | return \%h; | |||
| 142 | } | |||||
| 143 | ||||||
| 144 | 1; | |||||
| 145 | ||||||