diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-24 07:21:44 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-24 07:21:44 -0500 |
| commit | 12fb0108ba217f06fb9d40da8431d49540650402 (patch) | |
| tree | 4d2c6630b3a6bc8f13de45697bc88ba89a257389 | |
| parent | bc9652752c231e8634a90c875ed6d206ff172458 (diff) | |
| download | dotemacs-12fb0108ba217f06fb9d40da8431d49540650402.tar.gz dotemacs-12fb0108ba217f06fb9d40da8431d49540650402.zip | |
fix(org): surface directory-scan failures instead of crashing or hiding them
The refile target scan caught permission-denied and silently dropped the directory, and would crash outright on a missing root (only permission-denied was caught, so a missing code-dir/projects-dir raised file-missing and aborted the whole build). The agenda build had the same crash: cj/add-files-to-org-agenda-files-list called directory-files on projects-dir with no existence check.
Extracted cj/--org-refile-scan-dir, which warns (display-warning) and returns nil for a missing, unreadable, or permission-denied root so the rest of the scan continues. Guarded the agenda scan the same way. Both now log a concise warning naming the skipped directory rather than failing silently or fatally.
Also fixed a latent bug surfaced here: org-refile-targets was never declared special, so under make compile cj/org-refile-in-file let-bound it lexically and the scoped targets never reached org-refile. Added (defvar org-refile-targets) so the binding stays dynamic when byte-compiled. Tests cover the helper (missing/permission-denied/normal) and the agenda missing-dir guard.
| -rw-r--r-- | modules/org-agenda-config.el | 22 | ||||
| -rw-r--r-- | modules/org-refile-config.el | 51 | ||||
| -rw-r--r-- | tests/test-org-agenda-config-add-files.el | 12 | ||||
| -rw-r--r-- | tests/test-org-refile-config--scan-dir.el | 57 |
4 files changed, 124 insertions, 18 deletions
diff --git a/modules/org-agenda-config.el b/modules/org-agenda-config.el index 22bc569c..231eff8a 100644 --- a/modules/org-agenda-config.el +++ b/modules/org-agenda-config.el @@ -149,13 +149,21 @@ the file keeps precedence." "Add todo.org files from immediate subdirectories of DIRECTORY. Only checks DIRECTORY/*/todo.org — does not recurse deeper." (interactive "D") - (let ((todo-files - (seq-filter - #'file-exists-p - (mapcar (lambda (dir) (expand-file-name "todo.org" dir)) - (seq-filter #'file-directory-p - (directory-files directory t "^[^.]")))))) - (setq org-agenda-files (append todo-files org-agenda-files)))) + (if (not (and (file-directory-p directory) (file-readable-p directory))) + ;; Non-fatal: a missing or unreadable project root shouldn't crash the + ;; whole agenda build — surface it and carry on with the other files. + (display-warning + 'org-agenda + (format "Agenda scan: project directory missing or unreadable, skipped: %s" + directory) + :warning) + (let ((todo-files + (seq-filter + #'file-exists-p + (mapcar (lambda (dir) (expand-file-name "todo.org" dir)) + (seq-filter #'file-directory-p + (directory-files directory t "^[^.]")))))) + (setq org-agenda-files (append todo-files org-agenda-files))))) ;; ---------------------------- Rebuild Org Agenda --------------------------- ;; builds the org agenda list from all agenda targets with caching. diff --git a/modules/org-refile-config.el b/modules/org-refile-config.el index 16b37bf9..3c3cc7da 100644 --- a/modules/org-refile-config.el +++ b/modules/org-refile-config.el @@ -16,6 +16,13 @@ (require 'system-lib) (require 'cj-cache-lib) +;; Forward-declare org-refile's dynamic var so byte-compiled code treats our +;; `let'/`setq' on it as dynamic. Without this, compiling the module turns +;; `cj/org-refile-in-file's (let ((org-refile-targets ...)) ...) into a +;; lexical binding that never reaches `org-refile', silently breaking +;; in-file refiling under `make compile'. +(defvar org-refile-targets) + ;; ----------------------------- Org Refile Targets ---------------------------- ;; sets refile targets ;; - adds project files in org-roam to the refile targets @@ -43,6 +50,34 @@ This prevents issues where: (org-mode))) buf)) +(defun cj/--org-refile-scan-dir (dir) + "Return the todo.org files under DIR, or nil with a warning if unusable. +A missing, unreadable, or permission-denied DIR is non-fatal: it logs a +`display-warning' and returns nil so the rest of the refile-target scan +continues, rather than silently swallowing the failure or crashing the +whole scan on a missing directory." + (cond + ((not (file-directory-p dir)) + (display-warning 'org-refile + (format "Refile scan: directory missing, skipped: %s" dir) + :warning) + nil) + ((not (file-readable-p dir)) + (display-warning 'org-refile + (format "Refile scan: directory unreadable, skipped: %s" dir) + :warning) + nil) + (t + (condition-case _ + (directory-files-recursively + dir "^[Tt][Oo][Dd][Oo]\\.[Oo][Rr][Gg]$" nil + (lambda (d) (not (string-match-p "airootfs" d)))) + (permission-denied + (display-warning 'org-refile + (format "Refile scan: permission denied, skipped: %s" dir) + :warning) + nil))))) + (defun cj/--org-refile-scan-targets () "Scan disk for the refile-targets list. Pure-ish: no caching, no logging. Returns the list to assign to `org-refile-targets'. Slow -- walks @@ -61,17 +96,11 @@ Returns the list to assign to `org-refile-targets'. Slow -- walks (dolist (file project-and-topic-files) (unless (assoc file new-files) (push (cons file file-rule) new-files))))) - (dolist (dir (list user-emacs-directory code-dir projects-dir)) - (condition-case nil - (let* ((todo-files (directory-files-recursively - dir "^[Tt][Oo][Dd][Oo]\\.[Oo][Rr][Gg]$" - nil - (lambda (d) (not (string-match-p "airootfs" d))))) - (file-rule '(:maxlevel . 1))) - (dolist (file todo-files) - (unless (assoc file new-files) - (push (cons file file-rule) new-files)))) - (permission-denied nil))) + (let ((file-rule '(:maxlevel . 1))) + (dolist (dir (list user-emacs-directory code-dir projects-dir)) + (dolist (file (cj/--org-refile-scan-dir dir)) + (unless (assoc file new-files) + (push (cons file file-rule) new-files))))) (nreverse new-files))) (defun cj/build-org-refile-targets (&optional force-rebuild) diff --git a/tests/test-org-agenda-config-add-files.el b/tests/test-org-agenda-config-add-files.el index e977cc68..8c6f2a69 100644 --- a/tests/test-org-agenda-config-add-files.el +++ b/tests/test-org-agenda-config-add-files.el @@ -7,6 +7,7 @@ ;;; Code: (require 'ert) +(require 'cl-lib) (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) (require 'testutil-general) @@ -129,5 +130,16 @@ (should (member "/existing/file.org" org-agenda-files)))) (cj/delete-test-base-dir))) +(ert-deftest test-org-agenda-config-add-files-boundary-missing-dir-warns-not-errors () + "Boundary: a missing project directory warns and adds nothing, without erroring. +Previously `directory-files' on a missing dir crashed the agenda build." + (let ((org-agenda-files '("/existing/file.org")) + (warned nil)) + (cl-letf (((symbol-function 'display-warning) + (lambda (&rest _) (setq warned t)))) + (cj/add-files-to-org-agenda-files-list "/no/such/cj-agenda/dir/")) + (should warned) + (should (equal org-agenda-files '("/existing/file.org"))))) + (provide 'test-org-agenda-config-add-files) ;;; test-org-agenda-config-add-files.el ends here diff --git a/tests/test-org-refile-config--scan-dir.el b/tests/test-org-refile-config--scan-dir.el new file mode 100644 index 00000000..184fa98e --- /dev/null +++ b/tests/test-org-refile-config--scan-dir.el @@ -0,0 +1,57 @@ +;;; test-org-refile-config--scan-dir.el --- Tests for refile dir scan -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/--org-refile-scan-dir, which lists the todo.org files +;; under one root for the refile-target scan. A missing, unreadable, or +;; permission-denied root must be non-fatal: it logs a warning and returns +;; nil so the rest of the scan continues, instead of silently swallowing the +;; failure or crashing on a missing directory. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'org-refile-config) + +;;; Normal Cases + +(ert-deftest test-org-refile-scan-dir-normal-finds-todo () + "Normal: a readable directory with a todo.org returns it." + (let ((dir (make-temp-file "cj-refile-scan-" t))) + (unwind-protect + (progn + (write-region "* TODO x\n" nil (expand-file-name "todo.org" dir)) + (let ((found (cj/--org-refile-scan-dir dir))) + (should (= 1 (length found))) + (should (string-suffix-p "todo.org" (car found))))) + (delete-directory dir t)))) + +;;; Boundary Cases + +(ert-deftest test-org-refile-scan-dir-boundary-missing-warns-and-returns-nil () + "Boundary: a missing directory warns and returns nil, without erroring." + (let ((warned nil)) + (cl-letf (((symbol-function 'display-warning) + (lambda (&rest _) (setq warned t)))) + (should (null (cj/--org-refile-scan-dir "/no/such/cj-refile/dir/"))) + (should warned)))) + +;;; Error Cases + +(ert-deftest test-org-refile-scan-dir-error-permission-denied-warns-and-returns-nil () + "Error: a permission-denied during the scan warns and returns nil." + (let ((dir (make-temp-file "cj-refile-perm-" t)) + (warned nil)) + (unwind-protect + (cl-letf (((symbol-function 'directory-files-recursively) + (lambda (&rest _) (signal 'permission-denied (list dir)))) + ((symbol-function 'display-warning) + (lambda (&rest _) (setq warned t)))) + (should (null (cj/--org-refile-scan-dir dir))) + (should warned)) + (delete-directory dir t)))) + +(provide 'test-org-refile-config--scan-dir) +;;; test-org-refile-config--scan-dir.el ends here |
