aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-22 00:07:51 -0500
committerCraig Jennings <c@cjennings.net>2026-04-22 00:07:51 -0500
commit9958ec4c4396ae8435f7e1818ff383c05df47a14 (patch)
tree9835229246368cca582f669837cd2859a79c8862
parent603f70d78f771c0a14c7f312aee6da68060b5d8b (diff)
downloademacs-wttrin-9958ec4c4396ae8435f7e1818ff383c05df47a14.tar.gz
emacs-wttrin-9958ec4c4396ae8435f7e1818ff383c05df47a14.zip
feat: add IP geolocation command for setting wttrin-favorite-location
Lets users set `wttrin-favorite-location` by IP lookup instead of typing a city by hand. `M-x wttrin-set-location-from-geolocation` runs the lookup, shows the detected "City, Region" in a yes/no prompt, and on confirmation sets the variable for the session. The docstring points at `M-x customize-save-variable` for persistence across restarts. The new `wttrin-geolocation.el` module provides the provider layer. Three providers come built in: ipapi.co (the default), ipinfo.io, and ipwho.is. All three are HTTPS, need no API key, and have free tiers large enough for interactive use. The module has three layers. Pure JSON parsers handle the per-provider quirks: ipapi's `error: true` flag, ipwho.is's `success: false` flag, ipinfo's HTTP-status-only signalling. A small fetch helper extracts the HTTP body. `wttrin-geolocation-detect` wires them together and calls back with "City, Region" on success, or nil on any failure (network error, HTTP 4xx or 5xx, malformed response, rate-limit signal). Providers live in an alist keyed by symbol, with plist values for :name, :url, and :parser. To use a different provider, push an entry onto `wttrin-geolocation--providers` and select it via `wttrin-geolocation-provider`. No code change needed. README gains a subsection under Mode-line Weather Display covering the command, how to persist the result, provider selection with free-tier limits, and the accuracy caveat for VPN or mobile-hotspot users. 39 new tests across the parser layer (10 ipapi, 6 ipinfo, 6 ipwhois), fetch-and-dispatch (11), and interactive command (6). Each suite covers Normal, Boundary, and Error categories. Tests mock `url-retrieve` and `yes-or-no-p` at their boundaries and run the real extract-and-parse pipeline underneath. Test suite: 333 → 373 passing.
-rw-r--r--README.org19
-rw-r--r--tests/test-wttrin-geolocation--parse-ipapi.el83
-rw-r--r--tests/test-wttrin-geolocation--parse-ipinfo.el61
-rw-r--r--tests/test-wttrin-geolocation--parse-ipwhois.el63
-rw-r--r--tests/test-wttrin-geolocation-detect.el179
-rw-r--r--tests/test-wttrin-set-location-from-geolocation.el122
-rw-r--r--wttrin-geolocation.el190
-rw-r--r--wttrin.el34
8 files changed, 751 insertions, 0 deletions
diff --git a/README.org b/README.org
index cb8f5ef..df05568 100644
--- a/README.org
+++ b/README.org
@@ -233,6 +233,25 @@ If a refresh fails, the emoji dims to gray and the tooltip tells you what went w
*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.
+*** Setting the Favorite Location from IP Geolocation
+If you don't want to type your city by hand, wttrin can detect it for you:
+
+#+begin_src emacs-lisp
+ M-x wttrin-set-location-from-geolocation
+#+end_src
+
+This looks up your city via IP geolocation, shows the detected location, and sets =wttrin-favorite-location= after you confirm. To make the setting persist across Emacs sessions, run =M-x customize-save-variable RET wttrin-favorite-location RET=, or add =(setq wttrin-favorite-location "Your City, State")= to your init file.
+
+The default lookup provider is =ipapi.co=. Two alternatives ship with the package, both free and key-less:
+
+#+begin_src emacs-lisp
+ (setq wttrin-geolocation-provider 'ipapi) ;; ipapi.co (default, 30k/month)
+ (setq wttrin-geolocation-provider 'ipinfo) ;; ipinfo.io (50k/month)
+ (setq wttrin-geolocation-provider 'ipwhois) ;; ipwho.is (10k/month)
+#+end_src
+
+*Note:* IP-based geolocation can be wrong when you are behind a VPN or using a mobile hotspot. The confirmation prompt lets you reject an inaccurate result. If you prefer, set =wttrin-favorite-location= directly to any city string that wttr.in understands.
+
** Debugging and Troubleshooting
If something isn't working, debug mode logs every fetch, every display update, and every error.
diff --git a/tests/test-wttrin-geolocation--parse-ipapi.el b/tests/test-wttrin-geolocation--parse-ipapi.el
new file mode 100644
index 0000000..26d67bc
--- /dev/null
+++ b/tests/test-wttrin-geolocation--parse-ipapi.el
@@ -0,0 +1,83 @@
+;;; test-wttrin-geolocation--parse-ipapi.el --- Tests for ipapi.co response parser -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for `wttrin-geolocation--parse-ipapi'. Pure function — no
+;; network, no async. Covers normal, boundary, and error cases per the
+;; Normal/Boundary/Error discipline.
+
+;;; Code:
+
+(require 'ert)
+(require 'wttrin-geolocation)
+
+;;; Setup and Teardown
+
+(defun test-wttrin-geolocation--parse-ipapi-setup ()
+ "Setup for ipapi parser tests."
+ nil)
+
+(defun test-wttrin-geolocation--parse-ipapi-teardown ()
+ "Teardown for ipapi parser tests."
+ nil)
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin-geolocation--parse-ipapi-normal-full-response-returns-formatted-string ()
+ "A full ipapi.co response yields \"City, Region\"."
+ (let ((json "{\"ip\":\"1.2.3.4\",\"city\":\"Berkeley\",\"region\":\"California\",\"country_name\":\"United States\",\"latitude\":37.87,\"longitude\":-122.27}"))
+ (should (string= "Berkeley, California"
+ (wttrin-geolocation--parse-ipapi json)))))
+
+(ert-deftest test-wttrin-geolocation--parse-ipapi-normal-unicode-preserved ()
+ "Unicode city and region names round-trip through the parser."
+ (let ((json "{\"city\":\"São Paulo\",\"region\":\"São Paulo\"}"))
+ (should (string= "São Paulo, São Paulo"
+ (wttrin-geolocation--parse-ipapi json)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-wttrin-geolocation--parse-ipapi-boundary-extra-fields-ignored ()
+ "Unknown fields in the response do not affect the parsed result."
+ (let ((json "{\"city\":\"Paris\",\"region\":\"Ile-de-France\",\"currency\":\"EUR\",\"asn\":\"AS1234\"}"))
+ (should (string= "Paris, Ile-de-France"
+ (wttrin-geolocation--parse-ipapi json)))))
+
+(ert-deftest test-wttrin-geolocation--parse-ipapi-boundary-multi-word-city-preserved ()
+ "Multi-word city names are preserved verbatim."
+ (let ((json "{\"city\":\"New Orleans\",\"region\":\"Louisiana\"}"))
+ (should (string= "New Orleans, Louisiana"
+ (wttrin-geolocation--parse-ipapi json)))))
+
+;;; Error Cases
+
+(ert-deftest test-wttrin-geolocation--parse-ipapi-error-nil-input-returns-nil ()
+ "A nil input string returns nil."
+ (should-not (wttrin-geolocation--parse-ipapi nil)))
+
+(ert-deftest test-wttrin-geolocation--parse-ipapi-error-empty-string-returns-nil ()
+ "An empty input string returns nil."
+ (should-not (wttrin-geolocation--parse-ipapi "")))
+
+(ert-deftest test-wttrin-geolocation--parse-ipapi-error-malformed-json-returns-nil ()
+ "Malformed JSON returns nil rather than signalling."
+ (should-not (wttrin-geolocation--parse-ipapi "{not valid json")))
+
+(ert-deftest test-wttrin-geolocation--parse-ipapi-error-missing-city-returns-nil ()
+ "A response without a city field returns nil."
+ (let ((json "{\"region\":\"California\"}"))
+ (should-not (wttrin-geolocation--parse-ipapi json))))
+
+(ert-deftest test-wttrin-geolocation--parse-ipapi-error-missing-region-returns-nil ()
+ "A response without a region field returns nil."
+ (let ((json "{\"city\":\"Berkeley\"}"))
+ (should-not (wttrin-geolocation--parse-ipapi json))))
+
+(ert-deftest test-wttrin-geolocation--parse-ipapi-error-api-error-flag-returns-nil ()
+ "An ipapi rate-limit / error response returns nil even if city/region are absent."
+ (let ((json "{\"error\":true,\"reason\":\"RateLimited\",\"message\":\"wait\"}"))
+ (should-not (wttrin-geolocation--parse-ipapi json))))
+
+(provide 'test-wttrin-geolocation--parse-ipapi)
+;;; test-wttrin-geolocation--parse-ipapi.el ends here
diff --git a/tests/test-wttrin-geolocation--parse-ipinfo.el b/tests/test-wttrin-geolocation--parse-ipinfo.el
new file mode 100644
index 0000000..4543e32
--- /dev/null
+++ b/tests/test-wttrin-geolocation--parse-ipinfo.el
@@ -0,0 +1,61 @@
+;;; test-wttrin-geolocation--parse-ipinfo.el --- Tests for ipinfo.io response parser -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for `wttrin-geolocation--parse-ipinfo'. Pure function — no
+;; network, no async.
+
+;;; Code:
+
+(require 'ert)
+(require 'wttrin-geolocation)
+
+;;; Setup and Teardown
+
+(defun test-wttrin-geolocation--parse-ipinfo-setup ()
+ "Setup for ipinfo parser tests."
+ nil)
+
+(defun test-wttrin-geolocation--parse-ipinfo-teardown ()
+ "Teardown for ipinfo parser tests."
+ nil)
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin-geolocation--parse-ipinfo-normal-full-response-returns-formatted-string ()
+ "A full ipinfo.io response yields \"City, Region\"."
+ (let ((json "{\"ip\":\"8.8.8.8\",\"city\":\"Mountain View\",\"region\":\"California\",\"country\":\"US\",\"loc\":\"37.3860,-122.0838\",\"org\":\"AS15169 Google LLC\",\"postal\":\"94035\",\"timezone\":\"America/Los_Angeles\"}"))
+ (should (string= "Mountain View, California"
+ (wttrin-geolocation--parse-ipinfo json)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-wttrin-geolocation--parse-ipinfo-boundary-minimal-fields-works ()
+ "A response with only city and region parses correctly."
+ (let ((json "{\"city\":\"Berlin\",\"region\":\"Berlin\"}"))
+ (should (string= "Berlin, Berlin"
+ (wttrin-geolocation--parse-ipinfo json)))))
+
+;;; Error Cases
+
+(ert-deftest test-wttrin-geolocation--parse-ipinfo-error-nil-input-returns-nil ()
+ "A nil input string returns nil."
+ (should-not (wttrin-geolocation--parse-ipinfo nil)))
+
+(ert-deftest test-wttrin-geolocation--parse-ipinfo-error-malformed-json-returns-nil ()
+ "Malformed JSON returns nil rather than signalling."
+ (should-not (wttrin-geolocation--parse-ipinfo "not json at all")))
+
+(ert-deftest test-wttrin-geolocation--parse-ipinfo-error-missing-city-returns-nil ()
+ "A response without a city field returns nil."
+ (let ((json "{\"region\":\"California\",\"country\":\"US\"}"))
+ (should-not (wttrin-geolocation--parse-ipinfo json))))
+
+(ert-deftest test-wttrin-geolocation--parse-ipinfo-error-missing-region-returns-nil ()
+ "A response without a region field returns nil."
+ (let ((json "{\"city\":\"Mountain View\",\"country\":\"US\"}"))
+ (should-not (wttrin-geolocation--parse-ipinfo json))))
+
+(provide 'test-wttrin-geolocation--parse-ipinfo)
+;;; test-wttrin-geolocation--parse-ipinfo.el ends here
diff --git a/tests/test-wttrin-geolocation--parse-ipwhois.el b/tests/test-wttrin-geolocation--parse-ipwhois.el
new file mode 100644
index 0000000..14c8119
--- /dev/null
+++ b/tests/test-wttrin-geolocation--parse-ipwhois.el
@@ -0,0 +1,63 @@
+;;; test-wttrin-geolocation--parse-ipwhois.el --- Tests for ipwho.is response parser -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for `wttrin-geolocation--parse-ipwhois'. Pure function — no
+;; network, no async. The ipwho.is response carries a `success' flag which
+;; is false on rate-limit / lookup failure even when the response is
+;; well-formed JSON.
+
+;;; Code:
+
+(require 'ert)
+(require 'wttrin-geolocation)
+
+;;; Setup and Teardown
+
+(defun test-wttrin-geolocation--parse-ipwhois-setup ()
+ "Setup for ipwho.is parser tests."
+ nil)
+
+(defun test-wttrin-geolocation--parse-ipwhois-teardown ()
+ "Teardown for ipwho.is parser tests."
+ nil)
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin-geolocation--parse-ipwhois-normal-success-response-returns-formatted-string ()
+ "A successful ipwho.is response yields \"City, Region\"."
+ (let ((json "{\"ip\":\"8.8.8.8\",\"success\":true,\"type\":\"IPv4\",\"country\":\"United States\",\"country_code\":\"US\",\"region\":\"California\",\"region_code\":\"CA\",\"city\":\"Mountain View\",\"latitude\":37.386,\"longitude\":-122.0838}"))
+ (should (string= "Mountain View, California"
+ (wttrin-geolocation--parse-ipwhois json)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-wttrin-geolocation--parse-ipwhois-boundary-unicode-preserved ()
+ "Unicode city and region names round-trip through the parser."
+ (let ((json "{\"success\":true,\"city\":\"München\",\"region\":\"Bayern\"}"))
+ (should (string= "München, Bayern"
+ (wttrin-geolocation--parse-ipwhois json)))))
+
+;;; Error Cases
+
+(ert-deftest test-wttrin-geolocation--parse-ipwhois-error-nil-input-returns-nil ()
+ "A nil input string returns nil."
+ (should-not (wttrin-geolocation--parse-ipwhois nil)))
+
+(ert-deftest test-wttrin-geolocation--parse-ipwhois-error-success-false-returns-nil ()
+ "A response with `success: false' returns nil even if city/region are present."
+ (let ((json "{\"success\":false,\"message\":\"rate limit reached\",\"city\":\"Anywhere\",\"region\":\"Anywhere\"}"))
+ (should-not (wttrin-geolocation--parse-ipwhois json))))
+
+(ert-deftest test-wttrin-geolocation--parse-ipwhois-error-missing-city-returns-nil ()
+ "A success response without a city field returns nil."
+ (let ((json "{\"success\":true,\"region\":\"California\"}"))
+ (should-not (wttrin-geolocation--parse-ipwhois json))))
+
+(ert-deftest test-wttrin-geolocation--parse-ipwhois-error-malformed-json-returns-nil ()
+ "Malformed JSON returns nil rather than signalling."
+ (should-not (wttrin-geolocation--parse-ipwhois "{\"success\":true,\"city\":")))
+
+(provide 'test-wttrin-geolocation--parse-ipwhois)
+;;; test-wttrin-geolocation--parse-ipwhois.el ends here
diff --git a/tests/test-wttrin-geolocation-detect.el b/tests/test-wttrin-geolocation-detect.el
new file mode 100644
index 0000000..ccd113f
--- /dev/null
+++ b/tests/test-wttrin-geolocation-detect.el
@@ -0,0 +1,179 @@
+;;; test-wttrin-geolocation-detect.el --- Tests for wttrin-geolocation-detect -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for `wttrin-geolocation-detect'. Mocks `url-retrieve' at
+;; the boundary — exercises the real extract/parse logic via the selected
+;; provider. No network, no async timing.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'wttrin-geolocation)
+
+;;; Setup and Teardown
+
+(defun test-wttrin-geolocation-detect-setup ()
+ "Setup for detect tests — pin provider to ipapi for determinism."
+ (setq wttrin-geolocation-provider 'ipapi))
+
+(defun test-wttrin-geolocation-detect-teardown ()
+ "Teardown for detect tests — restore default provider."
+ (setq wttrin-geolocation-provider 'ipapi))
+
+;;; Helpers
+
+(defmacro test-wttrin-geolocation-detect--with-http (status body-string &rest body)
+ "Run BODY with `url-retrieve' mocked to return HTTP STATUS and BODY-STRING.
+The mock writes a full HTTP response into a temp buffer and invokes the
+retrieval callback with a nil status plist (success)."
+ (declare (indent 2))
+ `(cl-letf (((symbol-function 'url-retrieve)
+ (lambda (_url callback)
+ (with-temp-buffer
+ (insert (format "HTTP/1.1 %d OK\r\n" ,status))
+ (insert "Content-Type: application/json\r\n\r\n")
+ (insert ,body-string)
+ (funcall callback nil)))))
+ ,@body))
+
+(defmacro test-wttrin-geolocation-detect--with-network-error (&rest body)
+ "Run BODY with `url-retrieve' mocked to report a network-level error."
+ (declare (indent 0))
+ `(cl-letf (((symbol-function 'url-retrieve)
+ (lambda (_url callback)
+ (with-temp-buffer
+ (funcall callback '(:error (error "Network unreachable")))))))
+ ,@body))
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin-geolocation-detect-normal-ipapi-success-callback-receives-location ()
+ "A successful ipapi response leads to the callback receiving \"City, Region\"."
+ (test-wttrin-geolocation-detect-setup)
+ (unwind-protect
+ (let ((result 'unset))
+ (test-wttrin-geolocation-detect--with-http 200
+ "{\"city\":\"Berkeley\",\"region\":\"California\"}"
+ (wttrin-geolocation-detect (lambda (loc) (setq result loc))))
+ (should (string= "Berkeley, California" result)))
+ (test-wttrin-geolocation-detect-teardown)))
+
+(ert-deftest test-wttrin-geolocation-detect-normal-selected-provider-used ()
+ "Switching `wttrin-geolocation-provider' routes through that provider's URL and parser."
+ (let ((wttrin-geolocation-provider 'ipinfo)
+ (requested-url nil)
+ (result nil))
+ (cl-letf (((symbol-function 'url-retrieve)
+ (lambda (url callback)
+ (setq requested-url url)
+ (with-temp-buffer
+ (insert "HTTP/1.1 200 OK\r\n\r\n")
+ (insert "{\"city\":\"Mountain View\",\"region\":\"California\",\"loc\":\"37.4,-122.0\"}")
+ (funcall callback nil)))))
+ (wttrin-geolocation-detect (lambda (loc) (setq result loc))))
+ (should (string= "https://ipinfo.io/json" requested-url))
+ (should (string= "Mountain View, California" result))))
+
+;;; Boundary Cases
+
+(ert-deftest test-wttrin-geolocation-detect-boundary-parser-returns-nil-callback-gets-nil ()
+ "If the parser rejects the response (missing fields), the callback receives nil."
+ (test-wttrin-geolocation-detect-setup)
+ (unwind-protect
+ (let ((result 'unset))
+ (test-wttrin-geolocation-detect--with-http 200
+ "{\"city\":\"Berkeley\"}"
+ (wttrin-geolocation-detect (lambda (loc) (setq result loc))))
+ (should-not result))
+ (test-wttrin-geolocation-detect-teardown)))
+
+(ert-deftest test-wttrin-geolocation-detect-boundary-ipapi-rate-limit-error-flag-returns-nil ()
+ "An ipapi rate-limit response (HTTP 200 with error flag) yields nil."
+ (test-wttrin-geolocation-detect-setup)
+ (unwind-protect
+ (let ((result 'unset))
+ (test-wttrin-geolocation-detect--with-http 200
+ "{\"error\":true,\"reason\":\"RateLimited\"}"
+ (wttrin-geolocation-detect (lambda (loc) (setq result loc))))
+ (should-not result))
+ (test-wttrin-geolocation-detect-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-wttrin-geolocation-detect-error-network-failure-calls-callback-with-nil ()
+ "A network-level error surfaces as nil to the callback."
+ (test-wttrin-geolocation-detect-setup)
+ (unwind-protect
+ (let ((called nil)
+ (result 'unset))
+ (test-wttrin-geolocation-detect--with-network-error
+ (wttrin-geolocation-detect (lambda (loc)
+ (setq called t)
+ (setq result loc))))
+ (should called)
+ (should-not result))
+ (test-wttrin-geolocation-detect-teardown)))
+
+(ert-deftest test-wttrin-geolocation-detect-error-http-429-returns-nil ()
+ "An HTTP 429 rate-limit response yields nil."
+ (test-wttrin-geolocation-detect-setup)
+ (unwind-protect
+ (let ((result 'unset))
+ (test-wttrin-geolocation-detect--with-http 429
+ "{\"error\":\"rate limit\"}"
+ (wttrin-geolocation-detect (lambda (loc) (setq result loc))))
+ (should-not result))
+ (test-wttrin-geolocation-detect-teardown)))
+
+(ert-deftest test-wttrin-geolocation-detect-error-http-500-returns-nil ()
+ "An HTTP 500 server error yields nil."
+ (test-wttrin-geolocation-detect-setup)
+ (unwind-protect
+ (let ((result 'unset))
+ (test-wttrin-geolocation-detect--with-http 500
+ "Internal Server Error"
+ (wttrin-geolocation-detect (lambda (loc) (setq result loc))))
+ (should-not result))
+ (test-wttrin-geolocation-detect-teardown)))
+
+(ert-deftest test-wttrin-geolocation-detect-error-empty-body-returns-nil ()
+ "An empty response body yields nil."
+ (test-wttrin-geolocation-detect-setup)
+ (unwind-protect
+ (let ((result 'unset))
+ (test-wttrin-geolocation-detect--with-http 200 ""
+ (wttrin-geolocation-detect (lambda (loc) (setq result loc))))
+ (should-not result))
+ (test-wttrin-geolocation-detect-teardown)))
+
+(ert-deftest test-wttrin-geolocation-detect-error-malformed-json-returns-nil ()
+ "Malformed JSON in the response body yields nil."
+ (test-wttrin-geolocation-detect-setup)
+ (unwind-protect
+ (let ((result 'unset))
+ (test-wttrin-geolocation-detect--with-http 200 "{not valid json"
+ (wttrin-geolocation-detect (lambda (loc) (setq result loc))))
+ (should-not result))
+ (test-wttrin-geolocation-detect-teardown)))
+
+(ert-deftest test-wttrin-geolocation-detect-error-buffer-cleanup-after-success ()
+ "The response buffer is killed after a successful fetch (no leaks)."
+ (test-wttrin-geolocation-detect-setup)
+ (unwind-protect
+ (let ((buffers-before (length (buffer-list))))
+ (test-wttrin-geolocation-detect--with-http 200
+ "{\"city\":\"Paris\",\"region\":\"IDF\"}"
+ (wttrin-geolocation-detect #'ignore))
+ (should (= buffers-before (length (buffer-list)))))
+ (test-wttrin-geolocation-detect-teardown)))
+
+(ert-deftest test-wttrin-geolocation-detect-error-unknown-provider-signals-error ()
+ "Selecting an unknown provider signals a clear error."
+ (let ((wttrin-geolocation-provider 'nonexistent-provider))
+ (should-error (wttrin-geolocation-detect #'ignore) :type 'error)))
+
+(provide 'test-wttrin-geolocation-detect)
+;;; test-wttrin-geolocation-detect.el ends here
diff --git a/tests/test-wttrin-set-location-from-geolocation.el b/tests/test-wttrin-set-location-from-geolocation.el
new file mode 100644
index 0000000..170d0fb
--- /dev/null
+++ b/tests/test-wttrin-set-location-from-geolocation.el
@@ -0,0 +1,122 @@
+;;; test-wttrin-set-location-from-geolocation.el --- Tests for wttrin-set-location-from-geolocation -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for the interactive `wttrin-set-location-from-geolocation'
+;; command. Mocks `wttrin-geolocation-detect' (to invoke the callback
+;; synchronously with a chosen value) and `yes-or-no-p' (to simulate
+;; user consent). Does not hit the network and does not prompt.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'wttrin)
+(require 'wttrin-geolocation)
+
+;;; Setup and Teardown
+
+(defvar test-wttrin-set-location-from-geolocation--saved-favorite nil
+ "Snapshot of `wttrin-favorite-location' restored in teardown.")
+
+(defun test-wttrin-set-location-from-geolocation-setup ()
+ "Snapshot `wttrin-favorite-location' and clear it for the test."
+ (setq test-wttrin-set-location-from-geolocation--saved-favorite
+ wttrin-favorite-location)
+ (setq wttrin-favorite-location nil))
+
+(defun test-wttrin-set-location-from-geolocation-teardown ()
+ "Restore `wttrin-favorite-location' to its pre-test value."
+ (setq wttrin-favorite-location
+ test-wttrin-set-location-from-geolocation--saved-favorite))
+
+;;; Helpers
+
+(defmacro test-wttrin-set-location--with-detected (location confirm &rest body)
+ "Run BODY with `wttrin-geolocation-detect' returning LOCATION and `yes-or-no-p' returning CONFIRM."
+ (declare (indent 2))
+ `(cl-letf (((symbol-function 'wttrin-geolocation-detect)
+ (lambda (callback) (funcall callback ,location)))
+ ((symbol-function 'yes-or-no-p)
+ (lambda (&rest _) ,confirm)))
+ ,@body))
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin-set-location-from-geolocation-normal-confirm-sets-variable ()
+ "Successful detection followed by user confirmation sets the favorite."
+ (test-wttrin-set-location-from-geolocation-setup)
+ (unwind-protect
+ (progn
+ (test-wttrin-set-location--with-detected "Berkeley, California" t
+ (wttrin-set-location-from-geolocation))
+ (should (string= "Berkeley, California" wttrin-favorite-location)))
+ (test-wttrin-set-location-from-geolocation-teardown)))
+
+(ert-deftest test-wttrin-set-location-from-geolocation-normal-decline-leaves-variable-unchanged ()
+ "Successful detection followed by user declining leaves the favorite untouched."
+ (test-wttrin-set-location-from-geolocation-setup)
+ (setq wttrin-favorite-location "Pre-existing, Place")
+ (unwind-protect
+ (progn
+ (test-wttrin-set-location--with-detected "Berkeley, California" nil
+ (wttrin-set-location-from-geolocation))
+ (should (string= "Pre-existing, Place" wttrin-favorite-location)))
+ (test-wttrin-set-location-from-geolocation-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-wttrin-set-location-from-geolocation-boundary-unicode-location ()
+ "A Unicode location string round-trips into the favorite variable."
+ (test-wttrin-set-location-from-geolocation-setup)
+ (unwind-protect
+ (progn
+ (test-wttrin-set-location--with-detected "München, Bayern" t
+ (wttrin-set-location-from-geolocation))
+ (should (string= "München, Bayern" wttrin-favorite-location)))
+ (test-wttrin-set-location-from-geolocation-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-wttrin-set-location-from-geolocation-error-nil-detection-leaves-variable-unchanged ()
+ "When detection returns nil, the favorite variable is not modified."
+ (test-wttrin-set-location-from-geolocation-setup)
+ (setq wttrin-favorite-location "Pre-existing, Place")
+ (unwind-protect
+ (progn
+ (test-wttrin-set-location--with-detected nil t
+ (wttrin-set-location-from-geolocation))
+ (should (string= "Pre-existing, Place" wttrin-favorite-location)))
+ (test-wttrin-set-location-from-geolocation-teardown)))
+
+(ert-deftest test-wttrin-set-location-from-geolocation-error-nil-detection-does-not-prompt ()
+ "When detection returns nil, the user is not prompted for confirmation."
+ (test-wttrin-set-location-from-geolocation-setup)
+ (unwind-protect
+ (let ((prompt-called nil))
+ (cl-letf (((symbol-function 'wttrin-geolocation-detect)
+ (lambda (callback) (funcall callback nil)))
+ ((symbol-function 'yes-or-no-p)
+ (lambda (&rest _) (setq prompt-called t) t)))
+ (wttrin-set-location-from-geolocation))
+ (should-not prompt-called))
+ (test-wttrin-set-location-from-geolocation-teardown)))
+
+(ert-deftest test-wttrin-set-location-from-geolocation-error-detection-failure-shows-message ()
+ "When detection returns nil, the user sees a diagnostic message."
+ (test-wttrin-set-location-from-geolocation-setup)
+ (unwind-protect
+ (let ((messages nil))
+ (cl-letf (((symbol-function 'wttrin-geolocation-detect)
+ (lambda (callback) (funcall callback nil)))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (push (apply #'format fmt args) messages))))
+ (wttrin-set-location-from-geolocation))
+ (should (cl-some (lambda (m) (string-match-p "[Cc]ould not detect" m))
+ messages)))
+ (test-wttrin-set-location-from-geolocation-teardown)))
+
+(provide 'test-wttrin-set-location-from-geolocation)
+;;; test-wttrin-set-location-from-geolocation.el ends here
diff --git a/wttrin-geolocation.el b/wttrin-geolocation.el
new file mode 100644
index 0000000..03b573a
--- /dev/null
+++ b/wttrin-geolocation.el
@@ -0,0 +1,190 @@
+;;; wttrin-geolocation.el --- IP geolocation for wttrin -*- lexical-binding: t; coding: utf-8; -*-
+;;
+;; Copyright (C) 2026 Craig Jennings
+;; Maintainer: Craig Jennings <c@cjennings.net>
+;; Version: 0.3.1
+;; Package-Requires: ((emacs "24.4"))
+;; Keywords: weather, wttrin
+
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+;; This file is NOT part of GNU Emacs.
+
+;;; Commentary:
+
+;; IP-based geolocation support for wttrin. Three built-in providers ship
+;; with the package: ipapi.co (default), ipinfo.io, and ipwho.is. All three
+;; are HTTPS, keyless, and have generous free tiers.
+;;
+;; Users select a provider via `wttrin-geolocation-provider' or register
+;; custom providers by pushing onto `wttrin-geolocation--providers'.
+;;
+;; Each provider is a plist with keys:
+;; :name - Human-readable name (string)
+;; :url - JSON endpoint (string)
+;; :parser - Function symbol taking a JSON string and returning
+;; "City, Region" or nil on any failure.
+;;
+;; The public entry point is `wttrin-geolocation-detect', which fetches
+;; asynchronously and invokes a callback with the parsed location string
+;; or nil on failure.
+
+;;; Code:
+
+(require 'json)
+(require 'url)
+
+(defgroup wttrin-geolocation nil
+ "IP geolocation settings for wttrin."
+ :prefix "wttrin-geolocation-"
+ :group 'wttrin)
+
+(defcustom wttrin-geolocation-provider 'ipapi
+ "Provider used by `wttrin-geolocation-detect'.
+The value is a key into `wttrin-geolocation--providers'. Three
+providers ship with the package: `ipapi' (ipapi.co, the default),
+`ipinfo' (ipinfo.io), and `ipwhois' (ipwho.is). Users who register
+additional providers by pushing onto `wttrin-geolocation--providers'
+can select them here."
+ :group 'wttrin-geolocation
+ :type '(choice (const :tag "ipapi.co" ipapi)
+ (const :tag "ipinfo.io" ipinfo)
+ (const :tag "ipwho.is" ipwhois)
+ (symbol :tag "Other (registered in wttrin-geolocation--providers)")))
+
+;;; Response Parsers
+;;
+;; Each parser takes a raw JSON string and returns "City, Region" or nil.
+;; They differ only in which quirks of the upstream response they have to
+;; handle (error flags, success flags) before extracting city and region.
+
+(defun wttrin-geolocation--decode-json (json-string)
+ "Parse JSON-STRING into an alist.
+Return nil for a nil input, an empty string, or malformed JSON."
+ (when (and (stringp json-string) (> (length json-string) 0))
+ (condition-case nil
+ (let ((json-object-type 'alist)
+ (json-array-type 'list)
+ (json-key-type 'symbol))
+ (json-read-from-string json-string))
+ (error nil))))
+
+(defun wttrin-geolocation--format-city-region (data)
+ "Return \"City, Region\" built from DATA alist.
+Return nil if either the city or the region field is missing or empty."
+ (let ((city (cdr (assq 'city data)))
+ (region (cdr (assq 'region data))))
+ (when (and (stringp city) (> (length city) 0)
+ (stringp region) (> (length region) 0))
+ (format "%s, %s" city region))))
+
+(defun wttrin-geolocation--parse-ipapi (json-string)
+ "Parse an ipapi.co JSON response into a \"City, Region\" string.
+Return nil on malformed JSON, missing city or region, or an ipapi
+error response (which carries an \"error\": true field on rate-limit
+or other failures)."
+ (let ((data (wttrin-geolocation--decode-json json-string)))
+ (when (and data (not (eq t (cdr (assq 'error data)))))
+ (wttrin-geolocation--format-city-region data))))
+
+(defun wttrin-geolocation--parse-ipinfo (json-string)
+ "Parse an ipinfo.io JSON response into a \"City, Region\" string.
+Return nil on malformed JSON or missing city or region."
+ (let ((data (wttrin-geolocation--decode-json json-string)))
+ (when data
+ (wttrin-geolocation--format-city-region data))))
+
+(defun wttrin-geolocation--parse-ipwhois (json-string)
+ "Parse an ipwho.is JSON response into a \"City, Region\" string.
+Return nil on malformed JSON, missing city or region, or a
+`success: false' response (which ipwho.is uses to signal rate-limit
+or lookup failure even when the HTTP response itself is 200)."
+ (let ((data (wttrin-geolocation--decode-json json-string)))
+ (when (and data (eq t (cdr (assq 'success data))))
+ (wttrin-geolocation--format-city-region data))))
+
+;;; Provider Registry
+
+(defvar wttrin-geolocation--providers
+ '((ipapi
+ :name "ipapi.co"
+ :url "https://ipapi.co/json/"
+ :parser wttrin-geolocation--parse-ipapi)
+ (ipinfo
+ :name "ipinfo.io"
+ :url "https://ipinfo.io/json"
+ :parser wttrin-geolocation--parse-ipinfo)
+ (ipwhois
+ :name "ipwho.is"
+ :url "https://ipwho.is/"
+ :parser wttrin-geolocation--parse-ipwhois))
+ "Alist of geolocation providers keyed by symbol.
+Each entry is a plist with keys :name, :url, and :parser. The
+parser is a function symbol taking a JSON string and returning
+\"City, Region\" or nil. Users may register additional providers
+by pushing onto this list; the keys become valid values for
+`wttrin-geolocation-provider'.")
+
+(defun wttrin-geolocation--lookup-provider (symbol)
+ "Return the provider plist for SYMBOL.
+Signal an error if SYMBOL is not registered."
+ (or (cdr (assq symbol wttrin-geolocation--providers))
+ (error "Unknown wttrin-geolocation provider: %S" symbol)))
+
+;;; Fetch and Detect
+
+(defun wttrin-geolocation--extract-body ()
+ "Return the UTF-8 decoded HTTP body from the current buffer.
+Return nil for non-2xx status codes or when no body separator is
+found. Intended for use inside a `url-retrieve' callback."
+ (goto-char (point-min))
+ (when (re-search-forward "^HTTP/[0-9.]+ \\([0-9]+\\)" nil t)
+ (let ((status (string-to-number (match-string 1))))
+ (when (and (>= status 200) (< status 300))
+ (goto-char (point-min))
+ (when (re-search-forward "\r?\n\r?\n" nil t)
+ (decode-coding-string
+ (buffer-substring-no-properties (point) (point-max))
+ 'utf-8))))))
+
+(defun wttrin-geolocation-detect (callback)
+ "Detect current location via the configured geolocation provider.
+CALLBACK is invoked asynchronously with a single argument: a
+\"City, Region\" string on success, or nil on any failure (network
+error, HTTP 4xx or 5xx, malformed response, missing fields, or
+provider-specific rate-limit signals).
+
+The provider is selected by `wttrin-geolocation-provider'. Signals
+an error synchronously if that value is not registered in
+`wttrin-geolocation--providers'."
+ (let* ((provider (wttrin-geolocation--lookup-provider
+ wttrin-geolocation-provider))
+ (url (plist-get provider :url))
+ (parser (plist-get provider :parser)))
+ (url-retrieve
+ url
+ (lambda (status)
+ (let ((result nil))
+ (unless (plist-get status :error)
+ (condition-case nil
+ (let ((body (wttrin-geolocation--extract-body)))
+ (setq result (and body (funcall parser body))))
+ (error nil)))
+ (ignore-errors (kill-buffer (current-buffer)))
+ (funcall callback result))))))
+
+(provide 'wttrin-geolocation)
+;;; wttrin-geolocation.el ends here
diff --git a/wttrin.el b/wttrin.el
index 767b552..d09a64a 100644
--- a/wttrin.el
+++ b/wttrin.el
@@ -39,6 +39,10 @@
;; Declare xterm-color functions (loaded on-demand)
(declare-function xterm-color-filter "xterm-color" (string))
+;; Declare geolocation entry point (loaded on-demand by
+;; `wttrin-set-location-from-geolocation')
+(declare-function wttrin-geolocation-detect "wttrin-geolocation" (callback))
+
;; No-op stubs for debug functions (overridden when wttrin-debug.el is loaded)
(defun wttrin--debug-mode-line-info ()
"No-op stub. Replaced by `wttrin-debug' when debug mode is active."
@@ -539,6 +543,36 @@ This creates headroom to avoid frequent cleanups."
(clrhash wttrin--cache)
(message "Weather cache cleared"))
+;;;###autoload
+(defun wttrin-set-location-from-geolocation ()
+ "Detect your location via IP geolocation and set it as the favorite.
+Uses the provider named by `wttrin-geolocation-provider' to fetch
+\"City, Region\", asks for confirmation, and on yes assigns the
+result to `wttrin-favorite-location' for this session.
+
+To persist the setting across Emacs sessions, either run
+\\[customize-save-variable] on `wttrin-favorite-location', or add
+\(setq wttrin-favorite-location ...\) to your init file.
+
+IP-based geolocation can be wrong behind a VPN or a mobile hotspot.
+The confirmation prompt shows the detected location so you can
+reject inaccurate results."
+ (interactive)
+ (require 'wttrin-geolocation)
+ (message "Detecting location...")
+ (wttrin-geolocation-detect
+ (lambda (location)
+ (cond
+ ((null location)
+ (message "Could not detect location (network or provider error)"))
+ ((yes-or-no-p (format "Detected location: %s. Set as favorite? "
+ location))
+ (setq wttrin-favorite-location location)
+ (message "Set wttrin-favorite-location to: %s. Run M-x customize-save-variable to persist."
+ location))
+ (t
+ (message "Location detection cancelled"))))))
+
(defvar-local wttrin--current-location nil
"Current location displayed in this weather buffer.")