File Coverage

File:blib/lib/App/FileSync/Settings.pm
Coverage:93.8%

linestmtbrancondsubtimecode
1package App::FileSync::Settings;
2
3# Process-wide settings (logging and SSH defaults) read through
4# Object::Configure, and the logger built from them.  The repos themselves stay
5# in ~/.filesync/config.json, which "filesync init" writes and the admin tool
6# edits; this module never touches that store.
7
8# First, so the Windows end-of-scope hook is chosen before Log::Abstraction
9# (through namespace::clean) or Sub::Private loads B::Hooks::EndOfScope.
10
318
318
318
10059
247
4345
use App::FileSync::Platform;
11
12
318
318
318
524
226
2488
use strict;
13
318
318
318
435
223
5777
use warnings;
14
318
318
318
1326
33218
656
use autodie qw(:all);
15
16
318
318
318
809995
2993825
6425
use Config::Abstraction;
17
318
318
318
960
295
7437
use File::Basename qw(dirname);
18
318
318
318
681
275
2891
use File::Spec;
19
318
318
318
572
269
8370
use List::Util qw(any first);
20
318
318
318
144133
9537232
5848
use Log::Abstraction;
21
318
318
318
101038
1832428
6048
use Object::Configure;
22
318
318
318
989
314
7841
use Readonly;
23
318
318
318
785
351
1466
use Sub::Private { mode => 'enforce' };
24
25
318
318
318
42234
332
6451
use App::FileSync::Args qw(named);
26
318
318
318
671
322
5097
use App::FileSync::I18N qw(throw);
27
318
318
318
1326
291
4872
use App::FileSync::IO qw(ensure_dir);
28
29
318
318
318
630
306
1155
use version; our $VERSION = version->declare('0.001.1');
30
31# The hook predicates moved to App::FileSync::Platform, which must load before
32# this module's dependencies; these names are kept for existing callers.
33{
34
318
318
318
14543
300
275767
        no warnings 'once';
35        *wants_plain_end_of_scope_hook = \&App::FileSync::Platform::wants_plain_end_of_scope_hook;
36        *use_plain_end_of_scope_hook   = \&App::FileSync::Platform::use_plain_end_of_scope_hook;
37}
38
39# Package variables, not Readonly: they are documented and tests may localise them.
40our $DEFAULT_FORMAT  = '%level%: %message%';
41our $DEFAULT_LEVEL   = 'warning';
42our $CONFIG_BASENAME = 'app-filesync.json';
43
44# Defaults for every SSH repo.  A repo's own config still wins, so one machine
45# can point at another identity without repeating the common settings.
46our $DEFAULT_SERVER_COMMAND = 'filesync-server';
47
48our @VALID_LEVELS = qw(
49        trace debug info informational notice warn warning error err
50        crit critical fatal alert emerg emergency panic
51);
52
53# The process-wide settings, in the order "filesync settings" prints them.
54our @SETTING_KEYS = qw(
55        log_level log_format log_file
56        ssh_identity ssh_known_hosts server_command
57);
58
59# How each repo-level SSH key corresponds to the process setting it falls back
60# to.  The names differ only because the repo config uses shorter keys.
61our %REPO_SSH_KEYS = (
62        ssh_identity    => 'identity',
63        ssh_known_hosts => 'known_hosts',
64        server_command  => 'server_command',
65);
66our @REPO_SSH_SETTINGS = qw(ssh_identity ssh_known_hosts server_command);
67
68# Section names Object::Configure and Config::Abstraction use.
69Readonly my $CLASS_SECTION     => 'App__FileSync';
70Readonly my $UNIVERSAL_SECTION => 'UNIVERSAL';
71Readonly my $CONF_DIR          => '.conf';
72
73# Settings that only a repo records: there is no process-wide layer, and ssh's
74# own default applies when the repo leaves them out.
75Readonly my @REPO_ONLY => ([ port => '22' ], [ host_key_checking => 'yes' ]);
76
77# Provenance labels for "filesync settings".
78Readonly my $LABEL_DEFAULT     => 'built-in default';
79Readonly my $LABEL_SSH_DEFAULT => 'ssh default';
80Readonly my $LABEL_UNKNOWN     => 'unknown source';
81
82# Object::Configure's search order for a universal file beside the settings file.
83Readonly my @UNIVERSAL_EXTENSIONS => qw(.conf .yml .yaml .json);
84
85# The arguments load, explain and logger share.
86my %FILE_ARGS = (
87        config_file => { type => 'string', optional => 1 },
88        config_dirs => { type => 'arrayref', optional => 1 },
89);
90
91=encoding utf8
92
93 - 195
=head1 NAME

App::FileSync::Settings - process-wide settings and logging for App::FileSync

=head1 SYNOPSIS

        use App::FileSync::Settings;

        my $logger = App::FileSync::Settings::logger(verbose => 1);
        $logger->error('...');

        my $settings = App::FileSync::Settings::load();
        my $level = $settings->{log_level};

        my $report = App::FileSync::Settings::explain();
        printf "%s = %s (%s)\n", $_->{key}, $_->{value}, $_->{label}
                for @{ $report->{settings} };

=head1 DESCRIPTION

Settings that apply to the process rather than to one repo - the log level,
format and destination, plus the SSH identity, known-hosts file and remote
server command - are read through L<Object::Configure>.  The repos themselves
remain in F<~/.filesync/config.json>, which C<filesync init> writes; this
module does not replace that store.

The settings file is F<~/.conf/app-filesync.json> when it exists, or the path in
C<FILESYNC_SETTINGS>.  The section key is C<App__FileSync>, and a C<UNIVERSAL>
section in the same directory applies to every class that uses
Object::Configure.  Environment variables named C<App__FileSync__I<key>>
override the file:

        # ~/.conf/app-filesync.json
        { "App__FileSync": { "log_level": "debug", "log_format": "%level%: %message%" } }

        # or
        export App__FileSync__log_level=debug

C<log_file> sends the log to a file instead of standard error; leading
directories are created.  C<log_format> is a L<Log::Abstraction> format string.

C<ssh_identity>, C<ssh_known_hosts> and C<server_command> are the defaults for
every SSH repo, so a per-repo C<identity>, C<known_hosts> or C<server_command>
(written by C<filesync init --identity> and friends) only has to be set when it
differs.  C<server_command> defaults to C<filesync-server>:

        { "App__FileSync": {
                "ssh_identity"    : "~/.ssh/filesync_ed25519",
                "ssh_known_hosts" : "~/.ssh/filesync_known_hosts",
                "server_command"  : "/usr/local/bin/filesync-server"
        } }

Command output (sync summaries, plans, C<--json>) is written to standard output
by the commands themselves; the logger carries diagnostics on standard error,
so a cron job can separate the two.  C<--verbose> raises the level to C<debug>
and C<--quiet> lowers it to C<error> for that run, overriding the file.

C<wants_plain_end_of_scope_hook> and C<use_plain_end_of_scope_hook> are aliases
of the functions in L<App::FileSync::Platform>, kept for existing callers.

=head1 FUNCTIONS

=head2 config_file

Purpose: the settings file that would be used.

Args: an optional explicit path.

Returns: the explicit path when one is given; otherwise C<FILESYNC_SETTINGS> or
F<~/.conf/app-filesync.json> if that file exists; otherwise C<undef>.

Side Effects: none.

Usage:

        my $file = App::FileSync::Settings::config_file();

=head3 EXAMPLE

        my $file = App::FileSync::Settings::config_file() // 'none';
        print "settings: $file\n";

=head3 API SPECIFICATION

=head4 INPUT

        [ { name => 'given', type => 'string', optional => 1 } ]

=head4 OUTPUT

        { type => 'string', optional => 1 }

=head3 MESSAGES

None.

=head3 FORMAL SPECIFICATION

        config_file(g) = g                     if g ≠ ""
        config_file(g) = d                     if d = default_path ∧ d ∈ dom FS
        config_file(g) = ⊥                     otherwise

=cut
196
197sub config_file {
198
631
1081
        my ($given) = @_;
199
200
631
1367
        return $given if defined $given && length $given;
201
202        # A stale FILESYNC_SETTINGS must not fail every command: it is only a default.
203
612
2329
        my $path = _default_config_path();
204
612
6010
        return (defined $path && -e $path) ? $path : undef;
205}
206
207 - 253
=head2 load

Purpose: the merged process settings.

Args: optional C<config_file> and C<config_dirs> (as for
L<Object::Configure/configure>).

Returns: a hashref with every key in C<@SETTING_KEYS>, built-in defaults
overlaid by the settings file, the C<UNIVERSAL> section and C<App__FileSync__*>
environment variables.

Side Effects: reads the settings file.  Dies (through Object::Configure) when
an explicit file does not exist.

Usage:

        my $settings = App::FileSync::Settings::load(config_file => $path);

=head3 EXAMPLE

        print App::FileSync::Settings::load()->{log_level}, "\n";  # warning

=head3 API SPECIFICATION

=head4 INPUT

        {
                config_file => { type => 'string', optional => 1 },
                config_dirs => { type => 'arrayref', optional => 1 },
        }

=head4 OUTPUT

        { type => 'hashref' }

=head3 MESSAGES

        Key            Meaning                         Resolution
        args.invalid   an argument has the wrong type  fix the call

Object::Configure's own error for a missing explicit file is passed through.

=head3 FORMAL SPECIFICATION

        load(a) = defaults ⊕ file(config_file(a)) ⊕ universal ⊕ env

=cut
254
255sub load {
256
621
98364
        my $args = named(\@_, {%FILE_ARGS}, 'Settings::load');
257
258
621
1995
        my $config_file = config_file($args->{config_file});
259
260        my %params = (
261
621
621
912
1242
                %{ _defaults() },
262                # We build the logger ourselves; this keeps Object::Configure from
263                # constructing its default Log::Log4perl backend as a side effect.
264                logger => [],
265        );
266
621
1702
        $params{config_file} = $config_file if defined $config_file;
267
621
1152
        $params{config_dirs} = $args->{config_dirs} if $args->{config_dirs};
268
269
621
2460
        my $settings = Object::Configure::configure('App::FileSync', \%params);
270
620
1690944
        delete $settings->{logger};
271
272
620
7874
        return $settings;
273}
274
275 - 344
=head2 explain

Purpose: where each effective setting came from, for C<filesync settings>.

Args: as L</load>.

Returns: C<{ settings_file =E<gt> $path_or_undef, settings =E<gt> \@rows }>, one
row per key in C<@SETTING_KEYS>.  Each row has the effective C<value>, a
C<source> of C<default>, C<file>, C<env> or C<merged>, and a human-readable
C<label> naming the file section, environment variable or built-in default
that supplied it.

The values are whatever L</load> returns; the provenance is reconstructed from
the same layers Object::Configure merges.  A value no modelled layer accounts
for (a C<global> section, say) is reported as C<merged> rather than claimed as
a default.

Side Effects: reads the settings file and any universal file beside it.

Usage:

        my $report = App::FileSync::Settings::explain();

=head3 EXAMPLE

        for my $row (@{ App::FileSync::Settings::explain()->{settings} }) {
                printf "%-16s %-20s %s\n", $row->{key}, $row->{value}, $row->{label};
        }

=head3 API SPECIFICATION

=head4 INPUT

        {
                config_file => { type => 'string', optional => 1 },
                config_dirs => { type => 'arrayref', optional => 1 },
        }

=head4 OUTPUT

        {
                type => 'hashref',
                schema => {
                        settings_file => { type => 'string', optional => 1 },
                        settings      => { type => 'arrayref' },
                },
        }

=head3 MESSAGES

As L</load>.

=head3 FORMAL SPECIFICATION

        âˆ€ k : SETTING_KEYS •
                row(k).value = load()(k)
                row(k).source = last layer ∈ ⟨default, universal, class⟩ that sets k,
                                or merged if that layer's value ≠ row(k).value

=head3 PSEUDOCODE

        load the settings
        explain the class and universal sections of the settings file, if any
        for each key:
                start from the built-in default
                let the universal section, then the class section, override it
                without a file, let UNIVERSAL__key then App__FileSync__key override it
                if the loaded value differs from the winner, call it merged

=cut
345
346sub explain {
347
8
10966
        my $args = named(\@_, {%FILE_ARGS}, 'Settings::explain');
348
349
8
50
        my $config_file = config_file($args->{config_file});
350
8
8
8
11
        my $settings = load(%{$args});
351
8
12
        my $defaults = _defaults();
352
353        # Each layer is [ section explanation, label for a file entry ], lowest first.
354
8
6
        my @layers;
355
8
14
        if (defined $config_file) {
356
6
9
                my $universal = _universal_file($config_file);
357
6
17
                push @layers, [ _explanation($universal, $UNIVERSAL_SECTION), $UNIVERSAL_SECTION, "$universal [$UNIVERSAL_SECTION]" ]
358                        if defined $universal;
359
6
32
                push @layers, [ _explanation($config_file, $CLASS_SECTION), $CLASS_SECTION, "$config_file [$CLASS_SECTION]" ];
360        }
361
362
8
76
        my @rows;
363
8
8
        for my $key (@SETTING_KEYS) {
364
48
73
                my %row = (key => $key, value => $settings->{$key}, source => 'default', label => $LABEL_DEFAULT);
365
48
107
                my $winner = $defaults->{$key};
366
367
48
33
                if (defined $config_file) {
368
36
25
                        for my $layer (@layers) {
369
48
48
23
36
                                my ($explanation, $section, $file_label) = @{$layer};
370
48
43
                                my $entry = _pick($explanation, $section, $key) or next;
371
8
9
                                $row{source} = $entry->{type};
372
8
9
                                $row{label}  = $entry->{type} eq 'env' ? $entry->{label} : $file_label;
373
8
11
                                $winner      = $entry->{value};
374                        }
375                } else {
376                        # No settings file: Object::Configure falls back to environment
377                        # variables only, universal ones first and class ones last.
378
12
8
                        for my $section ($UNIVERSAL_SECTION, $CLASS_SECTION) {
379
24
21
                                my $name = _env_key("${section}__$key") // next;
380
4
7
                                @row{qw(source label)} = ('env', $name);
381
4
5
                                $winner = $ENV{$name};
382                        }
383                }
384
385
48
48
                if (_differs($settings->{$key}, $winner)) {
386
0
0
                        $row{source} = 'merged';
387
0
0
                        $row{label}  = join(' + ', @{ _labels_for($key, map { $_->[0] } @layers) }) || $LABEL_UNKNOWN;
388                }
389
390
48
40
                push @rows, \%row;
391        }
392
393
8
39
        return { settings_file => $config_file, settings => \@rows };
394}
395
396 - 462
=head2 explain_repos

Purpose: how the SSH settings resolve for every repo in a client config.

Args: C<config> (the hashref from C<App::FileSync::Config::load_client>),
C<report> (the result of L</explain>), C<config_file> (the client config path,
used in labels).

Returns: an arrayref, one entry per repo in name order, with C<repo>,
C<transport>, C<server> (when the repo records one) and C<ssh>: one
C<{key, value, source, label}> row per setting, where C<source> is C<repo>
(the repo's own entry), C<process> (the settings reported by C<explain>) or
C<default> (ssh's own default).  C<port> and C<host_key_checking> rows carry
C<repo_only =E<gt> 1>.  C<ssh> is C<undef> for the local transport, which never
runs ssh.

Side Effects: none.

Usage:

        my $repos = App::FileSync::Settings::explain_repos(
                config => $config, report => App::FileSync::Settings::explain(), config_file => $path,
        );

=head3 EXAMPLE

        for my $repo (@{ $repos }) {
                next unless $repo->{ssh};
                print "$repo->{repo}: $_->{key} = $_->{value} ($_->{label})\n" for @{ $repo->{ssh} };
        }

=head3 API SPECIFICATION

=head4 INPUT

        {
                config      => { type => 'hashref', optional => 1 },
                report      => { type => 'hashref', optional => 1 },
                config_file => { type => 'string', optional => 1 },
        }

=head4 OUTPUT

        { type => 'arrayref' }

=head3 MESSAGES

        Key            Meaning                         Resolution
        args.invalid   an argument has the wrong type  fix the call

=head3 FORMAL SPECIFICATION

        âˆ€ r : dom config.repos • ∀ k : REPO_SSH_SETTINGS •
                value(r, k) = repo(r)(k)       if repo(r)(k) ≠ ""
                            = process(k)       else if process(k) ≠ ""
                            = ""               otherwise

=head3 PSEUDOCODE

        for each repo, by name:
                record its transport and server
                if it uses ssh:
                        for each SSH setting take the repo's value, else the process
                        setting, else ssh's default
                        add the repo-only port and host-key-checking rows

=cut
463
464sub explain_repos {
465
3
24
        my $args = named(\@_, {
466                config      => { type => 'hashref', optional => 1 },
467                report      => { type => 'hashref', optional => 1 },
468                config_file => { type => 'string', optional => 1 },
469        }, 'Settings::explain_repos');
470
471
3
8
        my $config = $args->{config} || {};
472
3
18
3
4
21
8
        my %process = map { $_->{key} => $_ } @{ ($args->{report} || {})->{settings} || [] };
473
474
3
6
        my @repos;
475
3
3
3
14
        for my $name (sort keys %{ $config->{repos} || {} }) {
476
9
8
                my $repo = $config->{repos}{$name};
477
9
14
                $repo = {} unless ref($repo) eq 'HASH';
478
479
9
17
                my $entry = { repo => $name, transport => $repo->{transport} || 'ssh', ssh => undef };
480
9
11
                $entry->{server} = $repo->{server} if _set($repo->{server});
481
482                # The local transport never runs ssh, so there is nothing to resolve.
483
9
12
                if ($entry->{transport} eq 'ssh') {
484
6
9
                        my $repo_label = _repo_label($args->{config_file}, $name);
485
6
4
                        my @rows;
486
6
7
                        for my $key (@REPO_SSH_SETTINGS) {
487
18
32
                                my $value   = $repo->{ $REPO_SSH_KEYS{$key} };
488
18
11
                                my $setting = $process{$key};
489
490
18
16
                                if (_set($value)) {
491
3
8
                                        push @rows, { key => $key, value => $value, source => 'repo', label => $repo_label };
492                                } elsif ($setting && _set($setting->{value})) {
493
8
21
                                        push @rows, { key => $key, value => $setting->{value}, source => 'process', label => $setting->{label} };
494                                } else {
495
7
16
                                        push @rows, { key => $key, value => '', source => 'default', label => $LABEL_SSH_DEFAULT };
496                                }
497                        }
498
499                        # The transport omits -p and StrictHostKeyChecking defaults to yes.
500
6
11
                        for my $only (@REPO_ONLY) {
501
12
12
31
14
                                my ($key, $default) = @{$only};
502
12
96
                                my $set = _set($repo->{$key});
503                                push @rows, {
504                                        key       => $key,
505                                        repo_only => 1,
506
12
31
                                        value     => $set ? $repo->{$key} : $default,
507                                        source    => $set ? 'repo' : 'default',
508                                        label     => $set ? $repo_label : $LABEL_SSH_DEFAULT,
509                                };
510                        }
511
6
20
                        $entry->{ssh} = \@rows;
512                }
513
514
9
8
                push @repos, $entry;
515        }
516
517
3
9
        return \@repos;
518}
519
520 - 580
=head2 logger

Purpose: the diagnostics logger for this run.

Args: as L</load>, plus C<level> (overrides the settings), C<verbose> (debug)
and C<quiet> (errors only).  A command-line flag describes this run, so it
beats the settings file.

Returns: a L<Log::Abstraction> writing to standard error, or to C<log_file>.

Side Effects: reads the settings; creates C<log_file>'s directory.

Usage:

        my $logger = App::FileSync::Settings::logger(verbose => $opts{verbose});

=head3 EXAMPLE

        my $logger = App::FileSync::Settings::logger(level => 'info');
        $logger->info('starting');

=head3 API SPECIFICATION

=head4 INPUT

        {
                config_file => { type => 'string', optional => 1 },
                config_dirs => { type => 'arrayref', optional => 1 },
                level       => { type => 'string', optional => 1 },
                verbose     => { type => 'boolean', optional => 1 },
                quiet       => { type => 'boolean', optional => 1 },
        }

=head4 OUTPUT

        { type => 'object', isa => 'Log::Abstraction' }

=head3 MESSAGES

        Key                   Meaning                         Resolution
        settings.bad_level    log_level is not a known level  use one of @VALID_LEVELS
        io.mkdir              log_file's directory cannot be made  fix log_file

=head3 FORMAL SPECIFICATION

        level = debug                      if verbose
              = error                      else if quiet
              = args.level                 else if set
              = settings.log_level         else if set
              = warning                    otherwise
        pre lc level ∈ lc VALID_LEVELS

=head3 PSEUDOCODE

        load the settings
        choose the level: argument, then settings, then the default
        reject an unknown level
        send output to log_file (creating its directory) or standard error
        build the logger; apply --verbose or --quiet over it

=cut
581
582sub logger {
583
597
6138
        my $args = named(\@_, {
584                %FILE_ARGS,
585                level   => { type => 'string', optional => 1 },
586                verbose => { type => 'scalar', optional => 1 },
587                quiet   => { type => 'scalar', optional => 1 },
588        }, 'Settings::logger');
589
590
597
4
1194
1593
6
2329
        my $settings = load(map { $_ => $args->{$_} } grep { exists $args->{$_} } keys %FILE_ARGS);
591
592
597
1194
2792
1606
        my $level = first { _set($_) } $args->{level}, $settings->{log_level}, $DEFAULT_LEVEL;
593        throw('settings.bad_level', { level => $level, valid => join(', ', @VALID_LEVELS) })
594
597
4158
2789
3827
                unless any { lc($_) eq lc($level) } @VALID_LEVELS;
595
596
596
1523
        my $format = _set($settings->{log_format}) ? $settings->{log_format} : $DEFAULT_FORMAT;
597
598
596
1184
        my $destination = { fd => \*STDERR };
599
596
1028
        if (_set($settings->{log_file})) {
600
3
46
                ensure_dir(dirname($settings->{log_file}));
601
3
5
                $destination = { file => $settings->{log_file} };
602        }
603
604
596
1548
        my $logger = Log::Abstraction->new(
605                level          => $level,
606                format         => $format,
607                # Logging must never turn a handled error into a second, uncaught one.
608                croak_on_error => 0,
609                logger         => $destination,
610        );
611
612
596
94276
        $logger = $logger->new(level => 'debug') if $args->{verbose};
613
596
1280
        $logger = $logger->new(level => 'error') if $args->{quiet};
614
615
596
4085
        return $logger;
616}
617
618# _set
619# Purpose:     true for a value that is defined and not empty.
620# Entry:       any scalar.
621# Exit:        returns 1 or 0.
622# Side effects: none.
623sub _set :Private {
624        my ($value) = @_;
625
626        return (defined $value && length $value) ? 1 : 0;
627
318
318
318
1102
306
4597
}
628
629# _default_config_path
630# Purpose:     where the settings file would be when none is given.
631# Entry:       none.
632# Exit:        returns FILESYNC_SETTINGS, else HOME (or USERPROFILE on
633#              Windows) /.conf/app-filesync.json, else undef.
634# Side effects: none; existence is the caller's question.
635sub _default_config_path :Private {
636        my $home = first { _set($_) } @ENV{qw(HOME USERPROFILE)};
637
638        my $path = _set($ENV{FILESYNC_SETTINGS}) ? $ENV{FILESYNC_SETTINGS}
639                : defined $home ? File::Spec->catfile($home, $CONF_DIR, $CONFIG_BASENAME)
640                : undef;
641        return $path;
642
318
318
318
107727
381
2805
}
643
644# _defaults
645# Purpose:     the built-in value of every setting.
646# Entry:       none.
647# Exit:        returns a fresh hashref (callers may change it).
648# Side effects: none.
649sub _defaults :Private {
650        return {
651                log_level       => $DEFAULT_LEVEL,
652                log_format      => $DEFAULT_FORMAT,
653                log_file        => '',
654                ssh_identity    => '',
655                ssh_known_hosts => '',
656                server_command  => $DEFAULT_SERVER_COMMAND,
657        };
658
318
318
318
90881
339
2467
}
659
660# _repo_label
661# Purpose:     the provenance label for a value a repo's own entry supplied.
662# Entry:       $config_file may be undef.
663# Exit:        returns "FILE [name]" or "[name]".
664# Side effects: none.
665sub _repo_label :Private {
666        my ($config_file, $name) = @_;
667
668        return _set($config_file) ? "$config_file [$name]" : "[$name]";
669
318
318
318
88980
374
2478
}
670
671# _differs
672# Purpose:     compare two setting values as strings, undef only equal to undef.
673# Entry:       two scalars.
674# Exit:        returns 1 if they differ, else 0.
675# Side effects: none.
676sub _differs :Private {
677        my ($left, $right) = @_;
678
679        return (defined $left xor defined $right) ? 1
680                : !defined $left ? 0
681                : ("$left" ne "$right") ? 1 : 0;
682
318
318
318
86869
331
2362
}
683
684# _explanation
685# Purpose:     ask Config::Abstraction which sources set each key of a section.
686# Entry:       $file exists; $section is the env prefix's stem.
687# Exit:        returns a hashref (empty when the reader cannot be built).
688# Side effects: reads $file.
689sub _explanation :Private {
690        my ($file, $section) = @_;
691
692        my $reader = Config::Abstraction->new(config_file => $file, env_prefix => "${section}__");
693        my $explanation = $reader ? $reader->explain_sources() : undef;
694        return ref($explanation) eq 'HASH' ? $explanation : {};
695
318
318
318
94168
351
2487
}
696
697# _env_key
698# Purpose:     the name the environment actually uses for a variable.
699# Entry:       $name is "prefix__key".
700# Exit:        returns the matching %ENV key, or undef.
701# Side effects: none.
702# Config::Abstraction matches an env_prefix case-insensitively, and Windows
703# reports environment names upper-cased, so the report must name the variable
704# in the spelling that actually supplied the value.
705sub _env_key :Private {
706        my ($name) = @_;
707
708        return exists $ENV{$name} ? $name : first { lc $_ eq lc $name } sort keys %ENV;
709
318
318
318
89453
332
2455
}
710
711# _pick
712# Purpose:     the source that set a key in one section.
713# Entry:       $explanation is from _explanation.
714# Exit:        returns { type, label, value } or undef.
715# Side effects: none.
716# The last source wins, which is how Config::Abstraction layers a file under
717# its environment variables.
718sub _pick :Private {
719        my ($explanation, $section, $key) = @_;
720
721        for my $full ("$section.$key", "global.$key", $key) {
722                my $entry = $explanation->{$full};
723                next unless ref($entry) eq 'HASH' && ref($entry->{sources}) eq 'ARRAY' && @{ $entry->{sources} };
724
725                my $winner = $entry->{sources}[-1];
726                return { map { $_ => $winner->{$_} } qw(type label value) };
727        }
728
729        return undef;   ## no critic (Subroutines::ProhibitExplicitReturnUndef) -- callers test it in scalar context
730
318
318
318
101981
345
2593
}
731
732# _labels_for
733# Purpose:     every source label that mentions a key, for a merged value.
734# Entry:       explanations from _explanation.
735# Exit:        returns an arrayref of distinct labels, in order found.
736# Side effects: none.
737sub _labels_for :Private {
738        my ($key, @explanations) = @_;
739
740        my (%seen, @labels);
741        for my $explanation (@explanations) {
742                for my $full (grep { $_ eq $key || /\.\Q$key\E\z/ } sort keys %{$explanation}) {
743                        push @labels, grep { _set($_) && !$seen{$_}++ }
744                                map { $_->{label} } @{ $explanation->{$full}{sources} || [] };
745                }
746        }
747
748        return \@labels;
749
318
318
318
109311
323
2599
}
750
751# _universal_file
752# Purpose:     the universal settings file Object::Configure would read.
753# Entry:       $config_file is the settings file.
754# Exit:        returns the first readable candidate, or undef.
755# Side effects: none.
756# Mirrors Object::Configure's search: universal<ext> beside the settings
757# file, then the other config-file extensions, so the report matches.
758sub _universal_file :Private {
759        my ($config_file) = @_;
760
761        my ($volume, $directory) = File::Spec->splitpath($config_file);
762        my $base = File::Spec->catpath($volume, $directory, '');
763        my $ext = $config_file =~ /(\.[^.\/\\]+)\z/ ? $1 : '';
764
765        my %seen;
766        return first { -f $_ && -r $_ }
767                map { File::Spec->catfile($base, "universal$_") }
768                grep { !$seen{$_}++ } ($ext, @UNIVERSAL_EXTENSIONS);
769
318
318
318
110486
308
2769
}
770
7711;
772