diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-02 22:37:06 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-02 22:37:06 -0400 |
| commit | 8b461ecd3d4fae3be0a93523d19932e0a3d21c6c (patch) | |
| tree | fdc4beb9d03b21b3655df8e2531d5f4efe4598b5 | |
| parent | 6c808ff4a4c54d543e065b722bc8517fe5141b45 (diff) | |
| download | emacs-wttrin-8b461ecd3d4fae3be0a93523d19932e0a3d21c6c.tar.gz emacs-wttrin-8b461ecd3d4fae3be0a93523d19932e0a3d21c6c.zip | |
fix: persist runtime favorite and saved locations in a state filerelease/0.4.0
savehist rewrites its whole file on every save, keeping only the variables registered in the running process. Any Emacs that saved savehist without wttrin loaded (a batch tool, a session that never opened wttrin) silently deleted the persisted favorite and saved-location entries. A d-set default then reverted to the init value on the next restart.
The runtime favorite and directory now live in wttrin-state-file, written only by wttrin: write-through on every location command, atomic temp-file + rename, UTF-8 pinned both ways, read with read (never eval), versioned plist. Values restore at load and re-assert on savehist-mode-hook, so a stale legacy savehist restore can't clobber them. With no state file present, legacy savehist values are adopted and written once, so existing users migrate with zero steps. Search history stays on savehist. It's scrub-tolerant throwaway data, exactly what savehist is for.
Also: the README documents the mode-line tooltip forecast option, and the gitignore covers one more local tooling artifact.
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | README.org | 13 | ||||
| -rw-r--r-- | tests/test-wttrin-favorite-override.el | 13 | ||||
| -rw-r--r-- | tests/test-wttrin-make-default.el | 9 | ||||
| -rw-r--r-- | tests/test-wttrin-saved-locations.el | 9 | ||||
| -rw-r--r-- | tests/test-wttrin-state-file.el | 258 | ||||
| -rw-r--r-- | tests/testutil-wttrin.el | 19 | ||||
| -rw-r--r-- | wttrin.el | 167 |
8 files changed, 439 insertions, 52 deletions
@@ -19,3 +19,6 @@ githooks/ # Claude Code: task archive (follows todo file privacy) /archive/task-archive.org + +# Claude Code per-project tooling (swept 2026-07-01) +/AGENTS.md @@ -108,7 +108,7 @@ Simply use the keybinding you assigned, or run `M-x wttrin` to display the weath Choose one, or for a quick one-time weather check, type a new location and ⏎ . After the weather is displayed, the footer shows two groups of keys. Keys that act on the view: `a` for another location, `g` to refresh, `q` to quit. Keys that act on your saved locations: `s` to save the shown location, `d` to make it your default, `r` to rename a saved location, and `x` to remove one. -Pressing `d` makes the location on screen your default and remembers it across restarts (via savehist), so the mode-line and future sessions follow it. This works alongside a =wttrin-favorite-location= you set in your init: that option is your base default, and a location you promote with `d` overrides it and persists — so a `d` choice is never wiped out by the init value on the next restart. Your default is also offered in the location list the next time you run =M-x wttrin=. Enable =savehist-mode= for the persistence to stick. (On a geolocation-detected buffer, `d` first prompts for a name and saves it — see Naming Locations.) +Pressing `d` makes the location on screen your default and remembers it across restarts, so the mode-line and future sessions follow it. This works alongside a =wttrin-favorite-location= you set in your init: that option is your base default, and a location you promote with `d` overrides it and persists — so a `d` choice is never wiped out by the init value on the next restart. Your default is also offered in the location list the next time you run =M-x wttrin=. Persistence is automatic: wttrin keeps your default and saved locations in its own small state file (=wttrin-state-file=, in your Emacs directory by default) — nothing to enable. (On a geolocation-detected buffer, `d` first prompts for a name and saves it — see Naming Locations.) If you're looking at cached data, a line below the weather art tells you how old it is (e.g., "Last updated: 2:30 PM (5 minutes ago)"). @@ -254,6 +254,8 @@ The mode-line shows a color emoji (☀️ 🌧️ ⛅ etc.) that updates hourly. If a refresh fails, the emoji dims to gray and the tooltip tells you what went wrong and when it'll retry. Once the connection comes back, everything returns to normal on its own. +The tooltip can also include a short forecast. Set =wttrin-mode-line-tooltip-forecast-days= to a positive number and the tooltip appends one line per day — Today, Tomorrow, then the weekday — with the day's low–high temperatures and midday conditions. wttr.in's feed carries at most three days, so larger values are capped at three. The default (0) keeps the tooltip current-conditions-only; when enabled, each refresh performs a second, small JSON fetch for the forecast data, and a failed forecast fetch never disturbs the current-conditions display. + **** Customization #+begin_src emacs-lisp @@ -275,6 +277,9 @@ If a refresh fails, the emoji dims to gray and the tooltip tells you what went w ;; How long to wait before the first fetch (1-10 seconds, default 3) ;; Useful if your network is slow to come up after Emacs starts (setq wttrin-mode-line-startup-delay 5) + + ;; Append a multi-day forecast to the tooltip (0 = off, max 3 days) + (setq wttrin-mode-line-tooltip-forecast-days 3) #+end_src *Note:* If the weather emoji appears as a monochrome symbol instead of a color icon, try setting `wttrin-mode-line-emoji-font` to match a color emoji font installed on your system. Use `M-x fc-list` or check your system fonts to see what's available. @@ -284,7 +289,7 @@ If you don't want to type your city by hand, wttrin can detect it for you. *From the picker (weather here, right now):* run =M-x wttrin= and pick the first entry, "Current location (detect)". wttrin looks up your city via IP geolocation and shows its weather. If the guess is wrong (VPN, mobile hotspot), the detected city is right there in the buffer header, so just open the picker again and type the correct city. -*Make the detected city your default:* in that weather buffer, press =d=. The detected city becomes your default (what the mode-line tracks), overriding any =wttrin-favorite-location= you set in your init. With =savehist-mode= on, the choice persists across sessions automatically, since wttrin registers it with savehist. No =customize-save-variable= step is needed. +*Make the detected city your default:* in that weather buffer, press =d=. The detected city becomes your default (what the mode-line tracks), overriding any =wttrin-favorite-location= you set in your init. The choice persists across sessions automatically via wttrin's state file. No =customize-save-variable= step is needed. *Always use my current location:* run =M-x wttrin-use-current-location=, or set the variable directly: @@ -349,13 +354,13 @@ Or build the directory interactively: - =M-x wttrin-rename-location= — rename an entry (refused if the new name is already taken). - =M-x wttrin-remove-location= — remove an entry (asks to confirm). -The directory persists across sessions with =savehist-mode= on (=wttrin= registers it), the same as your favorite and history. +The directory persists across sessions automatically, alongside your default, in wttrin's state file. (Search history is separate — it rides =savehist-mode=; see Location History.) You can point =wttrin-favorite-location= at a saved name (e.g. ="Craig's House"=): the mode-line resolves it to the query for fetching but shows the name in the tooltip. When you pick "Current location (detect)" and press =d= to keep it, =wttrin= prompts for a name (prefilled with the detected address) and saves it as a named location, then makes it your default. Clear the field and press RET to keep the raw coordinates instead. Raw coordinates never clutter your history; only named places are remembered. -*Privacy:* a saved query can be a home or work street address, kept in plaintext in your savehist file. With =wttrin-debug= on, the query and raw responses are also written to the debug log. =wttrin= does not encrypt or redact these, so save what you're comfortable storing in plain text. +*Privacy:* a saved query can be a home or work street address, kept in plaintext in =wttrin-state-file= (and, for search history, your savehist file). With =wttrin-debug= on, the query and raw responses are also written to the debug log. =wttrin= does not encrypt or redact these, so save what you're comfortable storing in plain text. *** Theming the Faces The text wttrin draws itself uses named faces, so themes and =M-x customize-face= can restyle it. (The weather art itself is colored by the ANSI codes wttr.in returns, not by these faces.) diff --git a/tests/test-wttrin-favorite-override.el b/tests/test-wttrin-favorite-override.el index 0236901..95916c7 100644 --- a/tests/test-wttrin-favorite-override.el +++ b/tests/test-wttrin-favorite-override.el @@ -64,15 +64,18 @@ with `d' reverted to the init value on restart." (should (equal "New Orleans, LA" wttrin-favorite-location)) (should (equal "Paris, FR" (wttrin--favorite-location))))) -;;; savehist registration targets the runtime vars, not the config defcustoms +;;; savehist registration covers only the scrub-tolerant search history; +;;; the runtime vars persist in `wttrin-state-file' (see test-wttrin-state-file.el) -(ert-deftest test-wttrin-favorite-override-normal-savehist-registers-override () - "Normal: savehist persists the override, not the config defcustom." +(ert-deftest test-wttrin-favorite-override-normal-savehist-excludes-runtime-vars () + "Normal: savehist persists neither the runtime vars nor the config defcustoms. +The runtime favorite and directory live in `wttrin-state-file' — a savehist +entry would be scrubbed by any session that saves savehist without wttrin." (require 'savehist) (let ((savehist-additional-variables '(kill-ring))) (wttrin--savehist-register) - (should (memq 'wttrin--favorite-override savehist-additional-variables)) - (should (memq 'wttrin--saved-locations-runtime savehist-additional-variables)) + (should-not (memq 'wttrin--favorite-override savehist-additional-variables)) + (should-not (memq 'wttrin--saved-locations-runtime savehist-additional-variables)) (should-not (memq 'wttrin-favorite-location savehist-additional-variables)) (should-not (memq 'wttrin-saved-locations savehist-additional-variables)))) diff --git a/tests/test-wttrin-make-default.el b/tests/test-wttrin-make-default.el index e824194..c7954c8 100644 --- a/tests/test-wttrin-make-default.el +++ b/tests/test-wttrin-make-default.el @@ -60,13 +60,14 @@ unbound); persistence is left to `wttrin--savehist-register'." (wttrin--set-favorite-location "Berkeley, CA") (should (equal wttrin--location-history '("Oslo, NO"))))) -(ert-deftest test-wttrin-favorite-savehist-register-includes-favorite () - "Normal: `wttrin--savehist-register' registers the runtime override, not the -`wttrin-favorite-location' defcustom." +(ert-deftest test-wttrin-favorite-savehist-register-excludes-favorite () + "Normal: `wttrin--savehist-register' does not register the runtime override. +The favorite persists in `wttrin-state-file' instead — a savehist entry would +be scrubbed by any Emacs session that saves savehist without wttrin loaded." (require 'savehist) (let ((savehist-additional-variables '(kill-ring))) (wttrin--savehist-register) - (should (memq 'wttrin--favorite-override savehist-additional-variables)) + (should-not (memq 'wttrin--favorite-override savehist-additional-variables)) (should-not (memq 'wttrin-favorite-location savehist-additional-variables)))) ;;; -------------------------------------------------------------------------- diff --git a/tests/test-wttrin-saved-locations.el b/tests/test-wttrin-saved-locations.el index 1a08aaf..b41d7ee 100644 --- a/tests/test-wttrin-saved-locations.el +++ b/tests/test-wttrin-saved-locations.el @@ -159,13 +159,14 @@ Two names with the same query share a key; the name never leaks into the key." ;;; savehist -(ert-deftest test-wttrin-saved-locations-integration-savehist-registers () - "Integration: the runtime directory is registered for savehist persistence, -not the `wttrin-saved-locations' defcustom (which the user sets in init)." +(ert-deftest test-wttrin-saved-locations-integration-savehist-excludes-runtime () + "Integration: the runtime directory is not registered with savehist. +It persists in `wttrin-state-file' instead — a savehist entry would be +scrubbed by any Emacs session that saves savehist without wttrin loaded." (require 'savehist) (let ((savehist-additional-variables '(kill-ring))) (wttrin--savehist-register) - (should (memq 'wttrin--saved-locations-runtime savehist-additional-variables)) + (should-not (memq 'wttrin--saved-locations-runtime savehist-additional-variables)) (should-not (memq 'wttrin-saved-locations savehist-additional-variables)))) ;;; wttrin--coordinates-p diff --git a/tests/test-wttrin-state-file.el b/tests/test-wttrin-state-file.el new file mode 100644 index 0000000..5648a13 --- /dev/null +++ b/tests/test-wttrin-state-file.el @@ -0,0 +1,258 @@ +;;; test-wttrin-state-file.el --- Tests for state-file persistence -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;;; Commentary: + +;; Unit tests for the wttrin state file: `wttrin--state-save', +;; `wttrin--state-read', and `wttrin--state-load'. The state file persists +;; the runtime favorite (`wttrin--favorite-override') and runtime directory +;; (`wttrin--saved-locations-runtime') in a file only wttrin writes, so a +;; foreign Emacs process saving savehist without wttrin loaded can no longer +;; scrub them. Covers round-trips, authority over in-memory values, the +;; savehist-legacy adoption path, setter integration, and failure isolation. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'wttrin) + +(defmacro test-wttrin-state-file--with-sandbox (&rest body) + "Run BODY with `wttrin-state-file' bound to a fresh temp path. +The runtime vars start nil. The file is removed afterward." + (declare (indent 0)) + `(let ((wttrin-state-file (expand-file-name + (format "wttrin-test-state-%s.el" (random 1000000)) + temporary-file-directory)) + (wttrin--favorite-override nil) + (wttrin--saved-locations-runtime nil)) + (unwind-protect + (progn ,@body) + (when (file-exists-p wttrin-state-file) + (delete-file wttrin-state-file))))) + +;;; -------------------------------------------------------------------------- +;;; Normal Cases +;;; -------------------------------------------------------------------------- + +(ert-deftest test-wttrin-state-file-normal-save-load-round-trip () + "Normal: save writes both vars; load restores them after they are cleared." + (test-wttrin-state-file--with-sandbox + (setq wttrin--favorite-override "Hyatt Place Warwick, RI") + (setq wttrin--saved-locations-runtime + '(("Hyatt Place Warwick, RI" . "41.7266678,-71.443097"))) + (wttrin--state-save) + (setq wttrin--favorite-override nil) + (setq wttrin--saved-locations-runtime nil) + (wttrin--state-load) + (should (equal wttrin--favorite-override "Hyatt Place Warwick, RI")) + (should (equal wttrin--saved-locations-runtime + '(("Hyatt Place Warwick, RI" . "41.7266678,-71.443097")))))) + +(ert-deftest test-wttrin-state-file-normal-file-wins-over-memory () + "Normal: an existing state file is authoritative over in-memory values. +This is the guard against a later savehist restore of stale legacy lines." + (test-wttrin-state-file--with-sandbox + (setq wttrin--favorite-override "Newer, ME") + (wttrin--state-save) + (setq wttrin--favorite-override "Stale Legacy, LA") + (setq wttrin--saved-locations-runtime '(("Stale" . "stale"))) + (wttrin--state-load) + (should (equal wttrin--favorite-override "Newer, ME")) + (should (null wttrin--saved-locations-runtime)))) + +(ert-deftest test-wttrin-state-file-normal-set-favorite-writes-file () + "Normal: `wttrin--set-favorite-location' persists to the state file." + (test-wttrin-state-file--with-sandbox + (let ((wttrin-favorite-location nil) + (wttrin--location-history nil)) + (wttrin--set-favorite-location "Paris, FR") + (should (file-exists-p wttrin-state-file)) + (should (equal (plist-get (wttrin--state-read) :favorite-override) + "Paris, FR"))))) + +(ert-deftest test-wttrin-state-file-normal-put-saved-location-writes-file () + "Normal: `wttrin--put-saved-location' persists to the state file." + (test-wttrin-state-file--with-sandbox + (wttrin--put-saved-location "Home" "New Orleans, LA") + (should (equal (plist-get (wttrin--state-read) :saved-locations) + '(("Home" . "New Orleans, LA")))))) + +(ert-deftest test-wttrin-state-file-normal-remove-saved-location-writes-file () + "Normal: `wttrin--remove-saved-location' persists the removal." + (test-wttrin-state-file--with-sandbox + (wttrin--put-saved-location "Home" "New Orleans, LA") + (wttrin--remove-saved-location "Home") + (should (null (plist-get (wttrin--state-read) :saved-locations))))) + +(ert-deftest test-wttrin-state-file-normal-rename-location-writes-file () + "Normal: `wttrin-rename-location' persists the rename and favorite update." + (test-wttrin-state-file--with-sandbox + (let ((wttrin-saved-locations nil) + (wttrin-favorite-location nil) + (wttrin--location-history nil)) + (wttrin--put-saved-location "Hotel" "41.72,-71.44") + (wttrin--set-favorite-location "Hotel") + (wttrin-rename-location "Hotel" "Hyatt") + (let ((data (wttrin--state-read))) + (should (equal (plist-get data :saved-locations) + '(("Hyatt" . "41.72,-71.44")))) + (should (equal (plist-get data :favorite-override) "Hyatt")))))) + +(ert-deftest test-wttrin-state-file-normal-adopts-savehist-legacy-values () + "Normal: with no state file, non-nil vars (savehist legacy) are adopted." + (test-wttrin-state-file--with-sandbox + (setq wttrin--favorite-override "Legacy, TX") + (wttrin--state-load) + (should (file-exists-p wttrin-state-file)) + (should (equal (plist-get (wttrin--state-read) :favorite-override) + "Legacy, TX")))) + +(ert-deftest test-wttrin-state-file-normal-savehist-mode-hook-wired () + "Normal: `wttrin--state-load' is on `savehist-mode-hook' so a savehist +restore after wttrin loads cannot clobber state-file values." + (should (memq #'wttrin--state-load savehist-mode-hook))) + +;;; -------------------------------------------------------------------------- +;;; Boundary Cases +;;; -------------------------------------------------------------------------- + +(ert-deftest test-wttrin-state-file-boundary-tri-state-t-round-trips () + "Boundary: the favorite's auto-detect value t survives a round-trip." + (test-wttrin-state-file--with-sandbox + (setq wttrin--favorite-override t) + (wttrin--state-save) + (setq wttrin--favorite-override nil) + (wttrin--state-load) + (should (eq wttrin--favorite-override t)))) + +(ert-deftest test-wttrin-state-file-boundary-nils-round-trip () + "Boundary: an explicitly saved all-nil state loads as nils." + (test-wttrin-state-file--with-sandbox + (wttrin--state-save) + (setq wttrin--favorite-override "Ghost, AZ") + (setq wttrin--saved-locations-runtime '(("Ghost" . "ghost"))) + (wttrin--state-load) + (should (null wttrin--favorite-override)) + (should (null wttrin--saved-locations-runtime)))) + +(ert-deftest test-wttrin-state-file-boundary-long-directory-round-trips () + "Boundary: a long saved-locations alist survives intact. +Guards the `print-length' / `print-level' bindings in the writer." + (test-wttrin-state-file--with-sandbox + (let ((entries (cl-loop for i from 1 to 60 + collect (cons (format "Place %02d" i) + (format "%d.0,-%d.0" i i))))) + (setq wttrin--saved-locations-runtime entries) + (wttrin--state-save) + (setq wttrin--saved-locations-runtime nil) + (wttrin--state-load) + (should (equal wttrin--saved-locations-runtime entries)) + (should (= (length wttrin--saved-locations-runtime) 60))))) + +(ert-deftest test-wttrin-state-file-boundary-unicode-round-trips () + "Boundary: unicode names and queries survive a round-trip." + (test-wttrin-state-file--with-sandbox + (setq wttrin--favorite-override "Zürich 🌦️") + (setq wttrin--saved-locations-runtime '(("北京" . "Beijing, CN"))) + (wttrin--state-save) + (setq wttrin--favorite-override nil) + (setq wttrin--saved-locations-runtime nil) + (wttrin--state-load) + (should (equal wttrin--favorite-override "Zürich 🌦️")) + (should (equal wttrin--saved-locations-runtime '(("北京" . "Beijing, CN")))))) + +(ert-deftest test-wttrin-state-file-boundary-read-absent-file-returns-nil () + "Boundary: reading a nonexistent state file returns nil." + (test-wttrin-state-file--with-sandbox + (should-not (wttrin--state-read)))) + +(ert-deftest test-wttrin-state-file-boundary-absent-file-nil-vars-noop () + "Boundary: no file and nil vars is a silent no-op — no file created." + (test-wttrin-state-file--with-sandbox + (wttrin--state-load) + (should-not (file-exists-p wttrin-state-file)) + (should (null wttrin--favorite-override)) + (should (null wttrin--saved-locations-runtime)))) + +;;; -------------------------------------------------------------------------- +;;; Error Cases +;;; -------------------------------------------------------------------------- + +(ert-deftest test-wttrin-state-file-error-corrupt-file-does-not-signal () + "Error: unreadable lisp in the state file is ignored, vars untouched." + (test-wttrin-state-file--with-sandbox + (with-temp-file wttrin-state-file (insert "(((( not lisp")) + (setq wttrin--favorite-override "Kept, OK") + (wttrin--state-load) + (should (equal wttrin--favorite-override "Kept, OK")))) + +(ert-deftest test-wttrin-state-file-error-empty-file-does-not-signal () + "Error: an empty state file is ignored, vars untouched." + (test-wttrin-state-file--with-sandbox + (with-temp-file wttrin-state-file) + (setq wttrin--favorite-override "Kept, OK") + (wttrin--state-load) + (should (equal wttrin--favorite-override "Kept, OK")))) + +(ert-deftest test-wttrin-state-file-error-wrong-shape-data-ignored () + "Error: readable lisp that is not a versioned plist is treated as corrupt." + (test-wttrin-state-file--with-sandbox + (with-temp-file wttrin-state-file (insert "[1 2 3]")) + (setq wttrin--favorite-override "Kept, OK") + (wttrin--state-load) + (should (equal wttrin--favorite-override "Kept, OK")))) + +(ert-deftest test-wttrin-state-file-error-failed-write-preserves-file () + "Error: a failed write leaves the previous state file intact. +The writer goes through a temp file + rename, so an error before the +rename cannot truncate or clobber the existing file." + (test-wttrin-state-file--with-sandbox + (setq wttrin--favorite-override "Good, OK") + (wttrin--state-save) + (setq wttrin--favorite-override "Never Written, NV") + (cl-letf (((symbol-function 'write-region) + (lambda (&rest _) (error "Disk full")))) + (wttrin--state-save)) + (should (equal (plist-get (wttrin--state-read) :favorite-override) + "Good, OK")))) + +(ert-deftest test-wttrin-state-file-error-corrupt-file-survives-adoption () + "Error: adoption never writes over an existing corrupt state file. +The file is left byte-for-byte in place for inspection; non-nil vars do +not trigger the missing-file adoption write because the file exists." + (test-wttrin-state-file--with-sandbox + (with-temp-file wttrin-state-file (insert "(((( not lisp")) + (setq wttrin--favorite-override "Legacy, TX") + (wttrin--state-load) + (should (equal (with-temp-buffer + (insert-file-contents wttrin-state-file) + (buffer-string)) + "(((( not lisp")))) + +(ert-deftest test-wttrin-state-file-error-unwritable-path-does-not-signal () + "Error: a save to an unwritable path messages instead of signaling." + (let ((wttrin-state-file "/nonexistent-root-dir/wttrin/state.el") + (wttrin--favorite-override "Anywhere, US") + (wttrin--saved-locations-runtime nil)) + (should-not + (condition-case nil (progn (wttrin--state-save) nil) (error t))))) + +;;; -------------------------------------------------------------------------- +;;; savehist registration (post state-file) +;;; -------------------------------------------------------------------------- + +(ert-deftest test-wttrin-state-file-normal-savehist-excludes-state-vars () + "Normal: `wttrin--savehist-register' no longer registers the state-file +vars; only the scrub-tolerant search history stays with savehist." + (require 'savehist) + (let ((savehist-additional-variables '(kill-ring))) + (wttrin--savehist-register) + (should (memq 'wttrin--location-history savehist-additional-variables)) + (should-not (memq 'wttrin--favorite-override savehist-additional-variables)) + (should-not (memq 'wttrin--saved-locations-runtime + savehist-additional-variables)))) + +(provide 'test-wttrin-state-file) +;;; test-wttrin-state-file.el ends here diff --git a/tests/testutil-wttrin.el b/tests/testutil-wttrin.el index e4e2e4e..1373813 100644 --- a/tests/testutil-wttrin.el +++ b/tests/testutil-wttrin.el @@ -136,17 +136,32 @@ Other mocks BODY needs can be set in a nested `cl-letf'." ;;; Test Setup and Teardown +;; Redirect state-file writes for the whole batch: any test that exercises a +;; location setter triggers `wttrin--state-save', which must never touch the +;; developer's real `wttrin-state-file'. Process-wide, set once at load. +(setq wttrin-state-file + (expand-file-name (format "wttrin-test-state-%d.el" (emacs-pid)) + temporary-file-directory)) + (defun testutil-wttrin-setup () "Common setup for wttrin tests. Call this at the beginning of each test." (testutil-wttrin-clear-cache) - (setq wttrin--force-refresh nil)) + (setq wttrin--force-refresh nil) + (setq wttrin--favorite-override nil) + (setq wttrin--saved-locations-runtime nil) + (when (file-exists-p wttrin-state-file) + (delete-file wttrin-state-file))) (defun testutil-wttrin-teardown () "Common teardown for wttrin tests. Call this at the end of each test." (testutil-wttrin-clear-cache) - (setq wttrin--force-refresh nil)) + (setq wttrin--force-refresh nil) + (setq wttrin--favorite-override nil) + (setq wttrin--saved-locations-runtime nil) + (when (file-exists-p wttrin-state-file) + (delete-file wttrin-state-file))) (provide 'testutil-wttrin) ;;; testutil-wttrin.el ends here @@ -238,24 +238,24 @@ coordinates. For example: (\"Home\" . \"41.37,-71.83\")) A bare string S used anywhere a location is expected is shorthand for -\(S . S) — name and query the same. Persisted across sessions via -`savehist-mode'; add entries interactively with \\[wttrin-save-location] or the -`d' key in a weather buffer, or set this in your init." +\(S . S) — name and query the same. Entries added interactively with +\\[wttrin-save-location] or the `d' key in a weather buffer are persisted in +`wttrin-state-file'; entries set here in your init are re-read each start." :group 'wttrin :type '(alist :key-type (string :tag "Name") :value-type (string :tag "Query"))) (defvar wttrin--favorite-override nil - "Runtime favorite set by `d'/`wttrin-make-default' and the geolocation commands. -Persisted across sessions via savehist. When non-nil it shadows the configured -`wttrin-favorite-location', so a favorite chosen at runtime survives a restart -even when the user also sets `wttrin-favorite-location' in their init. Carries -the same tri-state shape as that option: nil (no override), a string, or t -\(auto-detect).") + "Runtime favorite set by `d'/`wttrin-make-default' and geolocation commands. +Persisted across sessions in `wttrin-state-file'. When non-nil it shadows +the configured `wttrin-favorite-location', so a favorite chosen at runtime +survives a restart even when the user also sets `wttrin-favorite-location' +in their init. Carries the same tri-state shape as that option: nil (no +override), a string, or t (auto-detect).") (defvar wttrin--saved-locations-runtime nil "Saved-location entries added at runtime via `d' or `wttrin-save-location'. -Persisted across sessions via savehist. Overlaid on the configured +Persisted across sessions in `wttrin-state-file'. Overlaid on the configured `wttrin-saved-locations' by `wttrin--saved-locations', runtime winning on a name collision, so directory edits survive a restart without being clobbered by an init that also sets `wttrin-saved-locations'. An alist of (NAME . QUERY).") @@ -272,10 +272,11 @@ during the resolution window.") (defun wttrin--favorite-location () "Return the effective favorite: the runtime override, else the configured one. -`wttrin--favorite-override' (set at runtime by `d' and the geolocation commands, -persisted by savehist) wins over the `wttrin-favorite-location' defcustom, so a -runtime choice is not clobbered by an init that also sets the option. Carries -the option's tri-state shape: nil, a string, or t." +`wttrin--favorite-override' (set at runtime by `d' and the geolocation +commands, persisted in `wttrin-state-file') wins over the +`wttrin-favorite-location' defcustom, so a runtime choice is not clobbered +by an init that also sets the option. Carries the option's tri-state +shape: nil, a string, or t." (or wttrin--favorite-override wttrin-favorite-location)) (defun wttrin--resolve-favorite-location () @@ -646,25 +647,122 @@ Persisted across sessions via `savehist-mode'.") (defvar savehist-additional-variables) (defun wttrin--savehist-register () - "Ensure wttrin's persisted runtime state is saved by savehist. -Registers `wttrin--location-history', `wttrin--favorite-override', and -`wttrin--saved-locations-runtime' so they survive across restarts without the -Emacs custom-variable mechanism. Deliberately registers the runtime override -and runtime directory rather than the `wttrin-favorite-location' and -`wttrin-saved-locations' defcustoms: those are configuration the user sets in -init, and persisting them would fight the init on every restart (the value the -user last chose with `d' would be clobbered by the init `setopt'). + "Register the search history with savehist. +Only `wttrin--location-history' rides savehist: it is scrub-tolerant +throwaway data, which is what savehist is for. The runtime favorite and +runtime directory are deliberately NOT registered — savehist rewrites its +whole file on every save keeping only currently-registered variables, so any +Emacs process that saves savehist without wttrin loaded (a deferred-loading +session where wttrin was never opened, a batch tool) would silently delete +them. Those live in `wttrin-state-file' instead, which only wttrin writes. Run both at load and on `savehist-save-hook', so the registration survives a user `setq' of `savehist-additional-variables' (a common config pattern) that -would otherwise drop the entries before they could be saved." - (add-to-list 'savehist-additional-variables 'wttrin--location-history) - (add-to-list 'savehist-additional-variables 'wttrin--favorite-override) - (add-to-list 'savehist-additional-variables 'wttrin--saved-locations-runtime)) +would otherwise drop the entry before it could be saved." + (add-to-list 'savehist-additional-variables 'wttrin--location-history)) + +;;;###autoload (with-eval-after-load 'savehist (add-to-list 'savehist-additional-variables 'wttrin--location-history)) (with-eval-after-load 'savehist (wttrin--savehist-register) (add-hook 'savehist-save-hook #'wttrin--savehist-register)) +(defcustom wttrin-state-file (locate-user-emacs-file "wttrin-state.el") + "File persisting wttrin's runtime state across sessions. +Holds the runtime favorite (`wttrin--favorite-override') and the runtime +saved-locations directory (`wttrin--saved-locations-runtime') — the values +written by `d'/`wttrin-make-default', `wttrin-save-location', and the +geolocation commands. A dedicated file rather than savehist because savehist +rewrites its whole file keeping only the variables registered in the current +process: any Emacs session that saved savehist without wttrin loaded would +silently delete the persisted favorite. Only wttrin writes this file. +Customize this before wttrin loads (e.g. via use-package `:custom'); the +state restores at load time, so a later `setq' takes effect only from the +next restore (`savehist-mode-hook' or restart)." + :group 'wttrin + :type 'file) + +(defun wttrin--state-save () + "Write the runtime favorite and directory to `wttrin-state-file'. +Writes atomically (temp file + rename) so a crash mid-write cannot leave a +truncated file, always as UTF-8 so the file round-trips regardless of the +session's default coding system (the same reason savehist pins +`savehist-coding-system'). Never signals: persistence failure must not +break the location command that triggered it — it messages and moves on." + (condition-case err + (let* ((file (expand-file-name wttrin-state-file)) + (dir (file-name-directory file)) + (tmp (make-temp-name (concat file ".tmp"))) + (coding-system-for-write 'utf-8-emacs) + (print-length nil) + (print-level nil)) + (unless (file-directory-p dir) (make-directory dir t)) + (unwind-protect + (progn + (with-temp-buffer + (insert ";; wttrin runtime state -- managed by wttrin;" + " do not edit by hand.\n") + (prin1 (list :version 1 + :favorite-override wttrin--favorite-override + :saved-locations wttrin--saved-locations-runtime) + (current-buffer)) + (insert "\n") + (write-region (point-min) (point-max) tmp nil 'silent)) + (rename-file tmp file t) + (setq tmp nil)) + ;; Any failure above leaves the temp file behind; remove it. On + ;; success TMP is nil and there is nothing to clean. + (when (and tmp (file-exists-p tmp)) + (delete-file tmp)))) + (error (message "wttrin: could not save state to %s: %s" + wttrin-state-file (error-message-string err))))) + +(defun wttrin--state-read () + "Read `wttrin-state-file' and return its state plist, or nil. +Returns nil when the file is absent, unreadable, or does not contain a +versioned plist. Reads with `read', never `eval', so the state file cannot +execute code, and as UTF-8, matching how `wttrin--state-save' writes it. +Never signals — a broken state file must not break load." + (when (file-readable-p wttrin-state-file) + (condition-case err + (with-temp-buffer + (let ((coding-system-for-read 'utf-8-emacs)) + (insert-file-contents wttrin-state-file)) + (let ((data (read (current-buffer)))) + (if (and (listp data) (integerp (plist-get data :version))) + data + (message "wttrin: ignoring malformed state file %s" + wttrin-state-file) + nil))) + (error (message "wttrin: could not read state file %s: %s" + wttrin-state-file (error-message-string err)) + nil)))) + +(defun wttrin--state-load () + "Restore the runtime favorite and directory from `wttrin-state-file'. +An existing state file is authoritative: its values replace whatever is in +memory, which is what defeats a later savehist restore of stale legacy +entries (this function also runs on `savehist-mode-hook'). When the file +does not exist but either variable is non-nil — savehist restored legacy +values from a pre-state-file wttrin earlier in init — those values are +adopted and written, migrating existing users with zero steps. A corrupt +file is left in place and ignored: vars keep their current values and no +adoption write happens over it." + (let ((data (wttrin--state-read))) + (cond + (data + (setq wttrin--favorite-override (plist-get data :favorite-override)) + (setq wttrin--saved-locations-runtime (plist-get data :saved-locations))) + ((and (not (file-exists-p wttrin-state-file)) + (or wttrin--favorite-override wttrin--saved-locations-runtime)) + (wttrin--state-save))))) + +;; Restore persisted state at load — but not in batch (CI, test runners, +;; scripts), which stays hermetic by design. The savehist-mode-hook entry +;; re-asserts the state file after savehist restores, in case a savehist file +;; still carrying legacy pre-state-file entries loads later in init. +(unless noninteractive (wttrin--state-load)) +(add-hook 'savehist-mode-hook #'wttrin--state-load) + (defconst wttrin--geolocation-sentinel "Current location (detect)" "Picker candidate that triggers geolocation detection. Selecting it routes through `wttrin--query-selection' to a @@ -760,9 +858,10 @@ Used to keep a raw geolocation fix out of history and to decide when the (defun wttrin--put-saved-location (name query) "Add or update NAME -> QUERY in the runtime directory; return the saved name. -Writes `wttrin--saved-locations-runtime' (savehist-persisted), never the -`wttrin-saved-locations' defcustom, so a runtime save is not clobbered by an -init that also sets the option. Trims NAME and QUERY. Signals a `user-error' +Writes `wttrin--saved-locations-runtime' (persisted in `wttrin-state-file'), +never the `wttrin-saved-locations' defcustom, so a runtime save is not +clobbered by an init that also sets the option. Trims NAME and QUERY. +Signals a `user-error' for an empty name or query, or a name equal to the geolocation sentinel. An existing runtime name has its query updated." (let ((name (string-trim (or name ""))) @@ -774,6 +873,7 @@ existing runtime name has its query updated." (setq wttrin--saved-locations-runtime (append (wttrin--saved-locations-runtime-without name) (list (cons name query)))) + (wttrin--state-save) name)) (defun wttrin--remove-saved-location (name) @@ -782,7 +882,8 @@ Only the runtime layer `wttrin--saved-locations-runtime' is mutated. An entry from the `wttrin-saved-locations' defcustom cannot be deleted here (it is re-read from the user's init) and remains in the effective directory." (setq wttrin--saved-locations-runtime - (wttrin--saved-locations-runtime-without name))) + (wttrin--saved-locations-runtime-without name)) + (wttrin--state-save)) (defvar-local wttrin--current-location nil "Query for the weather shown in this buffer (the fetch/cache identity).") @@ -1523,12 +1624,12 @@ defcustom, so the choice survives a restart even when the user also sets the option in their init (the override shadows the config; see `wttrin--favorite-location'). LOCATION becomes a permanent default, so it no longer needs a history entry, mirroring how `wttrin-default-locations' entries -are kept out of history. Persistence is handled by `wttrin--savehist-register', -which registers the override when savehist loads and again on -`savehist-save-hook', so setting it works whether or not savehist is loaded." +are kept out of history. Persisted immediately to `wttrin-state-file', so the +choice survives restarts regardless of savehist." (let ((changed (not (equal location (wttrin--favorite-location))))) (setq wttrin--favorite-override location) (setq wttrin--location-history (delete location wttrin--location-history)) + (wttrin--state-save) (when (and changed (bound-and-true-p wttrin-mode-line-mode)) (wttrin--mode-line-refresh-now)))) |
