Subversion Repositories perlutils

Rev

Go to most recent revision | Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
2 rodolico 1
#!/usr/bin/env perl
2
 
3
# Simplified BSD License (FreeBSD License)
4
#
5
# Copyright (c) 2026, Daily Data Inc.
6
# All rights reserved.
7
#
8
# Redistribution and use in source and binary forms, with or without
9
# modification, are permitted provided that the following conditions are met:
10
#
11
# 1. Redistributions of source code must retain the above copyright notice, this
12
#    list of conditions and the following disclaimer.
13
#
14
# 2. Redistributions in binary form must reproduce the above copyright notice,
15
#    this list of conditions and the following disclaimer in the documentation
16
#    and/or other materials provided with the distribution.
17
#
18
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
 
29
# perlConfigFileUtility
30
# Utility to merge template configuration structures with existing config files,
31
# edit configurations interactively, and convert between YAML and JSON formats.
32
# Supports reading Perl data structures as templates and YAML/JSON as config files.
33
# Provides interactive hierarchical editor for modifying configuration values.
34
# Can add new keys to hashes and elements to arrays dynamically.
35
# Automatically backs up config files when saving changes.
36
#
37
# Author: R. W. Rodolico <rodo@dailydata.net>
38
# Created: January 2026
39
#
40
# Revision History:
41
# Version: 1.1.0 RWR 2026-01-15
42
# Added ability to delete keys from hashes and elements from arrays
43
# Version: 1.0 RWR 2026-01-13
44
# Initial release with merge, edit, and format conversion capabilities
45
 
46
use strict;
47
use warnings;
48
use File::Slurp qw(read_file);
49
use Data::Dumper;
50
use Getopt::Long qw(GetOptions);
51
Getopt::Long::Configure("bundling");
52
 
53
our $VERSION = '1.1.0';
54
 
55
# ============================================================================
56
# FUNCTIONS
57
# ============================================================================
58
 
59
## Get a display tag showing the type and value of a data structure element.
60
## Returns formatted string indicating if value is a hash, array, or scalar.
61
##
62
## Arguments:
63
##   $value - The value to analyze
64
##
65
## Returns:
66
##   String: '[HASH]' for hashrefs, '[ARRAY]' for arrayrefs,
67
##           'value' for defined scalars, '<undef>' for undefined
68
sub getTypeTag {
69
   my ($value) = @_;
70
   my $ref = ref($value);
71
   return '[HASH]' if $ref eq 'HASH';
72
   return '[ARRAY]' if $ref eq 'ARRAY';
73
   return defined($value) ? "'$value'" : '<undef>';
74
}
75
 
76
## Display sorted hash keys with index numbers and type tags.
77
## Shows each key with its value type for user selection in interactive mode.
78
##
79
## Arguments:
80
##   $hash - Hashref to display
81
##   $path - String path to current location in data structure
82
##
83
## Returns:
84
##   Array of sorted key names
85
sub displayHashKeys {
86
   my ($hash, $path) = @_;
87
   $path //= 'root';
88
 
89
   print "\n" . "=" x 70 . "\n";
90
   print "Editing: $path\n";
91
   print "=" x 70 . "\n";
92
 
93
   my @keys = sort keys %$hash;
94
 
95
   if (@keys == 0) {
96
      print "  (empty hash)\n";
97
      return ();
98
   }
99
 
100
   for my $i (0 .. $#keys) {
101
      my $key = $keys[$i];
102
      my $typeTag = getTypeTag($hash->{$key});
103
      printf "  %2d) %-30s %s\n", $i + 1, $key, $typeTag;
104
   }
105
 
106
   return @keys;
107
}
108
 
109
## Display array elements with index numbers and type tags.
110
## Shows each element's index and value type for user selection.
111
##
112
## Arguments:
113
##   $array - Arrayref to display
114
##   $path  - String path to current location in data structure
115
##
116
## Returns:
117
##   Nothing (void)
118
sub displayArrayElements {
119
   my ($array, $path) = @_;
120
   $path //= 'root';
121
 
122
   print "\n" . "=" x 70 . "\n";
123
   print "Editing array: $path\n";
124
   print "=" x 70 . "\n";
125
 
126
   if (@$array == 0) {
127
      print "  (empty array)\n";
128
      return;
129
   }
130
 
131
   for my $i (0 .. $#$array) {
132
      my $typeTag = getTypeTag($array->[$i]);
133
      printf "  %2d) [%d] %s\n", $i + 1, $i, $typeTag;
134
   }
135
}
136
 
137
## Get and validate user's menu choice.
138
## Accepts numeric selection, back/quit commands, and optional add/delete commands.
139
##
140
## Arguments:
141
##   $prompt       - String prompt to display
142
##   $max          - Integer maximum valid selection
143
##   $allow_add    - Boolean whether to allow 'add' command
144
##   $allow_delete - Boolean whether to allow 'delete' command
145
##
146
## Returns:
147
##   Integer (0-based index), 'quit', 'back', 'add', 'delete', or undef for invalid input
148
sub getUserChoice {
149
   my ($prompt, $max, $allowAdd, $allowDelete) = @_;
150
 
151
   my $options = "1-$max, 0=back";
152
   $options .= ", a=add new" if $allowAdd;
153
   $options .= ", d=delete" if $allowDelete;
154
   $options .= ", q=quit";
155
 
156
   print "\n$prompt ($options): ";
157
   my $input = <STDIN>;
158
   chomp $input;
159
 
160
   return 'quit' if $input =~ /^q/i;
161
   return 'back' if $input eq '0' || $input eq '';
162
   return 'add' if $allowAdd && $input =~ /^a/i;
163
   return 'delete' if $allowDelete && $input =~ /^d/i;
164
 
165
   if ($input =~ /^\d+$/ && $input >= 1 && $input <= $max) {
166
      return $input - 1;  # Convert to 0-based index
167
   }
168
 
169
   print "Invalid choice. Please enter a valid option.\n";
170
   return undef;
171
}
172
 
173
## Prompt user to select type for a new value.
174
## Displays menu of scalar, hash, or array options.
175
##
176
## Arguments:
177
##   None
178
##
179
## Returns:
180
##   String: 'scalar', 'hash', or 'array'
181
sub getValueType {
182
   print "\nSelect value type:\n";
183
   print "  1) Scalar (string/number)\n";
184
   print "  2) Hash (key-value pairs)\n";
185
   print "  3) Array (list of items)\n";
186
   print "Enter choice (1-3): ";
187
 
188
   my $input = <STDIN>;
189
   chomp $input;
190
 
191
   return 'scalar' if $input eq '1';
192
   return 'hash' if $input eq '2';
193
   return 'array' if $input eq '3';
194
 
195
   print "Invalid choice. Defaulting to scalar.\n";
196
   return 'scalar';
197
}
198
 
199
## Create a new value of the specified type.
200
## For scalars, prompts for input. For hashes/arrays, returns empty structure.
201
##
202
## Arguments:
203
##   $type - String: 'scalar', 'hash', or 'array'
204
##
205
## Returns:
206
##   New value: hashref ({}), arrayref ([]), or user-entered string
207
sub createNewValue {
208
   my ($type) = @_;
209
 
210
   if ($type eq 'hash') {
211
      return {};
212
   } elsif ($type eq 'array') {
213
      return [];
214
   } else {
215
      print "Enter value: ";
216
      my $value = <STDIN>;
217
      chomp $value;
218
      return $value;
219
   }
220
}
221
 
222
## Edit a scalar value interactively.
223
## Displays current value and prompts for new value.
224
##
225
## Arguments:
226
##   $value - Current scalar value
227
##   $key   - Key name in parent structure
228
##   $path  - String path to current location
229
##
230
## Returns:
231
##   New value (original if user pressed Enter without input)
232
sub editScalar {
233
   my ($value, $key, $path) = @_;
234
 
235
   print "\n" . "-" x 70 . "\n";
236
   print "Editing: $path.$key\n";
237
   print "Current value: " . (defined($value) ? "'$value'" : '<undef>') . "\n";
238
   print "Enter new value (or press Enter to keep current): ";
239
 
240
   my $input = <STDIN>;
241
   chomp $input;
242
 
243
   if ($input ne '') {
244
      print "Value updated.\n";
245
      return $input;
246
   }
247
 
248
   return $value;
249
}
250
 
251
## Interactively edit a hash structure.
252
## Displays keys, allows selection and editing of values, supports adding and deleting keys.
253
## Recursively calls itself for nested hashes and edit_array for arrays.
254
##
255
## Arguments:
256
##   $hash - Hashref to edit
257
##   $path - String path to current location (default 'root')
258
##
259
## Returns:
260
##   Nothing (modifies $hash in place)
261
sub editHash {
262
   my ($hash, $path) = @_;
263
   $path //= 'root';
264
 
265
   while (1) {
266
      my @keys = displayHashKeys($hash, $path);
267
 
268
      my $choice = getUserChoice("Select key to edit", scalar(@keys) || 1, 1, scalar(@keys) > 0);
269
 
270
      return if !defined $choice;
271
      return if $choice eq 'back';
272
      exit 0 if $choice eq 'quit';
273
 
274
      if ($choice eq 'add') {
275
         print "\nEnter new key name: ";
276
         my $newKey = <STDIN>;
277
         chomp $newKey;
278
 
279
         if ($newKey eq '') {
280
            print "Invalid key name.\n";
281
            next;
282
         }
283
 
284
         if (exists $hash->{$newKey}) {
285
            print "Key '$newKey' already exists.\n";
286
            next;
287
         }
288
 
289
         my $valueType = getValueType();
290
         my $newValue = createNewValue($valueType);
291
         $hash->{$newKey} = $newValue;
292
         print "Key '$newKey' added.\n";
293
 
294
         # If it's a hash or array, allow immediate editing
295
         if ($valueType eq 'hash' || $valueType eq 'array') {
296
            my $newPath = $path eq 'root' ? $newKey : "$path.$newKey";
297
            if ($valueType eq 'hash') {
298
               editHash($newValue, $newPath);
299
            } else {
300
               editArray($newValue, $newPath);
301
            }
302
         }
303
         next;
304
      }
305
 
306
      if ($choice eq 'delete') {
307
         return if @keys == 0;  # Empty hash, nothing to delete
308
 
309
         print "\nSelect key to delete (1-" . scalar(@keys) . ", 0=cancel): ";
310
         my $delInput = <STDIN>;
311
         chomp $delInput;
312
 
313
         if ($delInput eq '0' || $delInput eq '') {
314
            print "Delete cancelled.\n";
315
            next;
316
         }
317
 
318
         if ($delInput =~ /^\d+$/ && $delInput >= 1 && $delInput <= scalar(@keys)) {
319
            my $keyToDelete = $keys[$delInput - 1];
320
            print "Delete key '$keyToDelete' and all its children? (y/n): ";
321
            my $confirm = <STDIN>;
322
            chomp $confirm;
323
 
324
            if ($confirm =~ /^y/i) {
325
               delete $hash->{$keyToDelete};
326
               print "Key '$keyToDelete' deleted.\n";
327
            } else {
328
               print "Delete cancelled.\n";
329
            }
330
         } else {
331
            print "Invalid selection.\n";
332
         }
333
         next;
334
      }
335
 
336
      return if @keys == 0;  # Empty hash after checking for add/delete
337
 
338
      my $key = $keys[$choice];
339
      my $value = $hash->{$key};
340
      my $newPath = $path eq 'root' ? $key : "$path.$key";
341
 
342
      if (ref($value) eq 'HASH') {
343
         editHash($value, $newPath);
344
      } elsif (ref($value) eq 'ARRAY') {
345
         editArray($value, $newPath);
346
      } else {
347
         $hash->{$key} = editScalar($value, $key, $path);
348
      }
349
   }
350
}
351
 
352
## Interactively edit an array structure.
353
## Displays elements, allows selection and editing, supports adding and deleting elements.
354
## Recursively handles nested hashes and arrays.
355
##
356
## Arguments:
357
##   $array - Arrayref to edit
358
##   $path  - String path to current location (default 'root')
359
##
360
## Returns:
361
##   Nothing (modifies $array in place)
362
sub editArray {
363
   my ($array, $path) = @_;
364
   $path //= 'root';
365
 
366
   while (1) {
367
      displayArrayElements($array, $path);
368
 
369
      my $choice = getUserChoice("Select element to edit", scalar(@$array) || 1, 1, scalar(@$array) > 0);
370
 
371
      return if !defined $choice;
372
      return if $choice eq 'back';
373
      exit 0 if $choice eq 'quit';
374
 
375
      if ($choice eq 'add') {
376
         my $valueType = getValueType();
377
         my $newValue = createNewValue($valueType);
378
         push @$array, $newValue;
379
         my $newIndex = $#$array;
380
         print "Element added at index $newIndex.\n";
381
 
382
         # If it's a hash or array, allow immediate editing
383
         if ($valueType eq 'hash' || $valueType eq 'array') {
384
            my $newPath = "$path\[$newIndex\]";
385
            if ($valueType eq 'hash') {
386
               editHash($newValue, $newPath);
387
            } else {
388
               editArray($newValue, $newPath);
389
            }
390
         }
391
         next;
392
      }
393
 
394
      if ($choice eq 'delete') {
395
         return if @$array == 0;  # Empty array, nothing to delete
396
 
397
         print "\nSelect element to delete (1-" . scalar(@$array) . ", 0=cancel): ";
398
         my $delInput = <STDIN>;
399
         chomp $delInput;
400
 
401
         if ($delInput eq '0' || $delInput eq '') {
402
            print "Delete cancelled.\n";
403
            next;
404
         }
405
 
406
         if ($delInput =~ /^\d+$/ && $delInput >= 1 && $delInput <= scalar(@$array)) {
407
            my $indexToDelete = $delInput - 1;
408
            print "Delete element at index $indexToDelete and all its children? (y/n): ";
409
            my $confirm = <STDIN>;
410
            chomp $confirm;
411
 
412
            if ($confirm =~ /^y/i) {
413
               splice @$array, $indexToDelete, 1;
414
               print "Element at index $indexToDelete deleted.\n";
415
            } else {
416
               print "Delete cancelled.\n";
417
            }
418
         } else {
419
            print "Invalid selection.\n";
420
         }
421
         next;
422
      }
423
 
424
      return if @$array == 0;  # Empty array after checking for add/delete
425
 
426
      my $value = $array->[$choice];
427
      my $newPath = "$path\[$choice\]";
428
 
429
      if (ref($value) eq 'HASH') {
430
         editHash($value, $newPath);
431
      } elsif (ref($value) eq 'ARRAY') {
432
         editArray($value, $newPath);
433
      } else {
434
         print "\n" . "-" x 70 . "\n";
435
         print "Editing: $newPath\n";
436
         print "Current value: " . (defined($value) ? "'$value'" : '<undef>') . "\n";
437
         print "Enter new value (or press Enter to keep current): ";
438
 
439
         my $input = <STDIN>;
440
         chomp $input;
441
 
442
         if ($input ne '') {
443
            $array->[$choice] = $input;
444
            print "Value updated.\n";
445
         }
446
      }
447
   }
448
}
449
 
450
## Return usage string for command line help.
451
##
452
## Arguments:
453
##   None
454
##
455
## Returns:
456
##   String containing usage information
457
sub usageString {
458
   return "Usage: $0 [-t <template_file>] [-c <config_file>] [-o <output_file>] [-e]\n" .
459
          "       $0 <template_file> [config_file]\n" .
460
          "\nAt least one of template or config file must be specified.\n" .
461
          "\nOptions:\n" .
462
          "  -t, --template <file>  Template file (Perl hashref)\n" .
463
          "  -c, --config <file>    Config file (YAML or JSON)\n" .
464
          "  -o, --output <file>    Output file (default: STDOUT)\n" .
465
          "  -e, --edit             Interactive edit mode\n" .
466
          "  -v, --version          Show version information\n" .
467
          "  -h, --help             Show this help message\n";
468
}
469
 
470
## Print usage information to STDOUT.
471
##
472
## Arguments:
473
##   None
474
##
475
## Returns:
476
##   Nothing (void)
477
sub printUsage {
478
   print usageString();
479
}
480
 
481
## Parse command line options using Getopt::Long.
482
## Supports both option-style and positional arguments for backward compatibility.
483
##
484
## Arguments:
485
##   None (reads from @ARGV)
486
##
487
## Returns:
488
##   Hashref with keys: templateFile, configFile, outputFile, editMode
489
sub parseCommandLine {
490
   my $opts = {
491
      templateFile => undef,
492
      configFile   => undef,
493
      outputFile   => undef,
494
      editMode     => 0,
495
   };
496
 
497
   GetOptions(
498
      't|template=s' => \$opts->{templateFile},
499
      'c|config=s'   => \$opts->{configFile},
500
      'o|output=s'   => \$opts->{outputFile},
501
      'e|edit'       => \$opts->{editMode},
502
      'v|version'    => sub { print "$0 version $VERSION\n"; exit 0; },
503
      'h|help'       => sub { printUsage(); exit 0; },
504
   ) or die "Error in command line arguments\n";
505
 
506
   # For backward compatibility, also accept positional arguments
507
   if (!$opts->{templateFile} && !$opts->{configFile} && @ARGV >= 1) {
508
      $opts->{templateFile} = $ARGV[0];
509
      $opts->{configFile} = $ARGV[1] if @ARGV >= 2;
510
   }
511
 
512
   # Check that at least one input file is specified
513
   die "Error: At least one of template or config file required\n" . usageString() 
514
      unless $opts->{templateFile} || $opts->{configFile};
515
 
516
   return $opts;
517
}
518
 
519
## Load and evaluate a Perl template file.
520
## Template file should contain a hashref data structure.
521
##
522
## Arguments:
523
##   $templateFile - Path to template file
524
##
525
## Returns:
526
##   Hashref from template, or empty hashref if no file specified
527
sub loadTemplate {
528
   my ($templateFile) = @_;
529
   return {} unless $templateFile;
530
 
531
   die "Template file '$templateFile' does not exist\n" unless -f $templateFile;
532
   my $templateContent = read_file($templateFile);
533
   my $template = eval $templateContent;
534
   die "Error parsing template file: $@\n" if $@;
535
   die "Template must be a hashref\n" unless ref($template) eq 'HASH';
536
 
537
   return $template;
538
}
539
 
540
## Detect file format based on extension.
541
## Recognizes .yaml, .yml, and .json extensions.
542
##
543
## Arguments:
544
##   $filename - Path to file
545
##
546
## Returns:
547
##   String: 'yaml' or 'json'
548
##   Dies if extension not recognized
549
sub detectFileFormat {
550
   my ($filename) = @_;
551
   return 'yaml' if $filename =~ /\.ya?ml$/i;
552
   return 'json' if $filename =~ /\.json$/i;
553
   die "Config file must be YAML (.yaml/.yml) or JSON (.json)\n";
554
}
555
 
556
## Load a YAML file using available YAML library.
557
## Tries YAML::XS, YAML::Tiny, and YAML in order.
558
##
559
## Arguments:
560
##   $filename - Path to YAML file
561
##
562
## Returns:
563
##   Perl data structure from YAML file
564
##   Dies if no YAML library available
565
sub loadYamlFile {
566
   my ($filename) = @_;
567
 
568
   for my $yamlModule ('YAML::XS', 'YAML::Tiny', 'YAML') {
569
      if (eval "require $yamlModule; 1") {
570
         $yamlModule->import('LoadFile');
571
         return LoadFile($filename);
572
      }
573
   }
574
   die "No YAML library available. Install YAML::XS, YAML::Tiny, or YAML\n";
575
}
576
 
577
## Load a JSON file using available JSON library.
578
## Tries JSON::XS, JSON::PP, and JSON in order.
579
##
580
## Arguments:
581
##   $filename - Path to JSON file
582
##
583
## Returns:
584
##   Perl data structure from JSON file
585
##   Dies if no JSON library available
586
sub loadJsonFile {
587
   my ($filename) = @_;
588
 
589
   for my $jsonModule ('JSON::XS', 'JSON::PP', 'JSON') {
590
      if (eval "require $jsonModule; 1") {
591
         my $jsonText = read_file($filename);
592
         return $jsonModule->new->decode($jsonText);
593
      }
594
   }
595
   die "No JSON library available. Install JSON::XS, JSON::PP, or JSON\n";
596
}
597
 
598
## Load config file and detect its format.
599
## Handles both YAML and JSON formats automatically.
600
##
601
## Arguments:
602
##   $configFile - Path to config file
603
##
604
## Returns:
605
##   List: ($config_hashref, $format_string)
606
##   Returns ({}, 'yaml') if no file specified
607
sub loadConfig {
608
   my ($configFile) = @_;
609
   my $config = {};
610
   my $format = 'yaml';  # Default format
611
 
612
   if ($configFile && -f $configFile) {
613
      $format = detectFileFormat($configFile);
614
 
615
      if ($format eq 'yaml') {
616
         $config = loadYamlFile($configFile);
617
      } elsif ($format eq 'json') {
618
         $config = loadJsonFile($configFile);
619
      }
620
 
621
      die "Config must be a hashref\n" unless ref($config) eq 'HASH';
622
   }
623
 
624
   return ($config, $format);
625
}
626
 
627
## Recursively merge template into config.
628
## Template values are only applied for missing keys in config.
629
## Handles nested hashes and arrays, tracking changes in messages array.
630
##
631
## Arguments:
632
##   $template - Hashref template structure
633
##   $config   - Hashref config to merge into
634
##   $path     - Current path string for messages (default '')
635
##   $messages - Arrayref to collect change notifications (default [])
636
##
637
## Returns:
638
##   List: ($config, $messages) - updated config and array of change messages
639
sub mergeHash {
640
   my ($template, $config, $path, $messages) = @_;
641
   $path //= '';  # Track the key path for notifications
642
   $messages //= [];  # Array to collect notification messages
643
 
644
   for my $key (keys %$template) {
645
      my $currentPath = $path ? "$path.$key" : $key;
646
 
647
      if (!exists $config->{$key}) {
648
         # Key missing in config, copy from template
649
         $config->{$key} = $template->{$key};
650
         push @$messages, "Added key: $currentPath";
651
      } elsif (ref($template->{$key}) eq 'HASH' && ref($config->{$key}) eq 'HASH') {
652
         # Both are hashes, recurse
653
         mergeHash($template->{$key}, $config->{$key}, $currentPath, $messages);
654
      } elsif (ref($template->{$key}) eq 'ARRAY' && ref($config->{$key}) eq 'ARRAY') {
655
         # Both are arrays, merge by index
656
         my $templateArray = $template->{$key};
657
         my $configArray = $config->{$key};
658
 
659
         # Extend config array if template is longer
660
         for my $i (0 .. $#$templateArray) {
661
            if ($i > $#$configArray) {
662
               # Config array is shorter, append from template
663
               push @$configArray, $templateArray->[$i];
664
               push @$messages, "Extended array: $currentPath\[$i\] (added from template)";
665
            } elsif (ref($templateArray->[$i]) eq 'HASH' && ref($configArray->[$i]) eq 'HASH') {
666
               # Both elements are hashes, merge them
667
               mergeHash($templateArray->[$i], $configArray->[$i], "$currentPath\[$i\]", $messages);
668
            }
669
            # Otherwise keep existing config value at this index
670
         }
671
      }
672
      # If key exists in config with different types, keep config value (don't override)
673
   }
674
   return ($config, $messages);
675
}
676
 
677
## Merge template and config or return whichever is provided.
678
## Determines merge strategy based on which files are present.
679
##
680
## Arguments:
681
##   $template     - Hashref from template file
682
##   $config       - Hashref from config file
683
##   $hasTemplate - Boolean whether template file was provided
684
##   $hasConfig   - Boolean whether config file was provided
685
##
686
## Returns:
687
##   List: ($merged_hashref, $messages_arrayref)
688
sub mergeConfigs {
689
   my ($template, $config, $hasTemplate, $hasConfig) = @_;
690
 
691
   if ($hasTemplate && $hasConfig) {
692
      return mergeHash($template, $config);
693
   } elsif ($hasTemplate) {
694
      return ($template, []);
695
   } else {
696
      return ($config, []);
697
   }
698
}
699
 
700
## Output notification messages to STDERR.
701
##
702
## Arguments:
703
##   $messages - Arrayref of message strings
704
##
705
## Returns:
706
##   Nothing (void)
707
sub outputMessages {
708
   my ($messages) = @_;
709
   return unless @$messages;
710
   warn "$_\n" for @$messages;
711
}
712
 
713
## Format data structure as JSON.
714
## Tries JSON::XS, JSON::PP, and JSON libraries in order.
715
## Uses pretty-printing and canonical key ordering.
716
##
717
## Arguments:
718
##   $data - Perl data structure to format
719
##
720
## Returns:
721
##   String containing JSON representation
722
##   Dies if no JSON library available
723
sub formatAsJson {
724
   my ($data) = @_;
725
 
726
   for my $jsonModule ('JSON::XS', 'JSON::PP', 'JSON') {
727
      if (eval "require $jsonModule; 1") {
728
         my $jsonObj = $jsonModule->new->pretty->canonical;
729
         return $jsonObj->encode($data);
730
      }
731
   }
732
   die "No JSON library available for output. Install JSON::XS, JSON::PP, or JSON\n";
733
}
734
 
735
## Format data structure as YAML.
736
## Tries YAML::XS, YAML::Tiny, and YAML libraries in order.
737
##
738
## Arguments:
739
##   $data - Perl data structure to format
740
##
741
## Returns:
742
##   String containing YAML representation
743
##   Dies if no YAML library available
744
sub formatAsYaml {
745
   my ($data) = @_;
746
 
747
   for my $yamlModule ('YAML::XS', 'YAML::Tiny', 'YAML') {
748
      if (eval "require $yamlModule; 1") {
749
         $yamlModule->import('Dump');
750
         return Dump($data);
751
      }
752
   }
753
   die "No YAML library available for output. Install YAML::XS, YAML::Tiny, or YAML\n";
754
}
755
 
756
## Format data structure as YAML or JSON based on format parameter.
757
##
758
## Arguments:
759
##   $data   - Perl data structure to format
760
##   $format - String: 'yaml' or 'json'
761
##
762
## Returns:
763
##   String containing formatted output
764
sub formatOutput {
765
   my ($data, $format) = @_;
766
 
767
   if ($format eq 'json') {
768
      return formatAsJson($data);
769
   } else {
770
      return formatAsYaml($data);
771
   }
772
}
773
 
774
## Write content to file or STDOUT.
775
##
776
## Arguments:
777
##   $content     - String content to write
778
##   $outputFile - Path to output file, or undef for STDOUT
779
##
780
## Returns:
781
##   Nothing (void)
782
sub writeOutput {
783
   my ($content, $outputFile) = @_;
784
 
785
   if ($outputFile) {
786
      open my $fh, '>', $outputFile or die "Cannot write to '$outputFile': $!\n";
787
      print $fh $content;
788
      close $fh;
789
      warn "Output written to: $outputFile\n";
790
   } else {
791
      print $content;
792
   }
793
}
794
 
795
## Prompt user whether to save changes.
796
##
797
## Arguments:
798
##   None
799
##
800
## Returns:
801
##   Boolean: true if user wants to save (answered 'y' or 'Y')
802
sub promptSave {
803
   print "\nSave changes? (y/n): ";
804
   my $input = <STDIN>;
805
   chomp $input;
806
   return $input =~ /^y/i;
807
}
808
 
809
## Backup original file and save new content.
810
## Renames original file with .bak suffix before writing new content.
811
##
812
## Arguments:
813
##   $filename - Path to file to save
814
##   $content  - String content to write
815
##
816
## Returns:
817
##   Nothing (void)
818
##   Dies on file operation errors
819
sub backupAndSave {
820
   my ($filename, $content) = @_;
821
 
822
   # Create backup by renaming original file
823
   my $backupFile = "$filename.bak";
824
   if (-f $filename) {
825
      rename $filename, $backupFile or die "Cannot create backup '$backupFile': $!\n";
826
      warn "Original file backed up to: $backupFile\n";
827
   }
828
 
829
   # Write new content to original filename
830
   open my $fh, '>', $filename or die "Cannot write to '$filename': $!\n";
831
   print $fh $content;
832
   close $fh;
833
   warn "Changes saved to: $filename\n";
834
}
835
 
836
# ============================================================================
837
# MAIN
838
# ============================================================================
839
 
840
my $opts = parseCommandLine();
841
my $template = loadTemplate($opts->{templateFile});
842
my ($config, $outputFormat) = loadConfig($opts->{configFile});
843
my ($merged, $messages) = mergeConfigs($template, $config, $opts->{templateFile}, $opts->{configFile});
844
 
845
outputMessages($messages);
846
 
847
# Enter interactive edit mode if requested
848
if ($opts->{editMode}) {
849
   print "\n*** Interactive Edit Mode ***\n";
850
   print "Navigate through the configuration and edit values.\n";
851
   print "Commands: select number to edit, 0=back, q=quit\n";
852
   editHash($merged);
853
   print "\n*** Exiting Edit Mode ***\n\n";
854
}
855
 
856
# Determine output format - if output file specified, use its extension
857
if ($opts->{outputFile}) {
858
   $outputFormat = detectFileFormat($opts->{outputFile});
859
}
860
 
861
my $outputContent = formatOutput($merged, $outputFormat);
862
 
863
# Handle output based on whether output file is specified
864
if ($opts->{outputFile}) {
865
   # Output file specified, write directly
866
   writeOutput($outputContent, $opts->{outputFile});
867
} elsif ($opts->{configFile} && promptSave()) {
868
   # No output file, but config file exists and user wants to save
869
   backupAndSave($opts->{configFile}, $outputContent);
870
} else {
871
   # No output file and either no config or user declined save, output to STDOUT
872
   writeOutput($outputContent, undef);
873
}