| File: | blib/lib/App/FileSync/Settings.pm |
| Coverage: | 93.8% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package 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. | |||||
| 40 | our $DEFAULT_FORMAT = '%level%: %message%'; | |||||
| 41 | our $DEFAULT_LEVEL = 'warning'; | |||||
| 42 | our $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. | |||||
| 46 | our $DEFAULT_SERVER_COMMAND = 'filesync-server'; | |||||
| 47 | ||||||
| 48 | our @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. | |||||
| 54 | our @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. | |||||
| 61 | our %REPO_SSH_KEYS = ( | |||||
| 62 | ssh_identity => 'identity', | |||||
| 63 | ssh_known_hosts => 'known_hosts', | |||||
| 64 | server_command => 'server_command', | |||||
| 65 | ); | |||||
| 66 | our @REPO_SSH_SETTINGS = qw(ssh_identity ssh_known_hosts server_command); | |||||
| 67 | ||||||
| 68 | # Section names Object::Configure and Config::Abstraction use. | |||||
| 69 | Readonly my $CLASS_SECTION => 'App__FileSync'; | |||||
| 70 | Readonly my $UNIVERSAL_SECTION => 'UNIVERSAL'; | |||||
| 71 | Readonly 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. | |||||
| 75 | Readonly my @REPO_ONLY => ([ port => '22' ], [ host_key_checking => 'yes' ]); | |||||
| 76 | ||||||
| 77 | # Provenance labels for "filesync settings". | |||||
| 78 | Readonly my $LABEL_DEFAULT => 'built-in default'; | |||||
| 79 | Readonly my $LABEL_SSH_DEFAULT => 'ssh default'; | |||||
| 80 | Readonly my $LABEL_UNKNOWN => 'unknown source'; | |||||
| 81 | ||||||
| 82 | # Object::Configure's search order for a universal file beside the settings file. | |||||
| 83 | Readonly my @UNIVERSAL_EXTENSIONS => qw(.conf .yml .yaml .json); | |||||
| 84 | ||||||
| 85 | # The arguments load, explain and logger share. | |||||
| 86 | my %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 | ||||||
| 197 | sub 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 | ||||||
| 255 | sub 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 | ||||||
| 346 | sub 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 | ||||||
| 464 | sub 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 | ||||||
| 582 | sub 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. | |||||
| 623 | sub _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. | |||||
| 635 | sub _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. | |||||
| 649 | sub _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. | |||||
| 665 | sub _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. | |||||
| 676 | sub _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. | |||||
| 689 | sub _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. | |||||
| 705 | sub _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. | |||||
| 718 | sub _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. | |||||
| 737 | sub _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. | |||||
| 758 | sub _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 | ||||||
| 771 | 1; | |||||
| 772 | ||||||