graphic design tool
git clone https://git.lucas.co/cce-designer.git
fix: cargo test no longer overwrites the user's own settings
`DesignSettings::file_path` hardcoded `$HOME/.config/cce/cce-designer/
state.kdl`, and `State::new` loads the BUNDLED project, whose meta subnets
overwrite the live viewport flags through `apply_settings_from_menubar_subnets`.
So any test that then reached `save_settings` — `toggle_network_plate`, the
dialog's toggle rows — wrote the bundled project's show_grid / show_cube /
show_origin over the real file. `cargo test` reset three of the user's own
toggles on every run, and the run was green either way.
The path now resolves through `cce_ui::config::cce_config_dir()`, so
`$XDG_CONFIG_HOME` is honored — every other app in the workspace already goes
through it and this one was the only holdout — and under `cfg(test)` it is a
per-process temp directory instead. The redirect is in the path itself rather
than in an environment variable the test module sets, because a variable
leaves the guarantee resting on every future test remembering to set it
before touching `State`, and the test that forgets destroys real settings
silently.
`Project::load_recent_files` / `save_recent_files` are gated the same way, for
a variant of the same reason: cce-ui derives that path from the EXE's
basename, so test binaries had left seven real `~/.config/cce/
cce_designer-<hash>/` directories behind. Reading was no safer than writing —
a test that loaded the real list would assert against whatever projects
happen to be on the machine running it.
`the_suite_does_not_write_the_users_own_settings` is the backstop. It spells
the real path out itself, `file_path()` being the thing under test; runs the
plate toggle — the flip `test_the_network_plate_is_an_option` documented as
unexerciseable for exactly this reason; and asserts both that a settings file
was actually written, or the check is vacuous, and that the real one did not
move. Verified against the old path under a fake HOME: it fails, and the
suite reproduces the three flipped toggles byte for byte.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 30 ++++++++++++++++++++++++++++++
src/app.rs | 54 ++++++++++++++++++++++++++++++++++++++++++++++--------
src/main.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++++++------
src/project.rs | 15 +++++++++++++++
4 files changed, 140 insertions(+), 14 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 195543f..ab15400 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -308,6 +308,36 @@ files migrate on load. Scroll behavior (`scroll_speed`, `inertial_scroll`,
`scroll_friction`) is intentionally absent: it is config-owned
(`input.inertial` in config.kdl) and must not be shadowed by app state.
+**The path honors `$XDG_CONFIG_HOME`**, resolved through
+`cce_ui::config::cce_config_dir()` like every other app in the workspace —
+this one hardcoded `$HOME/.config` until 2026-09-23 and was the only holdout.
+
+**And under `cfg(test)` it is a temp directory**, which is the part worth
+knowing. `State::new` loads the bundled project, and
+`apply_settings_from_menubar_subnets` copies that project's meta subnets over
+the live viewport flags; so any test that then reached `save_settings` —
+`run_command("toggle_network_plate")`, the dialog's toggle rows — wrote the
+BUNDLED project's show_grid / show_cube / show_origin over the user's real
+state.kdl. `cargo test` reset three of the user's own toggles on every run,
+and the run was green either way. `Project::load_recent_files` /
+`save_recent_files` are gated the same way, for a variant of the same reason:
+cce-ui derives that path from the EXE's basename, so test binaries had left
+seven real `~/.config/cce/cce_designer-<hash>/` directories behind.
+
+The redirect is in `DesignSettings::file_path` itself rather than in an
+environment variable the test module sets, because a variable leaves the
+guarantee resting on every future test remembering to set it BEFORE touching
+`State` — and the test that forgets destroys real settings, leaving nothing
+behind but toggles that came back wrong. `the_suite_does_not_write_the_users_own_settings`
+is the backstop: it spells the real path out itself (`file_path()` being the
+thing under test), runs the plate toggle, and asserts both that a settings
+file was actually written — or the check is vacuous — and that the real one
+did not move.
+
+What tests still READ is the real `~/.config/cce/config.kdl`, for grid pitch
+and the rest of the toolkit config. That is untouched and deliberate: it is
+read-only, and the suite's expectations are already calibrated against it.
+
### Conditional parameter rows
A `ParamDef` may carry `show_when`, a condition over its SIBLINGS' current
diff --git a/src/app.rs b/src/app.rs
index 8ecab5b..2747300 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1040,15 +1040,53 @@ fn hex_to_float_array(hex: &str) -> Option<[f32; 3]> {
cce_ui::color::parse_hex_rgb(hex)
}
+/// Where `cfg(test)` builds keep the files the installed app keeps under
+/// `<config home>/cce/cce-designer/` — a directory of this process's own,
+/// created on first use.
+///
+/// Per PROCESS, so two suites running at once (another session's, a second
+/// terminal's) cannot read each other's writes, and stable within one, so a
+/// save and the load after it agree about where the file is.
+#[cfg(test)]
+pub(crate) fn test_config_dir() -> std::path::PathBuf {
+ static DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
+ DIR.get_or_init(|| {
+ let dir = std::env::temp_dir().join(format!("cce-designer-test-{}", std::process::id()));
+ let _ = fs::create_dir_all(&dir);
+ dir
+ })
+ .clone()
+}
+
impl DesignSettings {
- fn file_path() -> std::path::PathBuf {
- let home = std::env::var("HOME").unwrap_or_default();
- let mut path = std::path::PathBuf::from(home);
- path.push(".config");
- path.push("cce");
- path.push("cce-designer");
- path.push("state.kdl");
- path
+ /// `<config home>/cce/cce-designer/state.kdl`, resolved through the
+ /// toolkit's own base directory so `$XDG_CONFIG_HOME` is honored. Every
+ /// other app in the workspace already goes through `cce_config_dir`;
+ /// this one hardcoded `$HOME/.config` and was the only holdout.
+ #[cfg(not(test))]
+ pub(crate) fn file_path() -> std::path::PathBuf {
+ cce_ui::config::cce_config_dir().join("cce-designer").join("state.kdl")
+ }
+
+ /// The same file under test, in a temp directory — and that redirect is
+ /// not a convenience.
+ ///
+ /// `State::new` loads the BUNDLED project, whose meta subnets overwrite
+ /// the live viewport flags through `apply_settings_from_menubar_subnets`.
+ /// So any test that then reached `save_settings` — `toggle_network_plate`,
+ /// the dialog's toggle rows — wrote the bundled project's
+ /// show_grid / show_cube / show_origin over the user's own state.kdl.
+ /// `cargo test` reset three of the user's toggles on every run, and
+ /// nothing about the run looked wrong afterwards.
+ ///
+ /// The redirect lives in the path itself rather than in an environment
+ /// variable the test module sets, because that would leave the guarantee
+ /// resting on every future test remembering to set the variable before
+ /// touching `State` — and the one test that forgets destroys real
+ /// settings silently. There is nothing here to remember.
+ #[cfg(test)]
+ pub(crate) fn file_path() -> std::path::PathBuf {
+ test_config_dir().join("state.kdl")
}
fn load_kdl(path: &std::path::Path) -> Option<Self> {
diff --git a/src/main.rs b/src/main.rs
index e8a0f41..4aad3ac 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5097,16 +5097,59 @@ mod tests {
assert!(!state.layout_current_level());
}
+ /// `cargo test` must not write the user's own settings.
+ ///
+ /// It did, until 2026-09-23. `DesignSettings::file_path` hardcoded
+ /// `$HOME/.config/cce/cce-designer/state.kdl`, and `State::new` loads the
+ /// BUNDLED project, whose meta subnets overwrite the live viewport flags —
+ /// so any test that reached `save_settings` wrote the bundled project's
+ /// show_grid / show_cube / show_origin over the user's. Every run of the
+ /// suite silently reset three of their toggles, and the run looked green.
+ ///
+ /// The real path is spelled out here rather than read from `file_path()`,
+ /// which is the thing under test and now answers with a temp directory.
+ #[test]
+ fn the_suite_does_not_write_the_users_own_settings() {
+ let real = cce_ui::config::cce_config_dir().join("cce-designer").join("state.kdl");
+ let before = std::fs::read(&real).ok();
+
+ assert!(
+ !crate::app::DesignSettings::file_path()
+ .starts_with(cce_ui::config::cce_config_dir()),
+ "the suite writes settings inside the real cce config directory"
+ );
+
+ // The flip that carried the damage: it marks settings dirty and
+ // `execute_action` saves at the end of the action.
+ let mut state = State::new(false);
+ let plate = state.network_plate;
+ assert!(state.run_command("toggle_network_plate"));
+ assert_ne!(state.network_plate, plate, "the toggle did not flip the plate");
+
+ // Without this the test passes on a save that never happened, which
+ // is exactly the bug wearing a different face.
+ assert!(
+ crate::app::DesignSettings::file_path().exists(),
+ "no settings file was written at all — the assertion below proves nothing"
+ );
+ assert_eq!(
+ std::fs::read(&real).ok(),
+ before,
+ "{} changed — a test wrote the user's real settings",
+ real.display()
+ );
+ }
+
/// The network plate is optional, and the option is reachable three ways
/// that cannot disagree: the View settings node's toggle, the network
/// pane's View menu, and the command palette.
///
- /// The toggle is NOT exercised here. Flipping it marks settings dirty and
- /// `execute_action` then writes `~/.config/cce/cce-designer/state.kdl` —
- /// the real one, since tests run with the real HOME — so a test that
- /// toggled it would rewrite the user's own settings as a side effect. What
- /// is asserted instead is everything around the flip: the default, the
- /// wiring, and the mirror.
+ /// The flip itself is exercised by
+ /// `the_suite_does_not_write_the_users_own_settings`, which is what it is
+ /// for: until the settings path was redirected under test, flipping the
+ /// plate here would have rewritten the user's own state.kdl as a side
+ /// effect. What is asserted below is everything around the flip — the
+ /// default, the wiring, and the mirror.
#[test]
fn test_the_network_plate_is_an_option() {
use crate::command::{by_id, Run};
diff --git a/src/project.rs b/src/project.rs
index 016f3d9..a52fcef 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -57,7 +57,19 @@ impl State {
self.title = title;
}
+ /// The recent list, from `<config home>/cce/<app>/recent-files.kdl`.
+ ///
+ /// Empty under test, and the write below is skipped there for the same
+ /// reason [`DesignSettings::file_path`](crate::app::DesignSettings) is
+ /// redirected: the toolkit derives that path from the EXE's basename, so
+ /// a test binary wrote a real `~/.config/cce/cce_designer-<hash>/` of its
+ /// own — seven of them had accumulated by 2026-09-23. Reading is no safer
+ /// than writing, either: a test that loaded the real list would assert
+ /// against whatever projects happen to be on the machine running it.
pub(crate) fn load_recent_files() -> Vec<std::path::PathBuf> {
+ if cfg!(test) {
+ return Vec::new();
+ }
cce_ui::config::load_recent_files()
.into_iter()
.map(std::path::PathBuf::from)
@@ -65,6 +77,9 @@ impl State {
}
fn save_recent_files(files: &[std::path::PathBuf]) {
+ if cfg!(test) {
+ return;
+ }
let string_files: Vec<String> = files.iter().map(|p| p.to_string_lossy().to_string()).collect();
cce_ui::config::save_recent_files(&string_files);
}