# YAMLPatch Module Documentation

A path-addressed, formatting-preserving editor for block-style YAML.

**Version:** 1.0.0
**Copyright:** 2026 Daily Data Inc.
**License:** Simplified BSD License (FreeBSD License)

---

## Why this exists

General-purpose YAML libraries (`YAML::Tiny`, `YAML::XS`) work by loading a document into a
data structure and, on save, re-serializing the *whole thing* from that structure. For a
config file a human reads and edits alongside your automation, that has real costs:

- **Comments are lost.** There is no comment slot in a parsed hash.
- **Keys are re-sorted**, typically alphabetically, regardless of how the author ordered them.
- **Scalars get re-quoted** according to the dumper's own rules, not the author's. `off`
  emitted bare instead of `'off'` is a real bug waiting to happen: it's a YAML 1.1 boolean, so
  a different or stricter parser reads it as `false`, not the string `"off"`.
- **Flow-style values can round-trip as broken strings.** `[1,2,3]` re-dumped by a minimal
  parser can come back as the literal string `'[1,2,3]'` - this is a defect that shipped in
  production (sneakernet v1.10.8/v1.10.9) via exactly this mechanism.

YAMLPatch never builds a document model and re-serializes it. It parses just enough structure
to map a dotted path (`transport.compression.method`) to the one physical line that owns it,
and every mutation rewrites only that line. Everything else - comments, blank lines, key
order, quoting style elsewhere in the file - is untouched, byte for byte.

The cost of that guarantee is a **restricted subset** of YAML. Outside the subset, every
operation refuses rather than guessing. See "Supported subset" and "Relaxation flags" below.

---

## Quick start

```perl
use YAMLPatch;

my $y = YAMLPatch->load_file('config.yaml');

$y->get('transport.compression.method');   # => { value => 'off', style => 'single', ... }

$y->set('transport.compression.method', 'xz', style => 'single');
$y->delete('datasets.ds1.maxDelta');
$y->comment_out('datasets.ds2');
$y->insert_kv(parent => 'transport', key => 'verifyFullSendDataset',
              value => 'storage/testing/verify', style => 'bare', after => 'verifyStream');

$y->save('config.yaml');   # atomic write; dies and leaves the file untouched on any failure
```

Or from the shell:

```sh
utilities/yamlpatch set transport.compression.method "xz" config.yaml --style single
```

---

## Supported subset

Parsing fails (with a message naming the offending line) on anything outside this subset,
unless the matching relaxation flag is given:

- 2-space-multiple indentation. **No tabs, anywhere, ever** - not relaxable.
- A single block-mapping document (`key:` / `key: value`).
- Block sequences of **inline scalars only** (`- value`). A sequence item that itself opens a
  nested map or sequence is outside the subset.
- Scalar values: bare, `'single-quoted'`, or `"double-quoted"`. Double-quoted values support
  `\"` and `\\` only - no other backslash escapes.
- Full-line comments and trailing inline comments (`key: value  # note`).
- **No** anchors/aliases/tags, flow collections (`[...]`/`{...}`), block scalars (`|`/`>`),
  duplicate sibling keys, or a second `---` document - unless explicitly allowed (next
  section). Duplicate sibling keys have no relaxation flag: which one "wins" is genuinely
  parser-dependent, so this is always a hard error.

A key name must match `[A-Za-z_][\w-]*` (this covers every real-world config this module has
been tested against; quoted YAML keys are not supported).

## Relaxation flags

Pass these to `load_file`/`load_string`. Each accepts the named construct as an **opaque,
whole-value leaf**: it can be read and replaced as a single unit, but never parsed into or
addressed by sub-path.

| Flag | Accepts | `get()` returns |
|---|---|---|
| `allow_flow` | A value starting with `[` or `{` | The raw bracketed text, verbatim |
| `allow_block_scalar` | A `\|`/`>` block scalar and its indented continuation lines | The continuation lines, joined with `\n` |
| `allow_anchors` | A value containing a real anchor/alias/tag (`&name`, `*name`, `!tag`, `!!type`) | The raw value text, verbatim |
| `allow_multidoc` | A second `---` document separator | Nothing - everything from that line to EOF is preserved verbatim on save but is not parsed or addressable at all |

The harness this module was built for (see `sneakernet/Documentation/TESTING_automation.md`)
passes **none** of these flags, by design - it wants the strict subset. They exist for this
module's general-purpose use elsewhere. A real example: `sneakernet/problem/*.yaml` files use
`diskList: []` (an empty flow-style list) and need `allow_flow => 1` to load; a value like
`**redacted**` is a plain bare string, not an alias, and needs no flag at all (a leading `*` or
`&` only triggers anchor/alias detection when immediately followed by an identifier
character).

---

## API reference

### Constructors

#### `YAMLPatch->load_file($path, %opts)`

Load and parse a file. `%opts` are the relaxation flags above. Dies on read failure or a
subset violation. Returns a `YAMLPatch` object.

#### `YAMLPatch->load_string($text, %opts)`

Same as `load_file`, from a string already in memory. Useful for testing and for editing text
that didn't come from a file.

### Read-only accessors

#### `$y->get($path)`

Returns `{ value, value_raw, style, line, commented, kind }` for a real (non-commented) path,
or `undef` if the path does not exist. `value` is dequoted; `value_raw` is exactly as written.
`kind` is `'kv'`, `'seqitem'`, or `'mapopen'` (a container key with no scalar value of its
own - `value` will be `undef`).

#### `$y->exists_path($path)` / `$y->exists_commented($path)`

Boolean checks. A path can be `exists_commented` true and `exists_path` false at the same
time (it's currently commented out) - this is exactly the state `set()` refuses to silently
paper over (see below).

#### `$y->path_list`

All real, addressable paths, sorted.

#### `$y->as_text`

The document's current full text (reflects any mutations made so far, before `save()`).

#### `$y->diff($originalText)`

A line-level diff between `$originalText` (defaults to the text as loaded / last saved) and
the current in-memory text, in the form:

```
-  12: method: 'off'
+  12: method: 'xz'
```

Uses a proper LCS line alignment, not a positional (`old[i]` vs `new[i]`) comparison - a
single inserted line is reported as one line, not as "every line after it, because they all
shifted position."

### Mutators

All mutators operate in memory. Nothing touches disk until `save()`. Each mutator re-parses
its result internally and dies immediately if the edit would produce something outside the
supported subset, so errors surface at the point of the mistake, not later at `save()` time.

#### `$y->set($path, $value, style => 'bare'|'single'|'double'|'keep')`

Rewrites a scalar's value in place, preserving its line's indentation and any trailing inline
comment. `style => 'keep'` (the default) reuses the existing quoting style - and refuses if
the new value isn't safe to emit in that style (e.g. keeping `bare` style for a value that
contains a space), asking you to pick a style explicitly instead of silently changing the
quoting or emitting something ambiguous.

Refuses if the path doesn't exist. If the path exists **only as a comment**, refuses with a
message telling you to call `uncomment()` first - it will not silently create a second, live
copy alongside a commented one that a human reading the file would expect to still apply.

Setting a value to what it already is is a true no-op: no line is rewritten, nothing is
recorded as changed.

#### `$y->delete($path)`

Removes the line (and, for a container, its entire subtree) that owns `$path`.

#### `$y->comment_out($path)` / `$y->uncomment($path)`

Reversibly disables a key and its subtree by prefixing `#` to each line's content (after its
indentation), or removes that prefix. `comment_out()` followed by `uncomment()` on the same
path restores the original text exactly.

A comment already present when the file was loaded (e.g. a hand-edited `#maxDelta: 0.9`) is
recognized by `exists_commented()` / `get()`-returns-undef even if this module never commented
it out - `uncomment()` works on it the same way.

#### `$y->insert_kv(parent => $p, key => $k, value => $v, style => ..., after|before => $sibling)`

Adds a new `key: value` line as a child of `$p` (use `parent => ''` for the top level).
`after`/`before` anchor the insertion next to an existing sibling; without either, it's
appended as the container's last child. Dies if `$p.$k` already exists, if `$p` isn't a
container, or if the named sibling doesn't exist.

#### `$y->insert_block(parent => $p, key => $k, text => $snippet, after|before => $sibling)`

Like `insert_kv`, but `$snippet` is itself a small YAML mapping (as literal text, indented
from column 0 - it's re-indented automatically to fit). Used for adding a whole new
multi-key block, e.g. a full dataset definition.

#### `$y->append_raw($text, allow_unsafe => 1)`

Appends `$text` verbatim with **no validation at all** - the one operation that can produce a
file outside the supported subset, or invalid YAML entirely. Refuses without
`allow_unsafe => 1`. Intended for deliberately testing how downstream consumers handle a
corrupted config; a subsequent `save()` automatically skips the round-trip/independent-parser/
delta gates (there is nothing meaningful to check) but still performs the atomic write and
still runs any caller-supplied hooks.

### Committing: `$y->save($path, %opts)`

```perl
$y->save($path,
    hooks          => [ \&my_containment_check ],
    max_line_delta => 10,
    backup         => 1,
);
```

Nothing is written until `save()`, and `save()` either fully succeeds or leaves the on-disk
file completely untouched - it writes to a temp file in the same directory and `rename()`s it
into place only after every gate below passes.

| Gate | Check | Skippable? |
|---|---|---|
| 1 | The module's own parser round-trips the result | no (structural) |
| 2 | An independent parser (`YAML::Tiny`, if installed) also accepts it | `skip_yaml_tiny_check => 1`, or automatic if `YAML::Tiny` isn't installed |
| 3 | The semantic delta **equals** the mutations actually requested this session - not a subset. A `set()` that silently resolved to a commented-out line and changed nothing would be caught here | no |
| 4 | Total changed lines (proper diff alignment, not positional) is within `max_line_delta`, if given | yes - omit the option |
| 5 | No disallowed construct was introduced (true by construction for every mutator except `append_raw`) | no |
| 6, 7 | Caller-supplied `hooks` | opt-in - only run if you pass `hooks` |

**Hooks** are the extension point for application-specific policy. Each is called as
`$hook->($self, $newText)` and must either `die` with an explanatory message or return a false
value to refuse the commit:

```perl
$y->save($path, hooks => [
    sub {
        my ($self, $text) = @_;
        return $text !~ /storage\/backup/;   # e.g.: never let this config point at production
    },
]);
```

A hook that refuses leaves the original file byte-for-byte untouched - this is what lets a
harness guarantee "the patcher cannot produce a config the containment check would refuse"
without `YAMLPatch.pm` itself knowing anything about what "containment" means for your
application.

`backup => 1` copies the existing file to `$path.bak.<epoch-seconds>` before writing, if the
file currently exists.

---

## Design notes worth knowing before you extend this module

- **Re-parse after every mutation, not incremental bookkeeping.** Each mutator applies its
  change to a copy of the raw lines and re-parses the *whole* document from scratch to rebuild
  paths/records. For the config-sized documents this module targets (tens to low hundreds of
  lines), that costs nothing measurable and eliminates an entire class of bugs from
  incrementally patching line-number bookkeeping after a splice.
- **The YAML same-indent sequence quirk is handled.** `key:` followed by `- item` at the
  *same* indentation as `key:` (not the more common "indented one level deeper") is valid YAML
  and is exactly what this project's own configs use for `cleanupScriptSchedule` lists. The
  owner-lookup for a sequence item pops the parse stack only while the top frame's indent is
  *strictly greater* than the item's indent, so both styles resolve correctly.
- **Subtree deletion trims trailing blank lines but not trailing comments.** A comment
  immediately inside a block (e.g. a commented-out sibling field, like `#maxDelta: 0.9` as
  `ds2`'s last line in the sneakernet config) is far more often part of that block than a
  banner for the next one - if `comment_out()`/`delete()` "helpfully" excluded it, it would be
  silently re-parented to whatever sibling happens to follow.
- **Diff and the line-delta gate use real LCS alignment**, then group adjacent delete/add runs
  the way a diffstat does (`max(deleted, added)` per run) rather than counting every line in
  the run. A naive positional comparison (`old[i]` vs `new[i]`) makes a single-line insertion
  look like it changed every subsequent line, since they all shifted position by one - this
  was caught during development via the CLI's `--dry-run` output on a real insert.

---

## `utilities/yamlpatch` (command-line front end)

Same operations, same code path, for shell scripts and non-Perl callers:

```sh
yamlpatch get    transport.compression.method  file.yaml
yamlpatch set    transport.compression.method  xz  file.yaml --style single
yamlpatch delete datasets.ds1.maxDelta  file.yaml
yamlpatch comment-out datasets.ds2  file.yaml
yamlpatch uncomment   datasets.ds2  file.yaml
yamlpatch insert-kv transport verifyFullSendDataset storage/testing/verify file.yaml \
    --style bare --after verifyStream
yamlpatch list file.yaml
yamlpatch exists debug file.yaml   # exit 0/1, no output
```

`--dry-run` prints the diff and writes nothing. `--backup` writes a timestamped backup before
saving. `--allow-flow`/`--allow-block-scalar`/`--allow-anchors`/`--allow-multidoc` mirror the
API's relaxation flags. Exit status: `0` success, `1` refusal (subset violation, gate failure,
path not found), `2` command-line usage error. Run `yamlpatch --help` for the full option
list.

---

## Testing

`testLibrary/test_YAMLPatch.pl` - unit tests for the module, including round-trip fidelity
against every real YAML file in this repository (not just synthetic fixtures) and against the
exact sneakernet `TESTING.md` Step 0.5 canonical config.

`testLibrary/test_yamlpatch_cli.pl` - proves the CLI and the API produce byte-identical
results for the same operation.

Run either directly: `perl testLibrary/test_YAMLPatch.pl`.
