TER1 (Statement): 98.84%
TER2 (Branch): 88.00%
TER3 (LCSAJ): 83.3% (5/6)
Approximate LCSAJ segments: 51
โ Covered โ this LCSAJ path was executed during testing.
โ Not covered โ this LCSAJ path was never executed. These are the paths to focus on.
Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:
start โ end โ jump
Uncovered paths show [NOT COVERED] in the tooltip.
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: use App::FileSync::Platform; 11: 12: use strict; 13: use warnings; 14: use autodie qw(:all); 15: 16: use Config::Abstraction; 17: use File::Basename qw(dirname); 18: use File::Spec; 19: use List::Util qw(any first); 20: use Log::Abstraction; 21: use Object::Configure; 22: use Readonly; 23: use Sub::Private { mode => 'enforce' }; 24: 25: use App::FileSync::Args qw(named); 26: use App::FileSync::I18N qw(throw); 27: use App::FileSync::IO qw(ensure_dir); 28: 29: 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: 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.Mutants (Total: 2, Killed: 2, Survived: 0)
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: );Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_49_5: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes๐งช Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_49_5: Replace return expression with undef
LOW: Mutation survived, but impact may be minor๐งช Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );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: );Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_51_5: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes๐งช Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_51_5: Replace return expression with undef
LOW: Mutation survived, but impact may be minor๐งช Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );Mutants (Total: 2, Killed: 2, Survived: 0)
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 = (Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_60_5: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes๐งช Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_60_5: Replace return expression with undef
LOW: Mutation survived, but impact may be minor๐งช Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );Mutants (Total: 2, Killed: 2, Survived: 0)
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
Mutants (Total: 2, Killed: 2, Survived: 0)
92: 93: =head1 NAME 94: 95: App::FileSync::Settings - process-wide settings and logging for App::FileSync 96: 97: =head1 SYNOPSIS 98: 99: use App::FileSync::Settings; 100: 101: my $logger = App::FileSync::Settings::logger(verbose => 1); 102: $logger->error('...'); 103: 104: my $settings = App::FileSync::Settings::load(); 105: my $level = $settings->{log_level}; 106: 107: my $report = App::FileSync::Settings::explain(); 108: printf "%s = %s (%s)\n", $_->{key}, $_->{value}, $_->{label} 109: for @{ $report->{settings} }; 110: 111: =head1 DESCRIPTION 112: 113: Settings that apply to the process rather than to one repo - the log level, 114: format and destination, plus the SSH identity, known-hosts file and remote 115: server command - are read through L<Object::Configure>. The repos themselves 116: remain in F<~/.filesync/config.json>, which C<filesync init> writes; this 117: module does not replace that store. 118: 119: The settings file is F<~/.conf/app-filesync.json> when it exists, or the path in 120: C<FILESYNC_SETTINGS>. The section key is C<App__FileSync>, and a C<UNIVERSAL> 121: section in the same directory applies to every class that uses 122: Object::Configure. Environment variables named C<App__FileSync__I<key>>
Mutants (Total: 1, Killed: 1, Survived: 0)
123: override the file: 124: 125: # ~/.conf/app-filesync.json 126: { "App__FileSync": { "log_level": "debug", "log_format": "%level%: %message%" } } 127: 128: # or 129: export App__FileSync__log_level=debug 130: 131: C<log_file> sends the log to a file instead of standard error; leading 132: directories are created. C<log_format> is a L<Log::Abstraction> format string. 133: 134: C<ssh_identity>, C<ssh_known_hosts> and C<server_command> are the defaults for 135: every SSH repo, so a per-repo C<identity>, C<known_hosts> or C<server_command> 136: (written by C<filesync init --identity> and friends) only has to be set when it 137: differs. C<server_command> defaults to C<filesync-server>: 138: 139: { "App__FileSync": {
Mutants (Total: 1, Killed: 1, Survived: 0)
140: "ssh_identity" : "~/.ssh/filesync_ed25519",
Mutants (Total: 1, Killed: 1, Survived: 0)
141: "ssh_known_hosts" : "~/.ssh/filesync_known_hosts", 142: "server_command" : "/usr/local/bin/filesync-server" 143: } } 144: 145: Command output (sync summaries, plans, C<--json>) is written to standard output 146: by the commands themselves; the logger carries diagnostics on standard error, 147: so a cron job can separate the two. C<--verbose> raises the level to C<debug>
Mutants (Total: 1, Killed: 1, Survived: 0)
148: and C<--quiet> lowers it to C<error> for that run, overriding the file. 149: 150: C<wants_plain_end_of_scope_hook> and C<use_plain_end_of_scope_hook> are aliases 151: of the functions in L<App::FileSync::Platform>, kept for existing callers. 152: 153: =head1 FUNCTIONS 154: 155: =head2 config_file 156: 157: Purpose: the settings file that would be used. 158: 159: Args: an optional explicit path. 160: 161: Returns: the explicit path when one is given; otherwise C<FILESYNC_SETTINGS> or 162: F<~/.conf/app-filesync.json> if that file exists; otherwise C<undef>. 163: 164: Side Effects: none. 165: 166: Usage: 167: 168: my $file = App::FileSync::Settings::config_file();
Mutants (Total: 1, Killed: 1, Survived: 0)
169: 170: =head3 EXAMPLE 171: 172: my $file = App::FileSync::Settings::config_file() // 'none'; 173: print "settings: $file\n"; 174: 175: =head3 API SPECIFICATION 176: 177: =head4 INPUT 178: 179: [ { name => 'given', type => 'string', optional => 1 } ] 180: 181: =head4 OUTPUT 182: 183: { type => 'string', optional => 1 } 184: 185: =head3 MESSAGES 186: 187: None. 188: 189: =head3 FORMAL SPECIFICATION 190: 191: config_file(g) = g if g â "" 192: config_file(g) = d if d = default_path â§ d â dom FS 193: config_file(g) = ⥠otherwise 194: 195: =cut 196: 197: sub config_file { 198: my ($given) = @_; 199: 200: return $given if defined $given && length $given; 201: 202: # A stale FILESYNC_SETTINGS must not fail every command: it is only a default. 203: my $path = _default_config_path(); 204: return (defined $path && -e $path) ? $path : undef; 205: } 206: 207: =head2 load 208: 209: Purpose: the merged process settings.
Mutants (Total: 1, Killed: 1, Survived: 0)
210: 211: Args: optional C<config_file> and C<config_dirs> (as for 212: L<Object::Configure/configure>). 213: 214: Returns: a hashref with every key in C<@SETTING_KEYS>, built-in defaults 215: overlaid by the settings file, the C<UNIVERSAL> section and C<App__FileSync__*> 216: environment variables. 217:
Mutants (Total: 1, Killed: 1, Survived: 0)
218: Side Effects: reads the settings file. Dies (through Object::Configure) when 219: an explicit file does not exist. 220: 221: Usage: 222: 223: my $settings = App::FileSync::Settings::load(config_file => $path); 224: 225: =head3 EXAMPLE 226: 227: print App::FileSync::Settings::load()->{log_level}, "\n"; # warning 228: 229: =head3 API SPECIFICATION 230: 231: =head4 INPUT 232: 233: { 234: config_file => { type => 'string', optional => 1 }, 235: config_dirs => { type => 'arrayref', optional => 1 }, 236: } 237: 238: =head4 OUTPUT 239: 240: { type => 'hashref' } 241: 242: =head3 MESSAGES 243: 244: Key Meaning Resolution 245: args.invalid an argument has the wrong type fix the call 246: 247: Object::Configure's own error for a missing explicit file is passed through. 248: 249: =head3 FORMAL SPECIFICATION
Mutants (Total: 2, Killed: 2, Survived: 0)
250: 251: load(a) = defaults â file(config_file(a)) â universal â env 252: 253: =cut 254: 255: sub load { 256: my $args = named(\@_, {%FILE_ARGS}, 'Settings::load'); 257:
Mutants (Total: 1, Killed: 1, Survived: 0)
258: my $config_file = config_file($args->{config_file}); 259: 260: my %params = ( 261: %{ _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: $params{config_file} = $config_file if defined $config_file; 267: $params{config_dirs} = $args->{config_dirs} if $args->{config_dirs}; 268: 269: my $settings = Object::Configure::configure('App::FileSync', \%params);
Mutants (Total: 2, Killed: 2, Survived: 0)
270: delete $settings->{logger}; 271: 272: return $settings; 273: } 274: 275: =head2 explain
276:Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_275_5: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes๐งช Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_275_5: Replace return expression with undef
LOW: Mutation survived, but impact may be minor๐งช Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );277: Purpose: where each effective setting came from, for C<filesync settings>.Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_276_5: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes๐งช Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_276_5: Replace return expression with undef
LOW: Mutation survived, but impact may be minor๐งช Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );Mutants (Total: 2, Killed: 2, Survived: 0)
278: 279: Args: as L</load>. 280: 281: Returns: C<{ settings_file =E<gt> $path_or_undef, settings =E<gt> \@rows }>, one 282: row per key in C<@SETTING_KEYS>. Each row has the effective C<value>, a 283: C<source> of C<default>, C<file>, C<env> or C<merged>, and a human-readable 284: C<label> naming the file section, environment variable or built-in default 285: that supplied it. 286: 287: The values are whatever L</load> returns; the provenance is reconstructed from 288: the same layers Object::Configure merges. A value no modelled layer accounts 289: for (a C<global> section, say) is reported as C<merged> rather than claimed as 290: a default.
Mutants (Total: 2, Killed: 2, Survived: 0)
291: 292: Side Effects: reads the settings file and any universal file beside it. 293: 294: Usage: 295: 296: my $report = App::FileSync::Settings::explain(); 297: 298: =head3 EXAMPLE 299: 300: for my $row (@{ App::FileSync::Settings::explain()->{settings} }) { 301: printf "%-16s %-20s %s\n", $row->{key}, $row->{value}, $row->{label};
Mutants (Total: 2, Killed: 2, Survived: 0)
302: } 303: 304: =head3 API SPECIFICATION 305:
Mutants (Total: 2, Killed: 2, Survived: 0)
306: =head4 INPUT 307: 308: {
309: config_file => { type => 'string', optional => 1 }, 310: config_dirs => { type => 'arrayref', optional => 1 }, 311: } 312: 313: =head4 OUTPUT 314: 315: { 316: type => 'hashref', 317: schema => { 318: settings_file => { type => 'string', optional => 1 }, 319: settings => { type => 'arrayref' }, 320: }, 321: } 322: 323: =head3 MESSAGES 324: 325: As L</load>. 326: 327: =head3 FORMAL SPECIFICATION 328: 329: â k : SETTING_KEYS ⢠330: row(k).value = load()(k)Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_308_5: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes๐งช Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_308_5: Replace return expression with undef
LOW: Mutation survived, but impact may be minor๐งช Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );Mutants (Total: 2, Killed: 2, Survived: 0)
331: row(k).source = last layer â â¨default, universal, classâ© that sets k, 332: or merged if that layer's value â row(k).value 333: 334: =head3 PSEUDOCODE 335: 336: load the settings 337: explain the class and universal sections of the settings file, if any 338: for each key: 339: start from the built-in default 340: let the universal section, then the class section, override it 341: without a file, let UNIVERSAL__key then App__FileSync__key override it 342: if the loaded value differs from the winner, call it merged 343: 344: =cut 345: 346: sub explain { โ347 โ 355 โ 362 347: my $args = named(\@_, {%FILE_ARGS}, 'Settings::explain'); 348:
349: my $config_file = config_file($args->{config_file}); 350: my $settings = load(%{$args}); 351: my $defaults = _defaults(); 352: 353: # Each layer is [ section explanation, label for a file entry ], lowest first. 354: my @layers; 355: if (defined $config_file) { 356: my $universal = _universal_file($config_file); 357: push @layers, [ _explanation($universal, $UNIVERSAL_SECTION), $UNIVERSAL_SECTION, "$universal [$UNIVERSAL_SECTION]" ] 358: if defined $universal; 359: push @layers, [ _explanation($config_file, $CLASS_SECTION), $CLASS_SECTION, "$config_file [$CLASS_SECTION]" ]; 360: } 361: โ362 โ 363 โ 393 362: my @rows; 363: for my $key (@SETTING_KEYS) { 364: my %row = (key => $key, value => $settings->{$key}, source => 'default', label => $LABEL_DEFAULT); 365: my $winner = $defaults->{$key}; 366: 367: if (defined $config_file) { 368: for my $layer (@layers) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_348_5: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes๐งช Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_348_5: Replace return expression with undef
LOW: Mutation survived, but impact may be minor๐งช Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );Mutants (Total: 2, Killed: 2, Survived: 0)
369: my ($explanation, $section, $file_label) = @{$layer}; 370: my $entry = _pick($explanation, $section, $key) or next; 371: $row{source} = $entry->{type};
372: $row{label} = $entry->{type} eq 'env' ? $entry->{label} : $file_label; 373: $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: for my $section ($UNIVERSAL_SECTION, $CLASS_SECTION) { 379: my $name = _env_key("${section}__$key") // next; 380: @row{qw(source label)} = ('env', $name); 381: $winner = $ENV{$name}; 382: } 383: } 384: 385: if (_differs($settings->{$key}, $winner)) { 386: $row{source} = 'merged'; 387: $row{label} = join(' + ', @{ _labels_for($key, map { $_->[0] } @layers) }) || $LABEL_UNKNOWN; 388: } 389: 390: push @rows, \%row; 391: } 392: 393: return { settings_file => $config_file, settings => \@rows };Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_371_5: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes๐งช Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_371_5: Replace return expression with undef
LOW: Mutation survived, but impact may be minor๐งช Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );Mutants (Total: 1, Killed: 1, Survived: 0)
394: } 395: 396: =head2 explain_repos 397: 398: Purpose: how the SSH settings resolve for every repo in a client config. 399: 400: Args: C<config> (the hashref from C<App::FileSync::Config::load_client>), 401: C<report> (the result of L</explain>), C<config_file> (the client config path, 402: used in labels). 403: 404: Returns: an arrayref, one entry per repo in name order, with C<repo>, 405: C<transport>, C<server> (when the repo records one) and C<ssh>: one 406: C<{key, value, source, label}> row per setting, where C<source> is C<repo> 407: (the repo's own entry), C<process> (the settings reported by C<explain>) or 408: C<default> (ssh's own default). C<port> and C<host_key_checking> rows carry 409: C<repo_only =E<gt> 1>. C<ssh> is C<undef> for the local transport, which never 410: runs ssh. 411: 412: Side Effects: none. 413: 414: Usage:
Mutants (Total: 2, Killed: 2, Survived: 0)
415: 416: my $repos = App::FileSync::Settings::explain_repos( 417: config => $config, report => App::FileSync::Settings::explain(), config_file => $path, 418: ); 419: 420: =head3 EXAMPLE 421: 422: for my $repo (@{ $repos }) { 423: next unless $repo->{ssh}; 424: print "$repo->{repo}: $_->{key} = $_->{value} ($_->{label})\n" for @{ $repo->{ssh} }; 425: } 426: 427: =head3 API SPECIFICATION 428: 429: =head4 INPUT 430: 431: { 432: config => { type => 'hashref', optional => 1 }, 433: report => { type => 'hashref', optional => 1 }, 434: config_file => { type => 'string', optional => 1 }, 435: } 436: 437: =head4 OUTPUT 438: 439: { type => 'arrayref' } 440: 441: =head3 MESSAGES 442: 443: Key Meaning Resolution 444: args.invalid an argument has the wrong type fix the call 445: 446: =head3 FORMAL SPECIFICATION 447: 448: â r : dom config.repos ⢠â k : REPO_SSH_SETTINGS ⢠449: value(r, k) = repo(r)(k) if repo(r)(k) â "" 450: = process(k) else if process(k) â "" 451: = "" otherwise 452: 453: =head3 PSEUDOCODE 454: 455: for each repo, by name: 456: record its transport and server 457: if it uses ssh: 458: for each SSH setting take the repo's value, else the process 459: setting, else ssh's default 460: add the repo-only port and host-key-checking rows 461: 462: =cut 463: 464: sub explain_repos { โ465 โ 475 โ 517 465: 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: my $config = $args->{config} || {}; 472: my %process = map { $_->{key} => $_ } @{ ($args->{report} || {})->{settings} || [] }; 473: 474: my @repos; 475: for my $name (sort keys %{ $config->{repos} || {} }) { 476: my $repo = $config->{repos}{$name}; 477: $repo = {} unless ref($repo) eq 'HASH'; 478: 479: my $entry = { repo => $name, transport => $repo->{transport} || 'ssh', ssh => undef }; 480: $entry->{server} = $repo->{server} if _set($repo->{server}); 481: 482: # The local transport never runs ssh, so there is nothing to resolve. 483: if ($entry->{transport} eq 'ssh') { 484: my $repo_label = _repo_label($args->{config_file}, $name); 485: my @rows; 486: for my $key (@REPO_SSH_SETTINGS) { 487: my $value = $repo->{ $REPO_SSH_KEYS{$key} }; 488: my $setting = $process{$key}; 489: 490: if (_set($value)) { 491: push @rows, { key => $key, value => $value, source => 'repo', label => $repo_label }; 492: } elsif ($setting && _set($setting->{value})) { 493: push @rows, { key => $key, value => $setting->{value}, source => 'process', label => $setting->{label} }; 494: } else { 495: 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: for my $only (@REPO_ONLY) { 501: my ($key, $default) = @{$only}; 502: my $set = _set($repo->{$key}); 503: push @rows, { 504: key => $key, 505: repo_only => 1, 506: value => $set ? $repo->{$key} : $default, 507: source => $set ? 'repo' : 'default', 508: label => $set ? $repo_label : $LABEL_SSH_DEFAULT, 509: }; 510: } 511: $entry->{ssh} = \@rows; 512: } 513: 514: push @repos, $entry; 515: } 516: 517: return \@repos; 518: } 519: 520: =head2 logger 521: 522: Purpose: the diagnostics logger for this run. 523: 524: Args: as L</load>, plus C<level> (overrides the settings), C<verbose> (debug) 525: and C<quiet> (errors only). A command-line flag describes this run, so it 526: beats the settings file. 527: 528: Returns: a L<Log::Abstraction> writing to standard error, or to C<log_file>. 529: 530: Side Effects: reads the settings; creates C<log_file>'s directory. 531: 532: Usage: 533: 534: my $logger = App::FileSync::Settings::logger(verbose => $opts{verbose}); 535: 536: =head3 EXAMPLE 537: 538: my $logger = App::FileSync::Settings::logger(level => 'info'); 539: $logger->info('starting'); 540: 541: =head3 API SPECIFICATION 542: 543: =head4 INPUT 544: 545: { 546: config_file => { type => 'string', optional => 1 }, 547: config_dirs => { type => 'arrayref', optional => 1 }, 548: level => { type => 'string', optional => 1 }, 549: verbose => { type => 'boolean', optional => 1 }, 550: quiet => { type => 'boolean', optional => 1 }, 551: } 552: 553: =head4 OUTPUT 554: 555: { type => 'object', isa => 'Log::Abstraction' } 556: 557: =head3 MESSAGES 558: 559: Key Meaning Resolution 560: settings.bad_level log_level is not a known level use one of @VALID_LEVELS 561: io.mkdir log_file's directory cannot be made fix log_file 562: 563: =head3 FORMAL SPECIFICATION 564: 565: level = debug if verbose 566: = error else if quiet 567: = args.level else if set 568: = settings.log_level else if set 569: = warning otherwise 570: pre lc level â lc VALID_LEVELS 571: 572: =head3 PSEUDOCODE 573: 574: load the settings 575: choose the level: argument, then settings, then the default 576: reject an unknown level 577: send output to log_file (creating its directory) or standard error 578: build the logger; apply --verbose or --quiet over it 579: 580: =cut 581: 582: sub logger { โ583 โ 599 โ 604 583: 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: my $settings = load(map { $_ => $args->{$_} } grep { exists $args->{$_} } keys %FILE_ARGS); 591: 592: my $level = first { _set($_) } $args->{level}, $settings->{log_level}, $DEFAULT_LEVEL; 593: throw('settings.bad_level', { level => $level, valid => join(', ', @VALID_LEVELS) }) 594: unless any { lc($_) eq lc($level) } @VALID_LEVELS; 595: 596: my $format = _set($settings->{log_format}) ? $settings->{log_format} : $DEFAULT_FORMAT; 597: 598: my $destination = { fd => \*STDERR }; 599: if (_set($settings->{log_file})) { 600: ensure_dir(dirname($settings->{log_file})); 601: $destination = { file => $settings->{log_file} }; 602: } 603: 604: 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: $logger = $logger->new(level => 'debug') if $args->{verbose}; 613: $logger = $logger->new(level => 'error') if $args->{quiet}; 614: 615: 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: } 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: } 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: } 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: } 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: } 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: } 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: } 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 โ 721 โ 729 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: } 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 { โ[NOT COVERED] 738 โ 741 โ 748 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: } 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: } 770: 771: 1; 772: 773: __END__ 774: 775: =head1 LIMITATIONS 776: 777: =over 4 778: 779: =item * C<explain> reconstructs provenance by re-reading the layers 780: Object::Configure merges. If a later Object::Configure adds a layer, the 781: report says C<merged> for values from it rather than naming it. 782: 783: =item * The settings are re-read on every C<load>, C<explain> and C<logger> 784: call; nothing is cached, so a long-running process sees edits but pays a file 785: read per call. 786: 787: =back 788: 789: =head1 AUTHOR 790: 791: Nigel Horne, C<< <njh at nigelhorne.com> >> 792: 793: =head1 LICENCE AND COPYRIGHT 794: 795: Copyright 2026 Nigel Horne. 796: 797: Usage is subject to the GPL2 licence terms. 798: If you use it, 799: please let me know. 800: 801: =cut