Skip to content

Stop the user-config tests writing to the real home directory - #34

Merged
fohara merged 1 commit into
mainfrom
fix/config-test-writes-real-home
Aug 9, 2026
Merged

fohara merged 1 commit into
mainfrom
fix/config-test-writes-real-home

Conversation

@fohara

@fohara fohara commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

What was wrong

test_user_config_save_and_load in lash-types called UserConfig::save(),
which writes ~/.lash/config.toml on the developer's machine. It restored at
the end with UserConfig::default().save().

That was wrong twice over. On the happy path it replaced whatever the user
actually had with defaults. And if the test panicked, was filtered out
mid-run, or the process was killed, the restore never ran, leaving
color_scheme = "Test Theme" in the real config. No theme resolves that name,
so every later lash invocation failed with
error: Color scheme 'Test Theme' not found.

Observed on 2026-08-09 after a mutation run killed the suite mid-execution
hundreds of times. lash index was broken machine-wide until the line was
removed by hand. Mutation testing amplified the bug rather than causing it:
any interrupted cargo test does the same.

The fix

UserConfig::load_from(path) and UserConfig::save_to(path) take the config
path explicitly. load() and save() become thin wrappers passing the
home-directory path, so production behaviour is unchanged. The tests point at
a TempDir and never resolve $HOME.

A Drop guard would not have been enough on its own: a SIGKILL still skips
it, and it still could not restore settings the test never captured.

Tests

Both existing tests are rewritten against a temp path, plus two new ones for
saving into a parent directory that does not exist yet and for rejecting
invalid TOML on load. test_user_config_load_nonexistent also drops its
"assumes ~/.lash/config.toml doesn't exist or is valid" caveat, since it now
reads a path it created itself.

Verified by running the full workspace suite and confirming the real
~/.lash/config.toml came out byte-identical.

Audit

Checked the rest of the workspace for the same pattern. lash-cli's
Config::user_config_path resolves a different location (dirs::config_dir)
and its test only inspects the returned path without writing. lash-tui's
apply_selected_theme calls save() for real, which is correct, and no test
exercises it.

…tory

`test_user_config_save_and_load` called `UserConfig::save()`, which writes
`~/.lash/config.toml` on the developer's machine. It restored afterwards
with `UserConfig::default().save()`, which was wrong twice over: on the
happy path it replaced whatever the user actually had with defaults, and
if the test panicked or the process was killed the restore never ran at
all, leaving `color_scheme = "Test Theme"` behind. No theme resolves that
name, so every later `lash` invocation on the machine failed with
`Color scheme 'Test Theme' not found`.

A mutation-testing run surfaced this on 2026-08-09 by killing the suite
mid-execution hundreds of times, but any interrupted `cargo test` does
the same.

`load_from(path)` and `save_to(path)` now take the config path explicitly;
`load()` and `save()` are thin wrappers that pass the home-directory path.
The tests point at a `TempDir` and never resolve `$HOME`. Also covered:
saving into a directory that does not exist yet, and rejecting invalid
TOML on load.

`test_user_config_load_nonexistent` no longer carries its "assumes
~/.lash/config.toml doesn't exist or is valid" caveat, since it now reads
a path it created.

Audited the rest of the workspace for the same pattern. lash-cli's
`Config::user_config_path` resolves a different location (`dirs::config_dir`)
and its test only inspects the path. lash-tui's `apply_selected_theme`
calls `save()` for real, which is correct, and no test exercises it.
@fohara
fohara merged commit c61c6e6 into main Aug 9, 2026
34 of 36 checks passed
@fohara
fohara deleted the fix/config-test-writes-real-home branch August 9, 2026 14:57
fohara added a commit that referenced this pull request Aug 9, 2026
Two bugs, both of which made `lash format` unsafe to run on a file it did
not fully model.

`format_file` rebuilt the file from the parsed model alone, and the model
holds the header, the Description section and the task tree and nothing
else. Every other section was silently deleted: a file with `## Notes`
and `## References` came back with only the header and the tasks, exit
code 0, no warning. This turned out to be broader than filed. A section
*above* `## Tasks` went too, because the parser folds it into "overview"
text that `TaskFile` never stored and the formatter never emitted.

`format_file` now takes the source alongside the parsed file. It
regenerates only the spans it owns — the H1 plus annotation block, the
Description section, the Tasks section — and copies every other line
through unchanged. Section boundaries come from
`parser::header::section_span`, which runs through pulldown-cmark, so a
`##` inside a fenced code block neither opens nor closes a section.
Passing an empty source keeps the old model-only behaviour, which is what
the existing unit tests want.

The second bug: the parser records inline labels in metadata without
removing them from the title, and the formatter wrote both. Every run
appended another copy of every label, so `- [ ] one #docs` became
`#docs #docs` and then `#docs #docs #docs`, and `format --check` reported
the file as needing formatting forever. The formatter strips inline
labels from the title and writes the sorted metadata list as the only
place they appear. The parsed title keeps them, so `lash list` and search
behave as they do today.

Each generated block ends with its own blank separator and the source's
is skipped rather than copied. Emitting both would add a blank line per
run, which is the same non-idempotence in a different coat.

Tests: sections above and below `## Tasks` survive and keep their order,
a fenced heading does not split a section, labels are emitted once, an
already-canonical file comes back byte-identical, and an idempotence
property test over six shapes asserting `format(format(x)) ==
format(x)`. All six fail against the old behaviour.

Also ticks off three items in lash.index.md that were fixed in #34 and
#35 but never marked done.
fohara added a commit that referenced this pull request Aug 9, 2026
Two bugs, both of which made `lash format` unsafe to run on a file it did
not fully model.

`format_file` rebuilt the file from the parsed model alone, and the model
holds the header, the Description section and the task tree and nothing
else. Every other section was silently deleted: a file with `## Notes`
and `## References` came back with only the header and the tasks, exit
code 0, no warning. This turned out to be broader than filed. A section
*above* `## Tasks` went too, because the parser folds it into "overview"
text that `TaskFile` never stored and the formatter never emitted.

`format_file` now takes the source alongside the parsed file. It
regenerates only the spans it owns — the H1 plus annotation block, the
Description section, the Tasks section — and copies every other line
through unchanged. Section boundaries come from
`parser::header::section_span`, which runs through pulldown-cmark, so a
`##` inside a fenced code block neither opens nor closes a section.
Passing an empty source keeps the old model-only behaviour, which is what
the existing unit tests want.

The second bug: the parser records inline labels in metadata without
removing them from the title, and the formatter wrote both. Every run
appended another copy of every label, so `- [ ] one #docs` became
`#docs #docs` and then `#docs #docs #docs`, and `format --check` reported
the file as needing formatting forever. The formatter strips inline
labels from the title and writes the sorted metadata list as the only
place they appear. The parsed title keeps them, so `lash list` and search
behave as they do today.

Each generated block ends with its own blank separator and the source's
is skipped rather than copied. Emitting both would add a blank line per
run, which is the same non-idempotence in a different coat.

Tests: sections above and below `## Tasks` survive and keep their order,
a fenced heading does not split a section, labels are emitted once, an
already-canonical file comes back byte-identical, and an idempotence
property test over six shapes asserting `format(format(x)) ==
format(x)`. All six fail against the old behaviour.

Also ticks off three items in lash.index.md that were fixed in #34 and
#35 but never marked done.
@fohara fohara mentioned this pull request Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant