git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commita7bb7607a6191eae26b069001a57af426aa104b7
parentea1f715f24
authorLucas Galante <[email protected]>
date2026-09-23 12:06
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);
     }