diff options
| -rw-r--r-- | init.el | 1 | ||||
| -rw-r--r-- | modules/org-spec-links.el | 80 | ||||
| -rw-r--r-- | tests/test-init-module-headers.el | 1 | ||||
| -rw-r--r-- | tests/test-org-spec-links.el | 79 |
4 files changed, 161 insertions, 0 deletions
@@ -140,6 +140,7 @@ (require 'org-refile-config) ;; refile org-branches (require 'org-roam-config) ;; personal knowledge management in org mode +(require 'org-spec-links) ;; resolve [[id:]] links into project docs/specs (require 'org-webclipper) ;; "instapaper" to org-roam workflow (require 'org-noter-config) diff --git a/modules/org-spec-links.el b/modules/org-spec-links.el new file mode 100644 index 00000000..369a4c23 --- /dev/null +++ b/modules/org-spec-links.el @@ -0,0 +1,80 @@ +;;; org-spec-links.el --- Resolve org-id links into project spec docs -*- lexical-binding: t -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; Layer: 3 (Domain Workflow). +;; Category: D/S. +;; Load shape: eager (cheap: defuns + one after-load registration). +;; Eager reason: none beyond the after-load hook; the scan itself runs +;; only when org-id loads. +;; Top-level side effects: one with-eval-after-load (org-id). +;; Runtime requires: none (org-id is configured after it loads). +;; Direct test load: yes. +;; +;; The docs-lifecycle convention (rulesets, 2026-07-01) gives every +;; formal spec under a project's docs/specs/ an :ID: UUID, and +;; cross-project links use [[id:...]]. org-id-locations only indexes +;; agenda files and files Emacs has visited, so a fresh spec's ID won't +;; resolve on click. This module enumerates every project's +;; docs/specs/*.org into `org-id-extra-files' (a literal file list -- +;; org-id doesn't glob) so org-id's own scans can find them, and +;; provides `cj/org-id-refresh-spec-locations' to re-scan after new +;; specs land and teach `org-id-locations' immediately. +;; +;; Projects are directories carrying .ai/protocols.org under ~/code/, +;; ~/projects/, and ~/.emacs.d -- the same discovery the `ai' launcher +;; and inbox-send use. + +;;; Code: + +(defun cj/--org-spec-project-base-dirs () + "Return the base directories scanned for projects." + (list (expand-file-name "code/" "~") + (expand-file-name "projects/" "~"))) + +(defun cj/--org-spec-project-roots (&optional base-dirs) + "Return project roots: dirs carrying .ai/protocols.org. +Scans one level under each of BASE-DIRS (default +`cj/--org-spec-project-base-dirs'), plus `user-emacs-directory'." + (let ((candidates + (append + (mapcan (lambda (base) + (when (file-directory-p base) + (directory-files base t "\\`[^.]" t))) + (or base-dirs (cj/--org-spec-project-base-dirs))) + (unless base-dirs (list user-emacs-directory))))) + (seq-filter (lambda (dir) + (and (file-directory-p dir) + (file-exists-p (expand-file-name ".ai/protocols.org" dir)))) + candidates))) + +(defun cj/--org-spec-files (&optional base-dirs) + "Return every project's docs/specs/*.org as absolute file names. +BASE-DIRS overrides the default project discovery (for tests)." + (mapcan (lambda (root) + (let ((specs (expand-file-name "docs/specs/" root))) + (when (file-directory-p specs) + (directory-files specs t "\\.org\\'" t)))) + (cj/--org-spec-project-roots base-dirs))) + +(defun cj/org-id-refresh-spec-locations () + "Point `org-id-extra-files' at every project's spec docs and rescan. +Run after new specs land (a spec-sort pass, a new project) so their +:ID:s resolve. The rescan also walks agenda files, so it can take a +few seconds; the `org-id-extra-files' assignment alone is instant." + (interactive) + (let ((files (cj/--org-spec-files))) + (setq org-id-extra-files files) + (when (and files (fboundp 'org-id-update-id-locations)) + (org-id-update-id-locations files)) + (message "org-id: %d spec file(s) registered" (length files)))) + +;; Teach org-id the spec files as soon as it loads: the extra-files list +;; is enough for org-id's own fallback scans; the full refresh command +;; is for immediacy after new specs land. +(with-eval-after-load 'org-id + (setq org-id-extra-files (cj/--org-spec-files))) + +(provide 'org-spec-links) +;;; org-spec-links.el ends here diff --git a/tests/test-init-module-headers.el b/tests/test-init-module-headers.el index fc956084..f89d6624 100644 --- a/tests/test-init-module-headers.el +++ b/tests/test-init-module-headers.el @@ -93,6 +93,7 @@ "org-refile-config" "org-reveal-config" "org-roam-config" + "org-spec-links" "org-webclipper" "hugo-config" ;; Batch 8 — Domain / integration / optional modules (Layer 2-4) diff --git a/tests/test-org-spec-links.el b/tests/test-org-spec-links.el new file mode 100644 index 00000000..6359689f --- /dev/null +++ b/tests/test-org-spec-links.el @@ -0,0 +1,79 @@ +;;; test-org-spec-links.el --- docs/specs org-id resolution -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the docs/specs spec-file scanner behind org-id link +;; resolution (org-spec-links.el). Projects are directories carrying +;; .ai/protocols.org; each contributes its docs/specs/*.org files. The +;; scanner takes explicit base dirs so the tests run against a sandbox. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +(require 'org-spec-links) + +;; org-id may never load in batch; declare its var special so the +;; let-binding below is dynamic, not a silent lexical shadow. +(defvar org-id-extra-files) + +(defun test-org-spec-links--make-sandbox () + "Build a sandbox: two projects, one with specs, one without; one non-project." + (let ((base (make-temp-file "spec-links-" t))) + ;; p1: a project with two spec files + (make-directory (expand-file-name "p1/.ai" base) t) + (write-region "" nil (expand-file-name "p1/.ai/protocols.org" base)) + (make-directory (expand-file-name "p1/docs/specs" base) t) + (write-region "" nil (expand-file-name "p1/docs/specs/a-spec.org" base)) + (write-region "" nil (expand-file-name "p1/docs/specs/b-spec.org" base)) + ;; p2: a project with no docs/specs + (make-directory (expand-file-name "p2/.ai" base) t) + (write-region "" nil (expand-file-name "p2/.ai/protocols.org" base)) + ;; p3: not a project (no protocols.org) but has docs/specs + (make-directory (expand-file-name "p3/docs/specs" base) t) + (write-region "" nil (expand-file-name "p3/docs/specs/c-spec.org" base)) + base)) + +(ert-deftest test-org-spec-links-scanner-finds-project-specs () + "Normal: spec files from protocols-carrying projects only, absolute paths." + (let ((base (test-org-spec-links--make-sandbox))) + (unwind-protect + (let ((files (cj/--org-spec-files (list base)))) + (should (= (length files) 2)) + (should (cl-every #'file-name-absolute-p files)) + (should (seq-find (lambda (f) (string-suffix-p "p1/docs/specs/a-spec.org" f)) files)) + (should (seq-find (lambda (f) (string-suffix-p "p1/docs/specs/b-spec.org" f)) files)) + (should-not (seq-find (lambda (f) (string-match-p "p3" f)) files))) + (delete-directory base t)))) + +(ert-deftest test-org-spec-links-scanner-empty-base () + "Boundary: a base dir with no projects yields nil." + (let ((base (make-temp-file "spec-links-empty-" t))) + (unwind-protect + (should-not (cj/--org-spec-files (list base))) + (delete-directory base t)))) + +(ert-deftest test-org-spec-links-scanner-missing-base () + "Error: a non-existent base dir is skipped without signaling." + (should-not (cj/--org-spec-files '("/nonexistent/base/dir")))) + +(ert-deftest test-org-spec-links-refresh-sets-extra-files () + "Normal: the refresh command points org-id-extra-files at the scan result." + (let* ((base (test-org-spec-links--make-sandbox)) + (org-id-extra-files nil) + ;; the default scan appends user-emacs-directory; point it into + ;; the sandbox so the real config's specs don't leak in + (user-emacs-directory (expand-file-name "p2/" base))) + (unwind-protect + (cl-letf (((symbol-function 'cj/--org-spec-project-base-dirs) + (lambda () (list base))) + ;; org-id may be absent in batch; the update step is mocked. + ((symbol-function 'org-id-update-id-locations) + (lambda (&rest _) nil))) + (cj/org-id-refresh-spec-locations) + (should (= (length org-id-extra-files) 2))) + (delete-directory base t)))) + +(provide 'test-org-spec-links) +;;; test-org-spec-links.el ends here |
