aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/org-agenda-config.el44
-rw-r--r--tests/test-org-agenda-config-category.el137
2 files changed, 181 insertions, 0 deletions
diff --git a/modules/org-agenda-config.el b/modules/org-agenda-config.el
index 70c7fb6d..2c30db0f 100644
--- a/modules/org-agenda-config.el
+++ b/modules/org-agenda-config.el
@@ -90,6 +90,50 @@
(local-set-key (kbd "s-<left>")
#'org-agenda-todo-previousset))))
+;; ----------------------- Project-name Category Override ---------------------
+;; The default `org-category' for a todo.org buffer is "todo" (the filename
+;; without extension), which renders as "todo:" in every agenda `%c' column
+;; and tells the reader nothing useful when every project has its own
+;; todo.org. Substitute the parent-directory basename instead, so
+;; `~/.emacs.d/todo.org' shows "emacs.d:" and `~/projects/foo/todo.org' shows
+;; "foo:". Files that aren't named todo.org are left alone, and a user-set
+;; `#+CATEGORY:' (which leaves `org-category' at a non-default value) wins.
+
+(defun cj/--org-todo-category-from-file (path)
+ "Return the project category for a todo.org PATH, or nil if not applicable.
+For a file named todo.org, returns the basename of its parent
+directory with a single leading dot stripped (so `~/.emacs.d/todo.org'
+yields \"emacs.d\", not \".emacs.d\"). For any other file -- or for a
+PATH that is nil, empty, or has no usable parent directory -- returns
+nil so the org default category applies."
+ (when (and (stringp path)
+ (not (string-empty-p path))
+ (string= "todo.org" (file-name-nondirectory path)))
+ (let* ((dir (file-name-directory path))
+ (parent (and dir
+ (file-name-nondirectory
+ (directory-file-name dir))))
+ (clean (and parent
+ (if (and (> (length parent) 1)
+ (eq ?. (aref parent 0)))
+ (substring parent 1)
+ parent))))
+ (and clean (not (string-empty-p clean)) clean))))
+
+(defun cj/--org-set-todo-category ()
+ "Set buffer-local `org-category' to the project name for a todo.org buffer.
+Runs from `org-mode-hook'. Only overrides when `org-category' is still
+the default-from-filename (\"todo\"), so an explicit `#+CATEGORY:' in
+the file keeps precedence."
+ (when (and buffer-file-name
+ (boundp 'org-category)
+ (stringp org-category)
+ (string= "todo" org-category))
+ (when-let* ((project (cj/--org-todo-category-from-file buffer-file-name)))
+ (setq-local org-category project))))
+
+(add-hook 'org-mode-hook #'cj/--org-set-todo-category)
+
;; ------------------------ Org Agenda File List Cache -------------------------
;; Cache agenda file list to avoid expensive directory scanning on every view.
;; The TTL+building cache lifecycle is provided by `cj-cache.el'.
diff --git a/tests/test-org-agenda-config-category.el b/tests/test-org-agenda-config-category.el
new file mode 100644
index 00000000..6a54d9e6
--- /dev/null
+++ b/tests/test-org-agenda-config-category.el
@@ -0,0 +1,137 @@
+;;; test-org-agenda-config-category.el --- Tests for project-name category derivation -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the agenda-display category helpers in org-agenda-config.el:
+;; - cj/--org-todo-category-from-file (pure)
+;; - cj/--org-set-todo-category (org-mode-hook side effect)
+;;
+;; Goal: when a buffer visits a project-local todo.org, its `org-category`
+;; should be the parent directory name (the project slug) rather than the
+;; default "todo" -- so the agenda's %c column shows "emacs.d:" instead of
+;; "todo:" for every project's tasks.
+
+;;; Code:
+
+(require 'ert)
+(require 'org)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-agenda-config)
+
+;;; ---------- cj/--org-todo-category-from-file (pure helper) ----------
+
+;;; Normal Cases
+
+(ert-deftest test-org-agenda-config-category-normal-emacs-d-todo ()
+ "Normal: todo.org under .emacs.d returns \"emacs.d\"."
+ (should (equal "emacs.d"
+ (cj/--org-todo-category-from-file
+ "/home/cjennings/.emacs.d/todo.org"))))
+
+(ert-deftest test-org-agenda-config-category-normal-project-todo ()
+ "Normal: todo.org under a project dir returns the project basename."
+ (should (equal "dotemacs"
+ (cj/--org-todo-category-from-file
+ "/home/cjennings/code/dotemacs/todo.org"))))
+
+(ert-deftest test-org-agenda-config-category-normal-deep-project ()
+ "Normal: deeply nested todo.org returns only the immediate parent."
+ (should (equal "frontend"
+ (cj/--org-todo-category-from-file
+ "/home/cjennings/projects/work/myapp/frontend/todo.org"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-org-agenda-config-category-boundary-non-todo-file ()
+ "Boundary: non-todo.org file returns nil so default category stays."
+ (should (null (cj/--org-todo-category-from-file
+ "/home/cjennings/sync/org/roam/inbox.org"))))
+
+(ert-deftest test-org-agenda-config-category-boundary-schedule-org ()
+ "Boundary: schedule.org returns nil; not a project todo file."
+ (should (null (cj/--org-todo-category-from-file
+ "/home/cjennings/sync/org/schedule.org"))))
+
+(ert-deftest test-org-agenda-config-category-boundary-todo-at-fs-root ()
+ "Boundary: /todo.org with no real parent directory returns nil."
+ (should (null (cj/--org-todo-category-from-file "/todo.org"))))
+
+(ert-deftest test-org-agenda-config-category-boundary-relative-path ()
+ "Boundary: bare relative \"todo.org\" with no directory returns nil."
+ (should (null (cj/--org-todo-category-from-file "todo.org"))))
+
+(ert-deftest test-org-agenda-config-category-boundary-todo-with-trailing-dir ()
+ "Boundary: tolerate a path that is already directory-form."
+ (should (equal "emacs.d"
+ (cj/--org-todo-category-from-file
+ "/home/cjennings/.emacs.d/todo.org"))))
+
+;;; Error Cases
+
+(ert-deftest test-org-agenda-config-category-error-nil-path ()
+ "Error: nil PATH returns nil, no signal."
+ (should (null (cj/--org-todo-category-from-file nil))))
+
+(ert-deftest test-org-agenda-config-category-error-empty-path ()
+ "Error: empty-string PATH returns nil."
+ (should (null (cj/--org-todo-category-from-file ""))))
+
+;;; ---------- cj/--org-set-todo-category (hook function) ----------
+
+(defmacro test-org-agenda-config-category--with-file (path body-form)
+ "Visit PATH in a temp buffer with org-mode active, evaluate BODY-FORM.
+Sets `buffer-file-name' so the hook's lookup sees the desired path.
+Suppresses other org-mode hooks to keep the test isolated."
+ (declare (indent 1))
+ `(with-temp-buffer
+ (let ((org-mode-hook nil)
+ (text-mode-hook nil))
+ (org-mode))
+ (setq buffer-file-name ,path)
+ ;; mimic org's default category (filename-sans-extension) so the
+ ;; hook's "only override the default" guard is exercised.
+ (setq-local org-category
+ (and ,path
+ (file-name-sans-extension
+ (file-name-nondirectory ,path))))
+ ,body-form))
+
+;;; Normal Cases
+
+(ert-deftest test-org-agenda-config-category-hook-normal-overrides-todo ()
+ "Normal: hook overrides default \"todo\" with the parent dir name."
+ (test-org-agenda-config-category--with-file "/home/cjennings/.emacs.d/todo.org"
+ (progn
+ (cj/--org-set-todo-category)
+ (should (equal "emacs.d" org-category)))))
+
+(ert-deftest test-org-agenda-config-category-hook-normal-leaves-inbox-alone ()
+ "Normal: hook leaves inbox.org's category at its filename default."
+ (test-org-agenda-config-category--with-file "/home/cjennings/sync/org/roam/inbox.org"
+ (progn
+ (cj/--org-set-todo-category)
+ (should (equal "inbox" org-category)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-org-agenda-config-category-hook-boundary-respects-explicit ()
+ "Boundary: explicit category (not the filename default) is preserved."
+ (test-org-agenda-config-category--with-file "/home/cjennings/.emacs.d/todo.org"
+ (progn
+ (setq-local org-category "Personal")
+ (cj/--org-set-todo-category)
+ (should (equal "Personal" org-category)))))
+
+(ert-deftest test-org-agenda-config-category-hook-boundary-nil-buffer-file-name ()
+ "Boundary: hook is safe in buffers with no `buffer-file-name'."
+ (with-temp-buffer
+ (let ((org-mode-hook nil)
+ (text-mode-hook nil))
+ (org-mode))
+ (setq buffer-file-name nil)
+ (cj/--org-set-todo-category)
+ ;; no error and no spurious mutation
+ (should t)))
+
+(provide 'test-org-agenda-config-category)
+;;; test-org-agenda-config-category.el ends here