diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29963da --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.direnv/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9f0c7fc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,207 @@ +# AGENTS.md — org-roam-project + +Guidance for AI coding agents working in this repository. + +## Project overview + +`org-roam-project` is an Emacs Lisp package that extends +[org-roam](https://www.orgroam.com/) with per-project databases using +Emacs' built-in `project.el`. Each project gets its own isolated notes +directory and SQLite database. The global zettelkasten is never touched. + +**Language:** Emacs Lisp +**Dependencies:** Emacs >= 28.1, org-roam >= 2.2.0 +**License:** GPL-3.0-or-later + +### Repository layout + +``` +org-roam-project.el Main package source (~355 lines) +org-roam-project-test.el ERT test suite (~430 lines) +Makefile Build and test runner +flake.nix / shell.nix Nix dev environment +``` + +## Build commands + +```sh +make all # Byte-compile + run tests +make compile # Byte-compile org-roam-project.el +make test # Run the full ERT test suite in batch mode +make clean # Remove .elc files +``` + +### Running a single test + +```sh +emacs --batch \ + -l ert \ + -l org-roam \ + --eval "(add-to-list 'load-path \".\")" \ + -l org-roam-project \ + -l org-roam-project-test \ + --eval "(ert-run-tests-batch-and-exit '\"orp-test-NAME\")" +``` + +Replace `orp-test-NAME` with the test name (e.g. `orp-test-default-notes-subdir`). +The string is an ERT selector regexp matching test names. + +### Dev environment + +The project uses Nix flakes. Run `nix develop` (or let `direnv` activate +via `.envrc`) to get an Emacs with org-roam and all dev tools on `$PATH`. + +## Code style guidelines + +### Lexical binding + +Every `.el` file MUST have `lexical-binding: t` in the first line: + +```elisp +;;; file.el --- Description -*- lexical-binding: t; -*- +``` + +### File structure + +Follow this order in every source file: + +1. First line with description and `lexical-binding: t` +2. Copyright / Author / Version / Package-Requires / Keywords / URL +3. License boilerplate (GPL-3.0-or-later) +4. `;;; Commentary:` section +5. `;;; Code:` marker +6. `(require ...)` forms +7. `;;; Section` headers (three semicolons) grouping related code +8. `(provide 'feature)` at the end +9. `;;; file.el ends here` footer + +### Namespace prefix + +All symbols use the `org-roam-project` prefix: + +- **Public:** `org-roam-project-` (single dash) +- **Private:** `org-roam-project--` (double dash) +- **Test public:** `orp-test-` +- **Test private:** `orp-test--` + +Never introduce unprefixed top-level symbols. + +### Requires / imports + +- Place all `(require ...)` forms at the top of `;;; Code:`. +- Only hard runtime dependencies. No `eval-when-compile` unless truly + needed for macros. +- **Do not use `cl-lib` in production code.** Use only built-in Emacs + Lisp primitives (`let`, `let*`, `when`, `unless`, `cond`, `cons`, + `car`, `cdr`, `alist-get`, `dolist`, etc.). `cl-lib` is acceptable + in the test file only. + +### Formatting + +- **Line length:** ~80 columns. String literals may slightly exceed this. +- **Indentation:** Standard Emacs Lisp indentation (2-space aligned + `let` bindings, standard `defun`/`defmacro`/`cond` indentation). +- **Parentheses:** Closing parens always on the same line as the last + form — never on their own line. +- **Blank lines:** One blank line between top-level forms. Section + headers (`;;;`) get blank lines before and after. +- **Function references:** Always use sharp-quote: `#'function-name`. +- **`let` vs `let*`:** Use `let*` only when bindings depend on earlier + bindings in the same form. Use plain `let` otherwise. + +### Docstrings + +Every public and private function, macro, and variable must have a +docstring. + +- First line: complete imperative sentence ("Return the context…"). +- Arguments referenced in UPPER CASE: `"DIR defaults to…"`. +- Cross-reference other symbols with backtick-quote: `` `org-roam-project-init' ``. +- Document return values and nil-return conditions explicitly. + +### Inline comments + +- Use `;;` (two semicolons) on a line by itself above the code. +- Section headers use `;;;` (three semicolons). +- Avoid end-of-line comments in production code (acceptable in tests + for brief annotations). + +### Defcustom conventions + +```elisp +(defcustom org-roam-project-XXXX default-value + "Docstring." + :type 'TYPE + :group 'org-roam-project) +``` + +- Always specify `:type` and `:group`. +- If the variable is usable in `.dir-locals.el`, add a + `safe-local-variable` declaration with an `;;;###autoload` cookie: + +```elisp +;;;###autoload +(put 'org-roam-project-XXXX 'safe-local-variable #'stringp) +``` + +### Autoloads + +Place `;;;###autoload` on: +- All interactive commands +- The minor mode definition (`define-minor-mode`) +- `safe-local-variable` `put` forms + +Never autoload internal (`--`) functions. + +### Error handling + +Two-tier pattern: + +1. **Internal functions** return `nil` on failure — never signal errors. + Let callers decide behavior. +2. **Interactive commands** call `org-roam-project--require-context` + which signals `user-error` (not `error`) with actionable messages. + +Always use `user-error` for user-facing errors, never bare `error`. + +### Macros + +- Always declare `(declare (indent 0) (debug t))` as the first form. +- Accept `&rest body`, splice with `,@body`. +- Use `unwind-protect` for cleanup in test macros. + +### Dynamic scoping pattern + +The core architectural pattern: temporarily rebind `org-roam-directory` +and `org-roam-db-location` via `let` — never mutate them with `setq`. + +```elisp +(let ((org-roam-directory (car ctx)) + (org-roam-db-location (cdr ctx))) + (org-roam-node-find)) +``` + +## Testing conventions + +- **Framework:** ERT (Emacs Regression Testing), built-in. +- **Test file:** `org-roam-project-test.el` +- **Test names:** `orp-test-` (no double dash). +- **Helpers/macros:** `orp-test--` (double dash). +- Tests create real temporary Git repos via `git init` so `project.el` + recognizes them. Always clean up with `orp-test--cleanup`. +- Use `orp-test--with-project` or `orp-test--with-initialized-project` + macros to set up and tear down test fixtures. +- Test `user-error` conditions with `(should-error ... :type 'user-error)`. +- Tests use real org-roam (not mocked) to catch compatibility issues. +- `cl-lib` functions (`cl-remove-if`, `cl-find`, `cl-count`, etc.) are + allowed in tests. + +## Section headers in test file + +Tests are numbered in groups with decorative Unicode headers: + +```elisp +;;; ─── 1. Customisation defaults ─────────────── +``` + +Maintain this grouping style when adding new test sections. diff --git a/org-roam-project-test.el b/org-roam-project-test.el index 9c15be0..849d32b 100644 --- a/org-roam-project-test.el +++ b/org-roam-project-test.el @@ -187,6 +187,20 @@ real org-roam database is opened globally." (should (consp ctx)) (should (string-suffix-p "my-custom.db" (cdr ctx))))))) +(ert-deftest orp-test-context-via-directory-override () + "Context is detected via `project-current-directory-override' (Emacs 29+). +Simulates the environment created by `project-switch-project': the +override is set to the target project while `default-directory' still +points elsewhere (e.g. the previously active project)." + (skip-unless (boundp 'project-current-directory-override)) + (orp-test--with-initialized-project + (let ((project-current-directory-override test-root) + (default-directory "/")) ; deliberately wrong — not the project + (let ((ctx (org-roam-project--context))) + (should (consp ctx)) + (should (string= (file-name-as-directory (car ctx)) + (file-name-as-directory test-notes-dir))))))) + ;;; ─── 5. org-roam-project--with-context (macro) ────────────────────────────── (ert-deftest orp-test-with-context-binds-org-roam-vars () @@ -246,6 +260,19 @@ real org-roam database is opened globally." (expand-file-name org-roam-project-db-filename test-notes-dir)))))) +(ert-deftest orp-test-require-context-via-directory-override () + "require-context succeeds via `project-current-directory-override' (Emacs 29+). +Simulates `project-switch-project' setting the override while +`default-directory' still points to the previous project." + (skip-unless (boundp 'project-current-directory-override)) + (orp-test--with-initialized-project + (let ((project-current-directory-override test-root) + (default-directory "/")) ; deliberately wrong — not the project + (let ((ctx (org-roam-project--require-context))) + (should (consp ctx)) + (should (string= (file-name-as-directory (car ctx)) + (file-name-as-directory test-notes-dir))))))) + ;;; ─── 7. org-roam-project--add-to-gitignore ────────────────────────────────── (ert-deftest orp-test-gitignore-no-file () diff --git a/org-roam-project.el b/org-roam-project.el index acd8ad8..d43cddf 100644 --- a/org-roam-project.el +++ b/org-roam-project.el @@ -105,7 +105,11 @@ Falls back to the global value of VAR if no dir-local value is set." (defun org-roam-project--context (&optional dir) "Return the org-roam context for the project containing DIR. -DIR defaults to `default-directory'. +When DIR is nil, the project is detected via `project-current', which +respects `project-current-directory-override' (set by +`project-switch-project' on Emacs 29+) before falling back to +`default-directory'. Pass an explicit DIR only when the caller already +has a concrete filesystem path (e.g. the autosync advice). Returns a cons cell (NOTES-DIR . DB-PATH) where NOTES-DIR is the absolute path to the project's org-roam notes directory and DB-PATH @@ -115,8 +119,9 @@ Returns nil if: - DIR is not inside a project.el project. - The notes directory does not exist and `org-roam-project-auto-create' is nil." - (let* ((dir (expand-file-name (or dir default-directory))) - (project (project-current nil dir))) + (let* ((project (if dir + (project-current nil (expand-file-name dir)) + (project-current nil)))) (when project (let* ((root (project-root project)) (notes-subdir (org-roam-project--value-in-dir @@ -150,10 +155,12 @@ Otherwise BODY executes with the global org-roam settings." (defun org-roam-project--require-context () "Return the project context or signal a helpful error. +Respects `project-current-directory-override' (set by +`project-switch-project' on Emacs 29+) before falling back to +`default-directory'. Signals `user-error' if there is no current project or the project's notes directory has not been initialized." - (let* ((dir default-directory) - (project (project-current nil dir))) + (let* ((project (project-current nil))) (unless project (user-error "Not inside a project.el project")) (let* ((root (project-root project))