git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitf31c86b4077501d77e62223f223d221d956a09d1
parenta0eae3abf5
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 14:17
feat(config): state_home() and cce_state_dir()

The XDG state base had no helper beside config_home/data_home/
runtime_dir, so cce-browser, cce-secrets, cce-keyring-sync and cce-mail
each resolve $XDG_STATE_HOME by hand, and all of them then join `cce`.
state_home() is that base; cce_state_dir() is `<state_home>/cce`, the
directory every app keeps its own subdirectory under (not created here:
the caller makes the one it writes, with the mode it needs).

A relative $XDG_STATE_HOME counts as unset, as the spec requires (only
cce-mail's copy did that; the others accepted any non-empty value).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/config.rs | 41 +++++++++++++++++++++++++++++++++++++++++
 1 file changed, 41 insertions(+)

diff --git a/src/config.rs b/src/config.rs
index 1b5fadb..d8b14ec 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -543,6 +543,21 @@ pub fn data_home() -> std::path::PathBuf {
     }
 }
 
+/// XDG state base directory: `$XDG_STATE_HOME`, else `~/.local/state`.
+///
+/// State is what should survive a restart but is not worth backing up or
+/// syncing like data, and is not the user's to edit like config: history,
+/// open tabs, logs, a "never ask again" list. A relative `$XDG_STATE_HOME`
+/// counts as unset, as the XDG spec says it must (an empty one too).
+pub fn state_home() -> std::path::PathBuf {
+    match std::env::var_os("XDG_STATE_HOME").map(std::path::PathBuf::from) {
+        Some(x) if x.is_absolute() => x,
+        _ => std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default())
+            .join(".local")
+            .join("state"),
+    }
+}
+
 /// XDG runtime base directory: `$XDG_RUNTIME_DIR`, else the temp dir.
 ///
 /// Unlike the other bases there is no `~/...` fallback to construct: the
@@ -562,6 +577,13 @@ pub fn cce_config_dir() -> std::path::PathBuf {
     config_home().join("cce")
 }
 
+/// The cce state directory (`<state_home>/cce`); each app keeps its own
+/// subdirectory under it (`cce/browser`, `cce/mail`, …). Not created here:
+/// a caller creates the subdirectory it writes, with the mode it needs.
+pub fn cce_state_dir() -> std::path::PathBuf {
+    state_home().join("cce")
+}
+
 /// The cce runtime directory (`<runtime_dir>/cce`), created if absent.
 ///
 /// Session-scoped files — logs, sockets, pid files — belong here rather than
@@ -879,6 +901,25 @@ pub fn get_kdl_type_annotations(kdl_content: &str, key_paths: &[String]) -> Vec<
 
 #[cfg(test)]
 mod tests {
+    /// `$XDG_STATE_HOME` when it is absolute; `~/.local/state` when it is
+    /// unset, empty or relative. Nothing else in the crate reads the
+    /// variable, so setting it here cannot race another test.
+    #[test]
+    fn state_home_follows_xdg_and_falls_back() {
+        let home = std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default());
+        let fallback = home.join(".local").join("state");
+        std::env::set_var("XDG_STATE_HOME", "/srv/state");
+        assert_eq!(super::state_home(), std::path::PathBuf::from("/srv/state"));
+        assert_eq!(super::cce_state_dir(), std::path::PathBuf::from("/srv/state/cce"));
+        for invalid in ["", "relative/state"] {
+            std::env::set_var("XDG_STATE_HOME", invalid);
+            assert_eq!(super::state_home(), fallback, "{invalid:?}");
+        }
+        std::env::remove_var("XDG_STATE_HOME");
+        assert_eq!(super::state_home(), fallback);
+        assert_eq!(super::cce_state_dir(), fallback.join("cce"));
+    }
+
     /// A string the writer emits comes back as it went in, quotes,
     /// backslashes and control characters included — as a section value,
     /// a top-level value, a keybind (written with a type annotation), a