File Coverage

File:blib/lib/App/Project/Doctor/Finding.pm
Coverage:98.7%

linestmtbrancondsubtimecode
1package 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
20our $VERSION = '0.02';
21
22# ---------------------------------------------------------------------------
23# Constants
24# ---------------------------------------------------------------------------
25
26# Maps each severity to the short bracketed icon shown in text-report lines.
27Readonly::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.
36Readonly::Hash my %VALID_SEVERITY => map { $_ => 1 } qw(error warning pass info);
37
38# ---------------------------------------------------------------------------
39# Constructor
40# ---------------------------------------------------------------------------
41
42sub 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
129sub 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
1441;
145