#!/usr/bin/env perl ## dsMerge ## Merge YAML, JSON and INI data files into one. ## Copyright (c) 2026, rodolico. All rights reserved. ## Licensed under the FreeBSD Simplified (2-Clause) License. ## See the project license text for full terms. ## ## The format drivers (YAML, JSON, INI), format detection and the atomic write are in ## DataSerialization.pm, shared with dsEdit; it must sit beside this script. ## ## Revision History: ## 2026-10-25 Claude v1.0 Initial release: merge any mix of YAML/JSON/INI files into the first one. ## 2026-10-25 Claude v1.1 Shared code moved to DataSerialization.pm. use strict; use warnings; use Scalar::Util qw(blessed); use Getopt::Long qw(GetOptions); use FindBin; use lib $FindBin::RealBin; # DataSerialization.pm lives beside this script use DataSerialization qw( availableFormats formatSupported formatDriver normalizeFormatName detectFormatFromName setIniDelimiter lastIniWriteError writeAtomicFile trim ); my $inFormatOverride = ''; my $outFormatOverride = ''; ## Display the command-line help text (to STDERR when called for an error, STDOUT for --help). sub usage { my ($fh) = @_; $fh ||= \*STDOUT; print {$fh} "Usage: dsMerge [options] primary other [other ...]\n"; print {$fh} " Merges the other files, in order, into the primary file's data and writes the result.\n"; print {$fh} " A key is taken from an other file only if the primary lacks it or its value is empty\n"; print {$fh} " (''/null, empty mapping, empty list). Mappings merge recursively; non-empty values and\n"; print {$fh} " lists already in the primary are kept. Use - as a file name to read standard input.\n"; print {$fh} " -o, --outfile PATH Write the result to PATH (- or no -o: standard output)\n"; print {$fh} " -f, --format FORMAT Format for input files whose extension is not recognised, and for -\n"; print {$fh} " -F, --outformat FORMAT Output format, overriding the --outfile extension\n"; print {$fh} " --delimiter ':'|'=' INI key/value delimiter to read and write (default: read either, write '=')\n"; print {$fh} " -h, --help Show this help\n"; print {$fh} "Formats: yaml, json, ini. Without -F the output uses the --outfile extension, or the\n"; print {$fh} "primary's format when writing to standard output.\n"; print {$fh} "Example: dsMerge -o merged.yaml site.yaml host.json defaults.ini\n"; print {$fh} "Supported formats: " . join(', ', availableFormats()) . "\n"; } ## True if a value counts as empty for merging: undef, '', an empty mapping or an empty list. ## 0, '0' and boolean false are NOT empty. sub isEmptyValue { my ($value) = @_; return 1 if !defined $value; my $type = ref($value); return ($value eq '' ? 1 : 0) if $type eq ''; return !%{$value} if $type eq 'HASH'; return !@{$value} if $type eq 'ARRAY'; return 0; # any other object (JSON/YAML boolean) is a value, even when false } ## Merge $other into $primary (both mapping references), in place. sub mergeHashes { my ($primary, $other) = @_; for my $key (keys %{$other}) { if (!exists $primary->{$key} || isEmptyValue($primary->{$key})) { $primary->{$key} = $other->{$key}; } elsif (ref($primary->{$key}) eq 'HASH' && ref($other->{$key}) eq 'HASH') { mergeHashes($primary->{$key}, $other->{$key}); } # otherwise the primary's non-empty value (scalar, list, or a type mismatch) stays } return $primary; } ## Read a file (or standard input for '-') and parse it; returns the data or undef after printing why. sub loadInput { my ($name) = @_; my $isStdin = $name eq '-'; my $format = $isStdin ? $inFormatOverride : (detectFormatFromName($name) || $inFormatOverride); if (!$format) { print STDERR "Could not determine the format of '$name'. Use .yaml, .yml, .json or .ini, or give --format.\n"; return undef; } if (!formatSupported($format) || !formatDriver($format)) { print STDERR "Format '$format' is not available because the required Perl module is missing.\n"; return undef; } my $content; if ($isStdin) { local $/ = undef; $content = ; $content = '' if !defined $content; } else { if (!-f $name) { print STDERR "File not found: $name\n"; return undef; } open my $fh, '<', $name or do { print STDERR "Unable to open $name: $!\n"; return undef; }; local $/ = undef; $content = <$fh>; close $fh; } my $parsed; eval { $parsed = formatDriver($format)->{load}->($content, $isStdin ? '(standard input)' : $name); 1; } or do { my $error = $@ || 'Unknown parse error'; $error =~ s/\s+\z//; print STDERR "Unable to parse " . ($isStdin ? 'standard input' : $name) . " as $format: $error\n"; return undef; }; # an empty document is an empty mapping, so it merges as "nothing to add" (or "everything missing") $parsed = {} if !defined $parsed; if (ref($parsed) ne 'HASH') { print STDERR "The top level of " . ($isStdin ? 'standard input' : $name) . " must be a mapping (key/value pairs) to be merged.\n"; return undef; } return ($parsed, $format); } sub main { my $outfile = ''; my $showHelp = 0; my ($formatArg, $outFormatArg, $delimiterArg) = ('', '', ''); Getopt::Long::Configure('no_ignore_case'); # -f and -F are different options my $optionsOk = GetOptions( 'o|outfile=s' => \$outfile, 'f|format=s' => \$formatArg, 'F|outformat=s' => \$outFormatArg, 'delimiter=s' => \$delimiterArg, 'h|help' => \$showHelp, ); if (!$optionsOk) { usage(\*STDERR); return 1; } if ($showHelp) { usage(); return 0; } if ($formatArg ne '') { $inFormatOverride = normalizeFormatName($formatArg); if (!$inFormatOverride) { print STDERR "Unknown format '$formatArg' for --format. Use yaml, json, or ini.\n"; return 1; } } if ($outFormatArg ne '') { $outFormatOverride = normalizeFormatName($outFormatArg); if (!$outFormatOverride) { print STDERR "Unknown format '$outFormatArg' for --outformat. Use yaml, json, or ini.\n"; return 1; } } if ($delimiterArg ne '') { if ($delimiterArg ne ':' && $delimiterArg ne '=') { print STDERR "--delimiter must be ':' or '='.\n"; return 1; } setIniDelimiter($delimiterArg); } my @inputs = @ARGV; if (@inputs < 2) { print STDERR "dsMerge needs a primary file and at least one file to merge into it.\n"; usage(\*STDERR); return 1; } if ((grep { $_ eq '-' } @inputs) > 1) { print STDERR "Standard input (-) can only be used once.\n"; return 1; } # load everything first so that nothing is written if any input is bad my ($merged, $primaryFormat) = loadInput($inputs[0]); return 1 if !defined $merged; for my $name (@inputs[1 .. $#inputs]) { my ($data) = loadInput($name); return 1 if !defined $data; mergeHashes($merged, $data); } my $toStdout = ($outfile eq '' || $outfile eq '-') ? 1 : 0; my $outFormat = $outFormatOverride; $outFormat ||= detectFormatFromName($outfile) if !$toStdout; $outFormat ||= $primaryFormat; if (!formatSupported($outFormat) || !formatDriver($outFormat)) { print STDERR "Format '$outFormat' is not available because the required Perl module is missing.\n"; return 1; } my $content = formatDriver($outFormat)->{dump}->($merged, $outfile); if (!defined $content) { print STDERR (($outFormat eq 'ini' && lastIniWriteError() ne '') ? lastIniWriteError() : "Unable to serialize the result as $outFormat.") . "\n"; return 1; } if ($toStdout) { print $content or do { print STDERR "Unable to write to standard output: $!\n"; return 1; }; return 0; } return writeAtomicFile($outfile, $content) ? 0 : 1; } exit(main());