aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--README.org21
-rw-r--r--tests/test-wttrin--make-cache-key.el19
-rw-r--r--tests/test-wttrin-favorite-override.el13
-rw-r--r--tests/test-wttrin-hide-follow-line.el100
-rw-r--r--tests/test-wttrin-make-default.el9
-rw-r--r--tests/test-wttrin-saved-locations.el9
-rw-r--r--tests/test-wttrin-state-file.el376
-rw-r--r--tests/testutil-wttrin.el19
-rw-r--r--wttrin.el222
10 files changed, 736 insertions, 55 deletions
diff --git a/.gitignore b/.gitignore
index 4102598..75d5c96 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/README.org b/README.org
index fd01bf0..ce4b420 100644
--- a/README.org
+++ b/README.org
@@ -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)").
@@ -218,6 +218,14 @@ wttr.in supports a handful of single-character flags that change what the report
The full list of flags is at https://wttr.in/:help. Skip =A= and =T= — wttrin manages ANSI output internally so the colored glyphs render correctly.
+If the one flag you want is the "Follow" line off, there's a dedicated toggle so you don't have to remember =F=:
+
+#+begin_src emacs-lisp
+ (setq wttrin-hide-follow-line t) ;; hide "Follow @igor_chubin for wttr.in updates"
+#+end_src
+
+It composes with =wttrin-display-options= (setting either one hides the line) and affects the weather buffer only, not the mode-line.
+
*** Cache Settings
Wttrin caches weather data and refreshes it in the background. By default it refreshes every hour and keeps up to 50 entries. You can adjust both:
@@ -254,6 +262,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 +285,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 +297,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 +362,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--make-cache-key.el b/tests/test-wttrin--make-cache-key.el
index 79c78d9..f1aca85 100644
--- a/tests/test-wttrin--make-cache-key.el
+++ b/tests/test-wttrin--make-cache-key.el
@@ -62,6 +62,25 @@ Without this, changing the forecast format keeps serving the old cached output."
(should-not (string= a c))
(should-not (string= b c))))
+;; wttrin-hide-follow-line changes the request (it adds the F flag), so it
+;; has to change the key too. Keying on the raw options string missed it and
+;; served a cached buffer that still showed the Follow line.
+(ert-deftest test-wttrin--make-cache-key-boundary-hide-follow-line-changes-key ()
+ "Boundary: turning `wttrin-hide-follow-line' on or off yields a distinct key."
+ (let ((off (let ((wttrin-display-options nil) (wttrin-hide-follow-line nil))
+ (wttrin--make-cache-key "Paris")))
+ (on (let ((wttrin-display-options nil) (wttrin-hide-follow-line t))
+ (wttrin--make-cache-key "Paris"))))
+ (should-not (string= off on))))
+
+(ert-deftest test-wttrin--make-cache-key-boundary-equivalent-requests-share-key ()
+ "Boundary: the toggle and an explicit F flag send one request, so one key."
+ (let ((toggle (let ((wttrin-display-options nil) (wttrin-hide-follow-line t))
+ (wttrin--make-cache-key "Paris")))
+ (flag (let ((wttrin-display-options "F") (wttrin-hide-follow-line nil))
+ (wttrin--make-cache-key "Paris"))))
+ (should (string= toggle flag))))
+
(ert-deftest test-wttrin--make-cache-key-boundary-language-changes-key ()
"Boundary: changing Accept-Language yields a distinct key."
(let ((en (let ((wttrin-default-languages '("Accept-Language" . "en-US")))
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-hide-follow-line.el b/tests/test-wttrin-hide-follow-line.el
new file mode 100644
index 0000000..742a872
--- /dev/null
+++ b/tests/test-wttrin-hide-follow-line.el
@@ -0,0 +1,100 @@
+;;; test-wttrin-hide-follow-line.el --- Tests for hiding the follow line -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+
+;; Unit tests for wttrin--effective-display-options and its effect on
+;; wttrin--build-url: the wttr.in F flag is added to the buffer request when
+;; wttrin-hide-follow-line is non-nil, composing with wttrin-display-options.
+
+;;; Code:
+
+(require 'ert)
+(require 'wttrin)
+
+;;; --------------------------------------------------------------------------
+;;; wttrin--effective-display-options
+;;; --------------------------------------------------------------------------
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin--effective-display-options-normal-hide-empty-adds-f ()
+ "Normal: hiding with no other options yields just the F flag."
+ (let ((wttrin-display-options nil)
+ (wttrin-hide-follow-line t))
+ (should (equal (wttrin--effective-display-options) "F"))))
+
+(ert-deftest test-wttrin--effective-display-options-normal-hide-appends-f ()
+ "Normal: hiding appends F to existing options."
+ (let ((wttrin-display-options "0q")
+ (wttrin-hide-follow-line t))
+ (should (equal (wttrin--effective-display-options) "0qF"))))
+
+(ert-deftest test-wttrin--effective-display-options-normal-no-hide-unchanged ()
+ "Normal: not hiding leaves the options untouched."
+ (let ((wttrin-display-options "0q")
+ (wttrin-hide-follow-line nil))
+ (should (equal (wttrin--effective-display-options) "0q"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-wttrin--effective-display-options-boundary-f-already-present ()
+ "Boundary: F already in the options is not duplicated."
+ (let ((wttrin-display-options "0Fq")
+ (wttrin-hide-follow-line t))
+ (should (equal (wttrin--effective-display-options) "0Fq"))))
+
+(ert-deftest test-wttrin--effective-display-options-boundary-no-hide-nil-options ()
+ "Boundary: not hiding with nil options yields an empty string."
+ (let ((wttrin-display-options nil)
+ (wttrin-hide-follow-line nil))
+ (should (equal (wttrin--effective-display-options) ""))))
+
+;; wttr.in flags are case-sensitive (q and Q differ), and string-match-p
+;; honours `case-fold-search', which defaults to t. A lowercase f must not
+;; read as the F flag, whatever the caller's case-fold setting is.
+(ert-deftest test-wttrin--effective-display-options-boundary-lowercase-f-is-not-f ()
+ "Boundary: a lowercase f in the options does not count as the F flag."
+ (let ((wttrin-display-options "0f")
+ (wttrin-hide-follow-line t)
+ (case-fold-search t))
+ (should (equal (wttrin--effective-display-options) "0fF"))))
+
+(ert-deftest test-wttrin--effective-display-options-boundary-case-fold-nil-same-result ()
+ "Boundary: the result does not depend on the caller's `case-fold-search'."
+ (let ((wttrin-display-options "0f")
+ (wttrin-hide-follow-line t)
+ (case-fold-search nil))
+ (should (equal (wttrin--effective-display-options) "0fF"))))
+
+;;; Error Cases
+
+(ert-deftest test-wttrin--effective-display-options-error-non-string-options ()
+ "Error: a non-string options value signals rather than building a bad URL."
+ (let ((wttrin-display-options 'bogus)
+ (wttrin-hide-follow-line t))
+ (should-error (wttrin--effective-display-options) :type 'wrong-type-argument)))
+
+;;; --------------------------------------------------------------------------
+;;; wttrin--build-url integration
+;;; --------------------------------------------------------------------------
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin--build-url-normal-hide-appends-f-to-url ()
+ "Normal: hiding the follow line puts the F flag at the end of the URL."
+ (let ((wttrin-display-options nil)
+ (wttrin-hide-follow-line t)
+ (wttrin-unit-system nil))
+ (should (string-suffix-p "F" (wttrin--build-url "Tokyo")))))
+
+(ert-deftest test-wttrin--build-url-normal-no-hide-no-trailing-f ()
+ "Normal: with hiding off, the URL does not end in the F flag."
+ (let ((wttrin-display-options nil)
+ (wttrin-hide-follow-line nil)
+ (wttrin-unit-system nil))
+ (should-not (string-suffix-p "F" (wttrin--build-url "Tokyo")))))
+
+(provide 'test-wttrin-hide-follow-line)
+;;; test-wttrin-hide-follow-line.el ends here
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..34f8eb3
--- /dev/null
+++ b/tests/test-wttrin-state-file.el
@@ -0,0 +1,376 @@
+;;; 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))))
+
+;;; --------------------------------------------------------------------------
+;;; Path customization after load (`:set' on `wttrin-state-file')
+;;; --------------------------------------------------------------------------
+
+(defmacro test-wttrin-state-file--with-path-sandbox (&rest body)
+ "Run BODY with a fresh temp PATH bound and the state vars isolated.
+`wttrin-state-file' is let-bound to itself so a `customize-set-variable'
+inside BODY is undone on exit; the `customized-value' property and the temp
+file are cleaned up afterward."
+ (declare (indent 0))
+ `(let ((path (expand-file-name
+ (format "wttrin-test-state-%s.el" (random 1000000))
+ temporary-file-directory))
+ (wttrin-state-file wttrin-state-file)
+ (wttrin--favorite-override nil)
+ (wttrin--saved-locations-runtime nil))
+ (unwind-protect
+ (progn ,@body)
+ (put 'wttrin-state-file 'customized-value nil)
+ (when (file-exists-p path)
+ (delete-file path)))))
+
+(ert-deftest test-wttrin-state-file-normal-set-property-wired ()
+ "Normal: `wttrin-state-file' carries the `:set' handler, so `setopt',
+`customize-set-variable', and Customize all route through it."
+ (should (eq (get 'wttrin-state-file 'custom-set) #'wttrin--state-file-set)))
+
+(ert-deftest test-wttrin-state-file-normal-set-after-load-restores-from-new-path ()
+ "Normal: customizing the path after load restores state from the new file.
+This is the use-package `:config' shape — `(setopt wttrin-state-file ...)'
+after `(require 'wttrin)' — where the load-time restore has already read the
+default path and would otherwise never see the file the setter writes."
+ (test-wttrin-state-file--with-path-sandbox
+ (let ((wttrin-state-file path)
+ (wttrin--favorite-override "Salt Lake City, Utah")
+ (wttrin--saved-locations-runtime '(("SLC" . "40.65,-111.87"))))
+ (wttrin--state-save))
+ (customize-set-variable 'wttrin-state-file path)
+ (should (equal wttrin-state-file path))
+ (should (equal wttrin--favorite-override "Salt Lake City, Utah"))
+ (should (equal wttrin--saved-locations-runtime '(("SLC" . "40.65,-111.87"))))))
+
+(ert-deftest test-wttrin-state-file-normal-set-refreshes-mode-line-on-change ()
+ "Normal: when the restore changes the effective favorite and the mode-line
+mode is on, the mode-line is refreshed so it shows the restored city."
+ (test-wttrin-state-file--with-path-sandbox
+ (let ((wttrin-state-file path)
+ (wttrin--favorite-override "Restored, UT"))
+ (wttrin--state-save))
+ (let ((refreshes 0)
+ (wttrin-favorite-location "Configured, LA")
+ (wttrin-mode-line-mode t))
+ (cl-letf (((symbol-function 'wttrin--mode-line-refresh-now)
+ (lambda () (setq refreshes (1+ refreshes)))))
+ (customize-set-variable 'wttrin-state-file path))
+ (should (= refreshes 1)))))
+
+(ert-deftest test-wttrin-state-file-boundary-set-same-favorite-no-refresh ()
+ "Boundary: a restore that leaves the effective favorite unchanged does not
+refresh the mode-line."
+ (test-wttrin-state-file--with-path-sandbox
+ (let ((wttrin-state-file path)
+ (wttrin--favorite-override "Same, UT"))
+ (wttrin--state-save))
+ (let ((refreshes 0)
+ (wttrin--favorite-override "Same, UT")
+ (wttrin-mode-line-mode t))
+ (cl-letf (((symbol-function 'wttrin--mode-line-refresh-now)
+ (lambda () (setq refreshes (1+ refreshes)))))
+ (customize-set-variable 'wttrin-state-file path))
+ (should (= refreshes 0)))))
+
+(ert-deftest test-wttrin-state-file-boundary-set-mode-line-off-no-refresh ()
+ "Boundary: a favorite change with the mode-line mode off does not refresh."
+ (test-wttrin-state-file--with-path-sandbox
+ (let ((wttrin-state-file path)
+ (wttrin--favorite-override "Restored, UT"))
+ (wttrin--state-save))
+ (let ((refreshes 0)
+ (wttrin-mode-line-mode nil))
+ (cl-letf (((symbol-function 'wttrin--mode-line-refresh-now)
+ (lambda () (setq refreshes (1+ refreshes)))))
+ (customize-set-variable 'wttrin-state-file path))
+ (should (equal wttrin--favorite-override "Restored, UT"))
+ (should (= refreshes 0)))))
+
+(ert-deftest test-wttrin-state-file-boundary-set-absent-file-nil-vars-noop ()
+ "Boundary: customizing to a path with no file and nil vars only sets the
+path — nothing is restored and no file is created."
+ (test-wttrin-state-file--with-path-sandbox
+ (customize-set-variable 'wttrin-state-file path)
+ (should (equal wttrin-state-file path))
+ (should-not (file-exists-p path))
+ (should (null wttrin--favorite-override))))
+
+(ert-deftest test-wttrin-state-file-boundary-set-absent-file-adopts-vars ()
+ "Boundary: customizing to a path with no file while the vars hold values
+adopts them into the new file, the same contract as `wttrin--state-load'."
+ (test-wttrin-state-file--with-path-sandbox
+ (setq wttrin--favorite-override "Adopted, TX")
+ (customize-set-variable 'wttrin-state-file path)
+ (should (file-exists-p path))
+ (should (equal (plist-get (wttrin--state-read) :favorite-override)
+ "Adopted, TX"))))
+
+(ert-deftest test-wttrin-state-file-error-set-corrupt-file-does-not-signal ()
+ "Error: customizing to a path holding a corrupt file sets the path, leaves
+the vars untouched, and does not signal."
+ (test-wttrin-state-file--with-path-sandbox
+ (with-temp-file path (insert "(((( not lisp"))
+ (setq wttrin--favorite-override "Kept, OK")
+ (should-not
+ (condition-case nil
+ (progn (customize-set-variable 'wttrin-state-file path) nil)
+ (error t)))
+ (should (equal wttrin-state-file path))
+ (should (equal wttrin--favorite-override "Kept, OK"))))
+
+(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
diff --git a/wttrin.el b/wttrin.el
index 435359a..812a5a9 100644
--- a/wttrin.el
+++ b/wttrin.el
@@ -176,6 +176,15 @@ xterm-color rendering produces the colored glyphs."
:type '(choice (const :tag "None" nil)
(string :tag "Options")))
+(defcustom wttrin-hide-follow-line nil
+ "When non-nil, hide the \"Follow @igor_chubin for wttr.in updates\" line.
+Adds the wttr.in F flag to the weather-buffer request so the service omits
+the line. This is the discoverable toggle for the same effect as putting
+F in `wttrin-display-options'; setting either one hides the line, and they
+do not conflict. Affects the `wttrin' buffer only, not the mode-line."
+ :group 'wttrin
+ :type 'boolean)
+
(define-obsolete-variable-alias 'wttrin-cache-ttl 'wttrin-refresh-interval "0.3.0")
@@ -238,24 +247,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 +281,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 ()
@@ -518,6 +528,19 @@ ERROR-MSG has no class."
(> (length error-msg) 0)
(get-text-property 0 'wttrin-error-type error-msg)))
+(defun wttrin--effective-display-options ()
+ "Return the wttr.in display flags for the weather-buffer request.
+Starts from `wttrin-display-options' and appends the F flag (\"do not show
+the Follow line\") when `wttrin-hide-follow-line' is non-nil and F is not
+already present, so the two controls compose without duplicating the flag.
+The match is case-sensitive, because wttr.in treats q and Q as
+different flags."
+ (let ((opts (or wttrin-display-options ""))
+ (case-fold-search nil))
+ (if (and wttrin-hide-follow-line (not (string-match-p "F" opts)))
+ (concat opts "F")
+ opts)))
+
(defun wttrin--build-url (query)
"Build wttr.in URL for QUERY with configured parameters."
(when (null query)
@@ -526,7 +549,7 @@ ERROR-MSG has no class."
(url-hexify-string query)
(wttrin-additional-url-params)
"A"
- (or wttrin-display-options "")))
+ (wttrin--effective-display-options)))
(defun wttrin--extract-http-status ()
"Return the HTTP status code from the current buffer, or nil.
@@ -646,25 +669,147 @@ 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))
+(defun wttrin--state-file-set (symbol value)
+ "Set SYMBOL (`wttrin-state-file') to VALUE and restore state from it.
+The `:set' handler for the option. The load-time restore below runs against
+whatever path the option holds when wttrin loads, so a path customized
+afterward — the use-package `:config' shape, `(setopt wttrin-state-file ...)'
+after the require — would be written by the location commands but never
+read back on the next start. Re-running `wttrin--state-load' here closes
+that gap for `setopt', `customize-set-variable', and Customize. When the
+restore changes the effective favorite and the mode-line mode is on, the
+mode-line is refreshed so it shows the restored city rather than the
+configured one. At definition time the loader is not yet defined, so the
+`fboundp' guard makes that first call a plain `set-default'; the load-time
+restore covers the initial read."
+ (let ((before (and (fboundp 'wttrin--favorite-location)
+ (wttrin--favorite-location))))
+ (set-default symbol value)
+ (when (fboundp 'wttrin--state-load)
+ (wttrin--state-load)
+ (when (and (not (equal before (wttrin--favorite-location)))
+ (bound-and-true-p wttrin-mode-line-mode))
+ (wttrin--mode-line-refresh-now)))))
+
+(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.
+The state restores at load time from this path. Set the option with
+`setopt', `customize-set-variable', or Customize (use-package `:custom' or
+`:config' both work): changing it after wttrin has loaded re-reads the state
+from the new path. A plain `setq' after load skips that restore and takes
+effect only from the next one (`savehist-mode-hook' or restart)."
+ :group 'wttrin
+ :type 'file
+ :set #'wttrin--state-file-set)
+
+(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 +905,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 +920,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 +929,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).")
@@ -1374,10 +1522,12 @@ coordinates from a geolocation command."
(defun wttrin--make-cache-key (location)
"Create a cache key from LOCATION and every setting that shapes the response.
Includes the unit system, display options, and Accept-Language, so changing any
-of them produces a distinct key rather than serving a stale-format response."
+of them produces a distinct key rather than serving a stale-format response.
+The display options are the effective ones sent to wttr.in, so
+`wttrin-hide-follow-line' changes the key and an equivalent request shares it."
(format "%S" (list location
(or wttrin-unit-system "default")
- (or wttrin-display-options "")
+ (wttrin--effective-display-options)
wttrin-default-languages)))
(defun wttrin--get-cached-or-fetch (location callback)
@@ -1523,12 +1673,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))))