#!/usr/bin/env perl ## dsEdit ## Terminal editor for YAML, JSON and INI data files. ## Copyright (c) 2026, rodolico. All rights reserved. ## Licensed under the FreeBSD Simplified (2-Clause) License. ## See the project license text for full terms. ## ## Revision History: ## 2026-10-20 rodolico v1.0 Initial single-file editor. ## 2026-10-25 rodolico v1.1 Added [] list append/delete-by-value syntax for non-interactive mode. ## 2026-10-25 Copilot v1.2 Added INI read/edit/write support and conversion. ## 2026-10-25 Claude v1.2.1 INI review fixes: JSON/YAML booleans write as text, reject empty section names and section/key name clashes, trim spaces around comma-list elements, clearer error for text after a section header. ## 2026-10-25 Claude v1.3 Added -f/--format and -F/--outformat to override format detection, and --delimiter (':' or '=') for INI files such as server.info. ## 2026-10-25 Claude v1.4 Format drivers, INI code, format detection and atomic write moved to DataSerialization.pm (shared with dsMerge); dsEdit now needs that file beside it. use strict; use warnings; 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 $root = {}; my @path = (); my $currentFile = ''; my $currentFormat = ''; my $modified = 0; my $inFormatOverride = ''; my $outFormatOverride = ''; ## Display the command-line help text. sub usage { print "Usage: dsEdit [options] [file]\n"; print " -a, --addentry 'key1.key2:value' Add or update a value at path\n"; print " --addentry 'key1.key2[]:value' Append value to a list key (create list if missing)\n"; print " -d, --delentry 'key1.key2' Delete a path and descendants\n"; print " --delentry 'key1.key2[]:value' Delete list element by value\n"; print " -o, --outfile /path/to/out.ext Write output to destination format\n"; print " -f, --format yaml|json|ini Input format, overriding detection from the file extension\n"; print " -F, --outformat yaml|json|ini Output format for --outfile, overriding its extension\n"; print " --delimiter ':'|'=' INI key/value delimiter to read and write (default: read either, write '=')\n"; print " -h, --help Show this help\n"; print "\nExamples:\n"; print " dsEdit config.yaml\n"; print " dsEdit -a 'server.host:mail01' -d 'legacy.setting' config.yaml\n"; print " dsEdit -a 'tags[]:prod' -a 'tags[]:web' config.yaml\n"; print " dsEdit -d 'tags[]:web' config.yaml\n"; print " dsEdit --outfile config.json config.yaml\n"; print " dsEdit -f ini --delimiter ':' -a 'Location:Plant 2' /etc/server.info\n"; print "Supported formats: " . join(', ', availableFormats()) . "\n"; } ## Prompt the user for input and return the response. sub prompt { my ($text, $default) = @_; print $text; my $input = ; return '' if !defined $input; chomp $input; $input = $default if $input eq ''; return $input; } ## Return the node found at the current breadcrumb path. sub nodeAtPath { my ($base, $steps) = @_; my $node = $base; for my $step (@{$steps}) { return undef if !defined $node; if (ref($node) eq 'HASH') { return undef if !exists $node->{$step}; $node = $node->{$step}; } elsif (ref($node) eq 'ARRAY') { return undef if $step !~ /^\d+$/; return undef if $step >= @{$node}; $node = $node->[$step]; } else { return undef; } } return $node; } ## Describe a structure value for display in the terminal. sub describeValue { my ($value) = @_; my $ref = ref($value); return '' if $ref eq 'HASH'; return '' if $ref eq 'ARRAY'; return '(undef)' if !defined $value; return $value; } ## Return the current editing node based on the current path. sub currentNode { return nodeAtPath($root, \@path); } ## Return the breadcrumb path for the current position. sub breadcrumbs { my $crumbs = '/'; return $crumbs if !@path; $crumbs .= join('/', map { $_ =~ /^\d+$/ ? "[$_]" : $_ } @path); return $crumbs; } ## Return all current entries in the active node. sub listEntries { my ($node) = @_; my @entries; return @entries if !defined $node; if (ref($node) eq 'HASH') { my $index = 0; foreach my $key (sort keys %{$node}) { my $value = $node->{$key}; push @entries, { index => $index, label => $key, type => ref($value) || 'scalar', value => $value, }; $index++; } } elsif (ref($node) eq 'ARRAY') { for (my $i = 0; $i < @{$node}; $i++) { my $value = $node->[$i]; push @entries, { index => $i, label => $i, type => ref($value) || 'scalar', value => $value, }; } } return @entries; } ## Prompt the user to choose an item from the current node. sub chooseItem { my ($node, $promptText) = @_; my @entries = listEntries($node); if (!@entries) { print "No entries available in the current structure.\n"; return undef; } for my $entry (@entries) { my $valueLabel = describeValue($entry->{value}); print sprintf("%2d) %s : %s\n", $entry->{index}, $entry->{label}, $valueLabel); } my $input = prompt($promptText, ''); return undef if !defined $input || $input eq ''; $input = trim($input); return $entries[$input] if $input =~ /^\d+$/ && $input < @entries; for my $entry (@entries) { return $entry if $entry->{label} eq $input; } print "No matching item selected.\n"; return undef; } ## Normalize a user-entered action to the short command used internally. sub normalizeAction { my ($action) = @_; my $value = lc(trim($action || '')); return '' if $value eq ''; return 'l' if $value eq 'load'; return 's' if $value eq 'save'; return 'a' if $value eq 'saveas' || $value eq 'save as' || $value eq 'as'; return 'd' if $value eq 'delete' || $value eq 'del'; return 'e' if $value eq 'edit'; return 'c' if $value eq 'clear'; return 'n' if $value eq 'new'; return 'p' if $value eq 'parent'; return 'q' if $value eq 'quit' || $value eq 'exit'; return $value; } ## Resolve a user input like 'e9' or 'd 9' into an action and target. sub parseActionInput { my ($input) = @_; my $value = lc(trim($input || '')); return ('', undef) if $value eq ''; if ($value =~ /^([a-z]+)\s*(\d+)$/) { return (normalizeAction($1), $2); } ## Split a dotted path like key1.key2 into path components. sub splitEntryPath { my ($pathText) = @_; my $path = trim($pathText || ''); return () if $path eq ''; my @parts = split /\./, $path, -1; for my $part (@parts) { return () if trim($part) eq ''; } return @parts; } ## Parse an --addentry argument into a path and scalar value. sub parseAddEntrySpec { my ($spec) = @_; my $value = trim($spec || ''); if ($value !~ /^([^:]+):(.*)$/s) { print "Invalid addentry '$spec'. Expected key.path:value\n"; return; } my $pathText = trim($1); my $rawValue = trim($2); my @parts = splitEntryPath($pathText); if (!@parts) { print "Invalid addentry path '$pathText'.\n"; return; } for (my $i = 0; $i < @parts - 1; $i++) { if ($parts[$i] =~ /\[\]/) { print "Invalid addentry path '$pathText'. [] is only valid on the last segment.\n"; return; } } my $appendToList = 0; if ($parts[-1] =~ /^(.*)\[\]\z/) { my $leaf = $1; if ($leaf eq '') { print "Invalid addentry path '$pathText'.\n"; return; } $parts[-1] = $leaf; $appendToList = 1; } if ($appendToList && $rawValue eq '') { print "Invalid addentry '$spec'. List append requires a non-empty value.\n"; return; } return (\@parts, $rawValue, $appendToList); } ## Parse a --delentry argument into path components. sub parseDeleteEntrySpec { my ($spec) = @_; my $value = trim($spec || ''); my $pathText = $value; my $rawValue = ''; my $deleteByListValue = 0; if ($value =~ /^([^:]+):(.*)$/s) { $pathText = trim($1); $rawValue = trim($2); $deleteByListValue = 1; } my @parts = splitEntryPath($pathText); if (!@parts) { print "Invalid delentry path '$spec'.\n"; return; } for (my $i = 0; $i < @parts - 1; $i++) { if ($parts[$i] =~ /\[\]/) { print "Invalid delentry path '$pathText'. [] is only valid on the last segment.\n"; return; } } my $leafHasListSuffix = 0; if ($parts[-1] =~ /^(.*)\[\]\z/) { my $leaf = $1; if ($leaf eq '') { print "Invalid delentry path '$pathText'.\n"; return; } $parts[-1] = $leaf; $leafHasListSuffix = 1; } if ($deleteByListValue && !$leafHasListSuffix) { print "Invalid delentry '$spec'. Delete-by-value requires a trailing [] on the final path segment.\n"; return; } if (!$deleteByListValue && $leafHasListSuffix) { print "Invalid delentry '$spec'. A path ending in [] requires :value.\n"; return; } if ($deleteByListValue && $rawValue eq '') { print "Invalid delentry '$spec'. Delete-by-value requires a non-empty value.\n"; return; } return (\@parts, $deleteByListValue, $rawValue, $pathText); } ## Return parent container and leaf key/index for a path. sub resolveParentForPath { my ($base, $partsRef, $createMissing) = @_; my @parts = @{$partsRef}; return if !@parts; my $node = $base; for (my $i = 0; $i < @parts - 1; $i++) { my $segment = $parts[$i]; if (ref($node) eq 'HASH') { if (!exists $node->{$segment}) { if (!$createMissing) { return; } $node->{$segment} = {}; } if (ref($node->{$segment}) eq '') { print "Path segment '$segment' is a scalar and cannot contain children.\n"; return; } $node = $node->{$segment}; next; } if (ref($node) eq 'ARRAY') { if ($segment !~ /^\d+$/ || $segment >= @{$node}) { return; } if (ref($node->[$segment]) eq '') { print "Path segment '$segment' is a scalar and cannot contain children.\n"; return; } $node = $node->[$segment]; next; } print "Cannot traverse into scalar path segment '$segment'.\n"; return; } return ($node, $parts[-1]); } ## Apply one --addentry operation to the in-memory structure. sub applyAddEntry { my ($spec) = @_; my ($partsRef, $value, $appendToList) = parseAddEntrySpec($spec); return 0 if !$partsRef; my ($parent, $leaf) = resolveParentForPath($root, $partsRef, 1); return 0 if !defined $parent; if (ref($parent) eq 'HASH') { if ($appendToList) { if ($currentFormat eq 'ini') { my @items; if (exists $parent->{$leaf}) { if (ref($parent->{$leaf}) ne '') { print "Cannot append to '$leaf' because it is not a scalar value.\n"; return 0; } my $existing = defined $parent->{$leaf} ? $parent->{$leaf} : ''; @items = $existing eq '' ? () : split(/\s*,\s*/, $existing, -1); } for my $existing (@items) { if ($existing eq $value) { print "Value '$value' is already present at '$leaf'.\n"; return 1; } } push @items, $value; $parent->{$leaf} = join(',', @items); $modified = 1; return 1; } if (!exists $parent->{$leaf}) { $parent->{$leaf} = [$value]; $modified = 1; return 1; } if (ref($parent->{$leaf}) ne 'ARRAY') { print "Cannot append to '$leaf' because it exists and is not a list.\n"; return 0; } for my $existing (@{$parent->{$leaf}}) { if (!ref($existing) && defined $existing && $existing eq $value) { print "Value '$value' is already present at '$leaf'.\n"; return 1; } } push @{$parent->{$leaf}}, $value; } else { if (exists $parent->{$leaf} && ref($parent->{$leaf}) eq 'ARRAY') { push @{$parent->{$leaf}}, $value; } else { $parent->{$leaf} = $value; } } $modified = 1; return 1; } if (ref($parent) eq 'ARRAY') { if ($leaf !~ /^\d+$/) { print "Array path requires numeric index at '$leaf'.\n"; return 0; } if ($appendToList) { if ($currentFormat eq 'ini') { my @items; if (defined $parent->[$leaf]) { if (ref($parent->[$leaf]) ne '') { print "Cannot append to '$leaf' because it is not a scalar value.\n"; return 0; } my $existing = defined $parent->[$leaf] ? $parent->[$leaf] : ''; @items = $existing eq '' ? () : split(/\s*,\s*/, $existing, -1); } for my $existing (@items) { if ($existing eq $value) { print "Value '$value' is already present at '$leaf'.\n"; return 1; } } push @items, $value; $parent->[$leaf] = join(',', @items); $modified = 1; return 1; } if (!defined $parent->[$leaf]) { $parent->[$leaf] = [$value]; $modified = 1; return 1; } if (ref($parent->[$leaf]) ne 'ARRAY') { print "Cannot append to '$leaf' because it exists and is not a list.\n"; return 0; } for my $existing (@{$parent->[$leaf]}) { if (!ref($existing) && defined $existing && $existing eq $value) { print "Value '$value' is already present at '$leaf'.\n"; return 1; } } push @{$parent->[$leaf]}, $value; } else { if ($leaf < @{$parent} && ref($parent->[$leaf]) eq 'ARRAY') { push @{$parent->[$leaf]}, $value; } else { $parent->[$leaf] = $value; } } $modified = 1; return 1; } print "Cannot add value at the requested path.\n"; return 0; } ## Apply one --delentry operation to the in-memory structure. sub applyDeleteEntry { my ($spec) = @_; my ($partsRef, $deleteByListValue, $value) = parseDeleteEntrySpec($spec); return 0 if !$partsRef; my ($parent, $leaf) = resolveParentForPath($root, $partsRef, 0); if (!defined $parent) { print "Delete path '$spec' was not found.\n"; return 0; } if ($deleteByListValue) { my $target; if (ref($parent) eq 'HASH') { if (!exists $parent->{$leaf}) { print "Delete value '$value' at '$spec' was not found.\n"; return 0; } $target = $parent->{$leaf}; } elsif (ref($parent) eq 'ARRAY') { if ($leaf !~ /^\d+$/ || $leaf >= @{$parent}) { print "Delete value '$value' at '$spec' was not found.\n"; return 0; } $target = $parent->[$leaf]; } else { print "Delete value '$value' at '$spec' was not found.\n"; return 0; } if (ref($target) ne 'ARRAY') { if ($currentFormat eq 'ini' && ref($target) eq '') { my $targetText = defined $target ? $target : ''; my @values = $targetText eq '' ? () : split(/\s*,\s*/, $targetText, -1); my $index = -1; for (my $i = 0; $i < @values; $i++) { if ($values[$i] eq $value) { $index = $i; last; } } if ($index < 0) { print "Delete value '$value' at '$spec' was not found.\n"; return 0; } splice(@values, $index, 1); if (ref($parent) eq 'HASH') { if (@values) { $parent->{$leaf} = join(',', @values); } else { delete $parent->{$leaf}; } } elsif (ref($parent) eq 'ARRAY') { if (@values) { $parent->[$leaf] = join(',', @values); } else { splice(@{$parent}, $leaf, 1); } } $modified = 1; return 1; } print "Delete value '$value' at '$spec' failed because target is not a list.\n"; return 0; } my $index = -1; for (my $i = 0; $i < @{$target}; $i++) { my $candidate = $target->[$i]; if (!ref($candidate) && defined $candidate && $candidate eq $value) { $index = $i; last; } } if ($index < 0) { print "Delete value '$value' at '$spec' was not found.\n"; return 0; } splice(@{$target}, $index, 1); $modified = 1; return 1; } if (ref($parent) eq 'HASH') { if (!exists $parent->{$leaf}) { print "Delete path '$spec' was not found.\n"; return 0; } delete $parent->{$leaf}; $modified = 1; return 1; } if (ref($parent) eq 'ARRAY') { if ($leaf !~ /^\d+$/ || $leaf >= @{$parent}) { print "Delete path '$spec' was not found.\n"; return 0; } splice(@{$parent}, $leaf, 1); $modified = 1; return 1; } print "Delete path '$spec' was not found.\n"; return 0; } ## Run non-interactive operations and write output. sub runNonInteractive { my ($inputFile, $outfile, $opsRef) = @_; my $source = trim($inputFile || ''); if ($source eq '') { print "Non-interactive mode requires an input file.\n"; return 0; } return 0 if !loadFile($source); for my $op (@{$opsRef}) { my $ok = 0; if ($op->{type} eq 'add') { $ok = applyAddEntry($op->{value}); } elsif ($op->{type} eq 'del') { $ok = applyDeleteEntry($op->{value}); } else { print "Unknown operation type '$op->{type}'.\n"; return 0; } return 0 if !$ok; } my $target = trim($outfile || ''); my $outFormat; if ($target eq '') { $target = $currentFile; $outFormat = $currentFormat; # in-place save keeps the input format } else { $outFormat = $outFormatOverride || detectFormatFromName($target); } if (!$outFormat) { print "Could not determine output format from '$target'. Use .yaml, .yml, .json, or .ini, or give --outformat.\n"; return 0; } if (!formatSupported($outFormat)) { print "Format '$outFormat' is not available because the required Perl module is missing.\n"; return 0; } return saveCurrentFile($target, $outFormat); } if ($value =~ /^([a-z])\s*(\d+)$/) { return (normalizeAction($1), $2); } if ($value =~ /^([a-z]+)$/) { return (normalizeAction($1), undef); } return (normalizeAction($value), undef); } ## Load a file from disk and parse it according to the file extension. sub loadFile { my ($filename) = @_; my $path = trim($filename || ''); if ($path eq '') { print "Enter path to load: "; $path = ; chomp $path if defined $path; $path = trim($path || ''); } if ($path eq '') { print "No file selected.\n"; return 0; } if (!-e $path) { print "File not found: $path\n"; return 0; } my $format = $inFormatOverride || detectFormatFromName($path); if (!$format) { print "Could not determine format from filename. Use .yaml, .yml, .json, or .ini, or give --format.\n"; return 0; } if (!formatSupported($format)) { print "Format '$format' is not available because the required Perl module is missing.\n"; return 0; } open my $fh, '<', $path or do { print "Unable to open $path: $!\n"; return 0; }; local $/ = undef; my $content = <$fh>; close $fh; my $parsed; eval { if (!formatDriver($format)) { die "No parser available for $format\n"; } $parsed = formatDriver($format)->{load}->($content, $path); 1; } or do { my $error = $@ || 'Unknown parse error'; print "Unable to parse $path as $format: $error\n"; return 0; }; $root = $parsed; @path = (); $currentFile = $path; $currentFormat = $format; $modified = 0; print "Loaded $path as $format.\n"; return 1; } ## Serialize the current structure to the given format. sub serializeRoot { my ($format) = @_; if (!formatDriver($format)) { die "Unsupported format '$format'\n"; } return formatDriver($format)->{dump}->($root, $currentFile); } ## Save the current in-memory structure back to its current file. sub saveCurrentFile { my ($targetPath, $forceFormat) = @_; $targetPath = trim($targetPath || ''); if ($targetPath eq '') { $targetPath = $currentFile; } if ($targetPath eq '') { print "No file name available. Use Save As to choose a new destination.\n"; return 0; } my $format = $forceFormat || $currentFormat || detectFormatFromName($targetPath); if (!$format) { $format = chooseFormat(); } if (!formatSupported($format)) { print "Format '$format' is not available because the required Perl module is missing.\n"; return 0; } my $content = serializeRoot($format); if (!defined $content) { if ($format eq 'ini' && lastIniWriteError() ne '') { print lastIniWriteError() . "\n"; } else { print "Unable to serialize data as $format.\n"; } return 0; } if (!writeAtomicFile($targetPath, $content)) { return 0; } $currentFile = $targetPath; $currentFormat = $format; $modified = 0; print "Saved to $targetPath as $format.\n"; return 1; } ## Ask the user to choose a supported output format. sub chooseFormat { my @formats = availableFormats(); if (!@formats) { print "No serialization formats are available. Check that YAML::XS and JSON::PP are installed.\n"; return ''; } print "Available formats: ", join(', ', @formats), "\n"; my $answer = trim(prompt('Select format: ', '')); return $answer if formatSupported($answer); print "Unsupported format selection.\n"; return ''; } ## Save the current data to a new file path. sub saveAsFile { my $targetPath = trim(prompt('Save As path: ', $currentFile || '')); if ($targetPath eq '') { print "No destination supplied.\n"; return 0; } my $format = $outFormatOverride || detectFormatFromName($targetPath); if (!$format) { $format = chooseFormat(); if (!$format) { return 0; } } return saveCurrentFile($targetPath, $format); } ## Edit the selected scalar value or navigate into a nested structure. sub editValue { my ($entry) = @_; my $itemValue = $entry->{value}; my $type = $entry->{type}; if ($type eq 'HASH' || $type eq 'ARRAY') { push @path, $entry->{label}; print "Navigated into $entry->{label}.\n"; return; } print "Current value: " . describeValue($itemValue) . "\n"; my $newValue = prompt('Enter new value (blank keeps current): ', ''); if ($newValue eq '') { return; } my $node = currentNode(); if (ref($node) eq 'HASH') { $node->{$entry->{label}} = $newValue; } elsif (ref($node) eq 'ARRAY') { $node->[$entry->{label}] = $newValue; } $modified = 1; print "Updated selected value.\n"; } ## Clear the selected hash key or array element. sub clearValue { my ($entry) = @_; my $node = currentNode(); if (!defined $node) { print "No current node.\n"; return; } if (ref($node) eq 'HASH') { $node->{$entry->{label}} = ''; } elsif (ref($node) eq 'ARRAY') { $node->[$entry->{label}] = ''; } $modified = 1; print "Cleared selected value.\n"; } ## Delete the selected hash key or array element. sub deleteValue { my ($entry) = @_; my $node = currentNode(); if (!defined $node) { print "No current node.\n"; return; } if (ref($node) eq 'HASH') { delete $node->{$entry->{label}}; } elsif (ref($node) eq 'ARRAY') { splice(@{$node}, $entry->{label}, 1); } $modified = 1; print "Deleted selected entry.\n"; } ## Add a new value to the current hash or array. sub addNewValue { my $node = currentNode(); if (!defined $node) { print "No current node to add to.\n"; return; } if (ref($node) eq 'HASH') { my $key = trim(prompt('Key name: ', '')); if ($key eq '') { print "Key cannot be empty.\n"; return; } my $value = prompt('Value: ', ''); $node->{$key} = $value; } elsif (ref($node) eq 'ARRAY') { my $value = prompt('Value: ', ''); push @{$node}, $value; } else { print "This node is not a hash or array.\n"; return; } $modified = 1; print "Added new item.\n"; } ## Confirm whether the user wants to save before quitting. sub promptForQuit { if (!$modified) { print "Exiting without saving.\n"; return 1; } my $answer = lc(trim(prompt('You have unsaved changes. Save before quitting? [y/N/s] ', 'n'))); if ($answer eq 'y' || $answer eq 'yes') { if ($currentFile ne '') { saveCurrentFile($currentFile, $currentFormat); } else { saveAsFile(); } } elsif ($answer eq 's') { saveAsFile(); } return 1; } ## Draw the current menu and list of entries for the active node. sub displayScreen { my $node = currentNode(); print "\n"; print "=" x 72, "\n"; print "File: " . ($currentFile ne '' ? $currentFile : '[unsaved]') . "\n"; print "Format: " . ($currentFormat ne '' ? $currentFormat : 'unknown') . "\n"; print "Breadcrumbs: " . breadcrumbs() . "\n"; print "Modified: " . ($modified ? 'yes' : 'no') . "\n"; print "=" x 72, "\n"; print "Menu: L=Load, S=Save, A=Save As, D=Delete, E=Edit, C=Clear, N=New, P=Parent, Q=Quit\n\n"; if (!defined $node) { print "No current structure selected.\n"; return; } my @entries = listEntries($node); if (!@entries) { print "Current object is empty.\n"; return; } for my $entry (@entries) { print sprintf("%2d) %s : %s\n", $entry->{index}, $entry->{label}, describeValue($entry->{value})); } print "\n"; } ## Handle one action chosen by the user. sub processAction { my ($action, $target) = @_; my $node = currentNode(); $action = normalizeAction($action); if ($action eq 'l') { loadFile(''); return; } if ($action eq 's') { if ($currentFile eq '') { saveAsFile(); } else { saveCurrentFile($currentFile, $currentFormat); } return; } if ($action eq 'a') { saveAsFile(); return; } if ($action eq 'p') { if (@path) { pop @path; print "Moved to parent.\n"; } else { print "Already at the root.\n"; } return; } if ($action eq 'q') { exit if promptForQuit(); return; } if ($action eq 'n') { addNewValue(); return; } if (!defined $node) { print "No structure available.\n"; return; } if ($action eq 'd' || $action eq 'e' || $action eq 'c') { my $entry; if (defined $target) { my @entries = listEntries($node); if ($target =~ /^\d+$/ && $target < @entries) { $entry = $entries[$target]; } else { for my $entryCandidate (@entries) { if ($entryCandidate->{label} eq $target) { $entry = $entryCandidate; last; } } } if (!defined $entry) { print "No matching item selected for '$target'.\n"; return; } } else { $entry = chooseItem($node, 'Select an item: '); return if !defined $entry; } if ($action eq 'd') { deleteValue($entry); } elsif ($action eq 'e') { editValue($entry); } elsif ($action eq 'c') { clearValue($entry); } return; } print "Unrecognised action: $action\n"; } ## Entry point for the editor loop. sub main { my @operations; my $outfile = ''; my $showHelp = 0; my ($formatArg, $outFormatArg, $delimiterArg) = ('', '', ''); Getopt::Long::Configure('no_ignore_case'); # -f and -F are different options my $optionsOk = GetOptions( 'a|addentry=s' => sub { my ($optName, $optValue) = @_; push @operations, { type => 'add', value => $optValue }; }, 'd|delentry=s' => sub { my ($optName, $optValue) = @_; push @operations, { type => 'del', value => $optValue }; }, 'o|outfile=s' => \$outfile, 'f|format=s' => \$formatArg, 'F|outformat=s' => \$outFormatArg, 'delimiter=s' => \$delimiterArg, 'h|help' => \$showHelp, ); if (!$optionsOk) { usage(); exit 1; } if ($showHelp) { usage(); exit 0; } if ($formatArg ne '') { $inFormatOverride = normalizeFormatName($formatArg); if (!$inFormatOverride) { print "Unknown format '$formatArg' for --format. Use yaml, json, or ini.\n"; exit 1; } } if ($outFormatArg ne '') { $outFormatOverride = normalizeFormatName($outFormatArg); if (!$outFormatOverride) { print "Unknown format '$outFormatArg' for --outformat. Use yaml, json, or ini.\n"; exit 1; } if (trim($outfile) eq '') { print "--outformat needs --outfile (an in-place save keeps the input format).\n"; exit 1; } } if ($delimiterArg ne '') { if ($delimiterArg ne ':' && $delimiterArg ne '=') { print "--delimiter must be ':' or '='.\n"; exit 1; } setIniDelimiter($delimiterArg); } my $isNonInteractive = @operations || trim($outfile) ne ''; if ($isNonInteractive) { my $inputFile = @ARGV ? $ARGV[0] : ''; exit(runNonInteractive($inputFile, $outfile, \@operations) ? 0 : 1); } if (!@ARGV) { print "Available formats: " . join(', ', availableFormats()) . "\n"; } else { my $initialFile = $ARGV[0]; if (!loadFile($initialFile)) { print "No data loaded. Enter a filename to continue or press Ctrl-D to exit.\n"; my $manual = ; chomp $manual if defined $manual; $manual = trim($manual || ''); if ($manual ne '') { loadFile($manual); } } } while (1) { displayScreen(); print "Action> "; my $action = ; last if !defined $action; chomp $action; my ($parsedAction, $parsedTarget) = parseActionInput($action); $action = lc(trim($parsedAction)); next if $action eq ''; last if $action eq 'q' && promptForQuit(); processAction($action, $parsedTarget); } print "Goodbye.\n"; } main();