summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-10-26 22:42:56 -0500
committerCraig Jennings <c@cjennings.net>2025-10-26 22:42:56 -0500
commit671539db07570ceba1e078c944e49bbbe8d69702 (patch)
tree94f2a3b5792f40ab957ccf452ccc12af1e7b1f60
parentd76d1aac7a77434c4a7ecdf5af855572c223b22e (diff)
feat:org-roam-config: Add new utilities and tests
Introduce several utility functions for org-roam configuration, including `cj/--generate-roam-slug`, `cj/--demote-org-subtree`, and `cj/--format-roam-node`. These enhance title-to-slug conversion, subtree demotion, and node file formatting. Additionally, include comprehensive test cases for these functions to ensure correctness and reliability in various scenarios.
-rw-r--r--modules/org-roam-config.el88
-rw-r--r--tests/test-org-roam-config-demote.el183
-rw-r--r--tests/test-org-roam-config-format.el151
-rw-r--r--tests/test-org-roam-config-link-description.el188
-rw-r--r--tests/test-org-roam-config-slug.el223
5 files changed, 802 insertions, 31 deletions
diff --git a/modules/org-roam-config.el b/modules/org-roam-config.el
index 18552b1d..07098743 100644
--- a/modules/org-roam-config.el
+++ b/modules/org-roam-config.el
@@ -1,7 +1,15 @@
;;; org-roam-config.el --- Org-Roam Config -*- lexical-binding: t; coding: utf-8; -*-
;; author: Craig Jennings <c@cjennings.net>
;;; Commentary:
-;; Currently a work in progress. The initial version of this was taken from David Wilson:
+;; Configuration and utilities for org-roam knowledge management.
+;;
+;; Key features:
+;; - Custom capture templates for different node types (v2mom, recipe, topic)
+;; - Automatic moving of completed tasks to daily journal
+;; - Tag-based node filtering and finding
+;; - Branch extraction to new roam nodes (cj/move-org-branch-to-roam)
+;;
+;; The initial version was adapted from David Wilson:
;; https://systemcrafters.net/build-a-second-brain-in-emacs/5-org-roam-hacks/
;;; Code:
@@ -190,6 +198,51 @@ Otherwise return TEXT unchanged."
(or description url))
text))
+(defun cj/--generate-roam-slug (title)
+ "Convert TITLE to a filename-safe slug.
+Converts to lowercase, replaces non-alphanumeric characters with hyphens,
+and removes leading/trailing hyphens.
+Returns the slugified string."
+ (let ((slug (replace-regexp-in-string
+ "[^a-zA-Z0-9]+" "-"
+ (downcase title))))
+ (replace-regexp-in-string "^-\\|-$" "" slug)))
+
+(defun cj/--demote-org-subtree (content from-level to-level)
+ "Demote org subtree CONTENT from FROM-LEVEL to TO-LEVEL.
+CONTENT is the org-mode text with headings.
+FROM-LEVEL is the current level of the top heading (integer).
+TO-LEVEL is the desired level for the top heading (integer).
+Returns the demoted content as a string.
+All headings in the tree are adjusted proportionally."
+ (if (<= from-level to-level)
+ ;; No demotion needed
+ content
+ (let ((demote-count (- from-level to-level)))
+ (with-temp-buffer
+ (insert content)
+ (goto-char (point-min))
+ (while (re-search-forward "^\\(\\*+\\) " nil t)
+ (let* ((stars (match-string 1))
+ (level (length stars))
+ (new-level (max 1 (- level demote-count)))
+ (new-stars (make-string new-level ?*)))
+ (replace-match (concat new-stars " "))))
+ (buffer-string)))))
+
+(defun cj/--format-roam-node (title node-id content)
+ "Format org-roam node file CONTENT with TITLE and NODE-ID.
+TITLE is the node title string.
+NODE-ID is the unique identifier for the node.
+CONTENT is the main body content (already demoted if needed).
+Returns the complete file content as a string."
+ (concat ":PROPERTIES:\n"
+ ":ID: " node-id "\n"
+ ":END:\n"
+ "#+TITLE: " title "\n"
+ "#+CATEGORY: " title "\n"
+ "#+FILETAGS: Topic\n\n"
+ content))
(defun cj/move-org-branch-to-roam ()
"Move the org subtree at point to a new org-roam node.
@@ -213,12 +266,7 @@ title."
(title (cj/org-link-get-description raw-title))
(timestamp (format-time-string "%Y%m%d%H%M%S"))
;; Convert title to filename-safe format
- (title-slug (replace-regexp-in-string
- "[^a-zA-Z0-9]+" "-"
- (downcase title)))
- ;; Remove leading/trailing hyphens
- (title-slug (replace-regexp-in-string
- "^-\\|-$" "" title-slug))
+ (title-slug (cj/--generate-roam-slug title))
(filename (format "%s-%s.org" timestamp title-slug))
(filepath (expand-file-name filename org-roam-directory))
;; Generate a unique ID for the node
@@ -234,33 +282,11 @@ title."
(org-cut-subtree)
;; Process the subtree to demote it to level 1
- (with-temp-buffer
- (org-mode)
- (insert subtree-content)
- ;; Demote the entire tree so the top level becomes level 1
- (goto-char (point-min))
- (when (> current-level 1)
- (let ((demote-count (- current-level 1)))
- (while (re-search-forward "^\\*+ " nil t)
- (beginning-of-line)
- (dotimes (_ demote-count)
- (when (looking-at "^\\*\\*")
- (delete-char 1)))
- (forward-line))))
- (setq subtree-content (buffer-string)))
+ (setq subtree-content (cj/--demote-org-subtree subtree-content current-level 1))
;; Create the new org-roam file
(with-temp-file filepath
- ;; Insert the org-roam template with ID at file level
- (insert ":PROPERTIES:\n")
- (insert ":ID: " node-id "\n")
- (insert ":END:\n")
- (insert "#+TITLE: " title "\n")
- (insert "#+CATEGORY: " title "\n")
- (insert "#+FILETAGS: Topic\n\n")
-
- ;; Insert the demoted subtree content
- (insert subtree-content))
+ (insert (cj/--format-roam-node title node-id subtree-content)))
;; Sync the org-roam database
(org-roam-db-sync)
diff --git a/tests/test-org-roam-config-demote.el b/tests/test-org-roam-config-demote.el
new file mode 100644
index 00000000..98cc8244
--- /dev/null
+++ b/tests/test-org-roam-config-demote.el
@@ -0,0 +1,183 @@
+;;; test-org-roam-config-demote.el --- Tests for cj/--demote-org-subtree -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--demote-org-subtree function from org-roam-config.el
+;;
+;; This function demotes org subtree content from one level to another.
+;; All headings in the tree are adjusted proportionally, with a minimum level of 1.
+;;
+;; Examples:
+;; Input: "*** Heading\n**** Sub", from: 3, to: 1
+;; Output: "* Heading\n** Sub"
+;;
+;; Input: "** Heading\n*** Sub", from: 2, to: 1
+;; Output: "* Heading\n** Sub"
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Now load the actual production module
+(require 'org-roam-config)
+
+;;; Test Helpers
+
+(defun test-demote (content from-level to-level)
+ "Test cj/--demote-org-subtree on CONTENT.
+FROM-LEVEL is the current top level, TO-LEVEL is the desired top level.
+Returns the demoted content."
+ (cj/--demote-org-subtree content from-level to-level))
+
+;;; Normal Cases - Single Heading
+
+(ert-deftest test-demote-level2-to-level1 ()
+ "Should demote level 2 heading to level 1."
+ (let ((result (test-demote "** Heading\n" 2 1)))
+ (should (string= result "* Heading\n"))))
+
+(ert-deftest test-demote-level3-to-level1 ()
+ "Should demote level 3 heading to level 1."
+ (let ((result (test-demote "*** Heading\n" 3 1)))
+ (should (string= result "* Heading\n"))))
+
+(ert-deftest test-demote-level4-to-level1 ()
+ "Should demote level 4 heading to level 1."
+ (let ((result (test-demote "**** Heading\n" 4 1)))
+ (should (string= result "* Heading\n"))))
+
+(ert-deftest test-demote-level3-to-level2 ()
+ "Should demote level 3 heading to level 2."
+ (let ((result (test-demote "*** Heading\n" 3 2)))
+ (should (string= result "** Heading\n"))))
+
+;;; Normal Cases - Multiple Headings at Same Level
+
+(ert-deftest test-demote-multiple-same-level ()
+ "Should demote multiple headings at same level."
+ (let ((result (test-demote "** First\n** Second\n** Third\n" 2 1)))
+ (should (string= result "* First\n* Second\n* Third\n"))))
+
+;;; Normal Cases - Hierarchical Structure
+
+(ert-deftest test-demote-with-subheading ()
+ "Should demote heading and subheading proportionally."
+ (let ((result (test-demote "** Heading\n*** Subheading\n" 2 1)))
+ (should (string= result "* Heading\n** Subheading\n"))))
+
+(ert-deftest test-demote-three-levels ()
+ "Should demote three-level hierarchy."
+ (let ((result (test-demote "** Main\n*** Sub\n**** SubSub\n" 2 1)))
+ (should (string= result "* Main\n** Sub\n*** SubSub\n"))))
+
+(ert-deftest test-demote-complex-hierarchy ()
+ "Should demote complex hierarchy maintaining relative structure."
+ (let ((result (test-demote "*** Top\n**** Sub1\n***** Deep\n**** Sub2\n" 3 1)))
+ (should (string= result "* Top\n** Sub1\n*** Deep\n** Sub2\n"))))
+
+;;; Normal Cases - With Content
+
+(ert-deftest test-demote-heading-with-text ()
+ "Should demote heading preserving body text."
+ (let ((result (test-demote "** Heading\nBody text\n" 2 1)))
+ (should (string= result "* Heading\nBody text\n"))))
+
+(ert-deftest test-demote-with-properties ()
+ "Should demote heading preserving properties."
+ (let ((result (test-demote "** Heading\n:PROPERTIES:\n:ID: 123\n:END:\n" 2 1)))
+ (should (string= result "* Heading\n:PROPERTIES:\n:ID: 123\n:END:\n"))))
+
+(ert-deftest test-demote-with-mixed-content ()
+ "Should demote headings preserving all content."
+ (let ((result (test-demote "** H1\nText\n*** H2\nMore text\n" 2 1)))
+ (should (string= result "* H1\nText\n** H2\nMore text\n"))))
+
+;;; Boundary Cases - No Demotion Needed
+
+(ert-deftest test-demote-same-level ()
+ "Should return content unchanged when from equals to."
+ (let ((result (test-demote "* Heading\n" 1 1)))
+ (should (string= result "* Heading\n"))))
+
+(ert-deftest test-demote-promote-ignored ()
+ "Should return content unchanged when to > from (promotion)."
+ (let ((result (test-demote "* Heading\n" 1 2)))
+ (should (string= result "* Heading\n"))))
+
+;;; Boundary Cases - Minimum Level
+
+(ert-deftest test-demote-respects-minimum-level ()
+ "Should not demote below level 1."
+ (let ((result (test-demote "** Main\n*** Sub\n" 2 1)))
+ (should (string= result "* Main\n** Sub\n"))
+ ;; Sub went from 3 to 2, not below 1
+ (should (string-match-p "^\\*\\* Sub" result))))
+
+(ert-deftest test-demote-deep-hierarchy-min-level ()
+ "Should respect minimum level for deep hierarchies."
+ (let ((result (test-demote "**** L4\n***** L5\n****** L6\n" 4 1)))
+ (should (string= result "* L4\n** L5\n*** L6\n"))))
+
+;;; Boundary Cases - Empty and Edge Cases
+
+(ert-deftest test-demote-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-demote "" 2 1)))
+ (should (string= result ""))))
+
+(ert-deftest test-demote-no-headings ()
+ "Should return non-heading content unchanged."
+ (let ((result (test-demote "Just plain text\nNo headings here\n" 2 1)))
+ (should (string= result "Just plain text\nNo headings here\n"))))
+
+(ert-deftest test-demote-heading-without-space ()
+ "Should not match headings without space after stars."
+ (let ((result (test-demote "**Not a heading\n** Real Heading\n" 2 1)))
+ (should (string= result "**Not a heading\n* Real Heading\n"))))
+
+;;; Edge Cases - Special Heading Content
+
+(ert-deftest test-demote-heading-with-tags ()
+ "Should demote heading preserving tags."
+ (let ((result (test-demote "** Heading :tag1:tag2:\n" 2 1)))
+ (should (string= result "* Heading :tag1:tag2:\n"))))
+
+(ert-deftest test-demote-heading-with-todo ()
+ "Should demote heading preserving TODO keyword."
+ (let ((result (test-demote "** TODO Task\n" 2 1)))
+ (should (string= result "* TODO Task\n"))))
+
+(ert-deftest test-demote-heading-with-priority ()
+ "Should demote heading preserving priority."
+ (let ((result (test-demote "** [#A] Important\n" 2 1)))
+ (should (string= result "* [#A] Important\n"))))
+
+;;; Edge Cases - Whitespace
+
+(ert-deftest test-demote-preserves-indentation ()
+ "Should preserve indentation in body text."
+ (let ((result (test-demote "** Heading\n Indented text\n" 2 1)))
+ (should (string= result "* Heading\n Indented text\n"))))
+
+(ert-deftest test-demote-multiple-spaces-after-stars ()
+ "Should handle multiple spaces after stars."
+ (let ((result (test-demote "** Heading\n" 2 1)))
+ (should (string= result "* Heading\n"))))
+
+;;; Edge Cases - Large Demotion
+
+(ert-deftest test-demote-large-level-difference ()
+ "Should handle large level differences."
+ (let ((result (test-demote "****** Level 6\n******* Level 7\n" 6 1)))
+ (should (string= result "* Level 6\n** Level 7\n"))))
+
+(ert-deftest test-demote-to-level-2 ()
+ "Should demote to level 2 when specified."
+ (let ((result (test-demote "***** Level 5\n****** Level 6\n" 5 2)))
+ (should (string= result "** Level 5\n*** Level 6\n"))))
+
+(provide 'test-org-roam-config-demote)
+;;; test-org-roam-config-demote.el ends here
diff --git a/tests/test-org-roam-config-format.el b/tests/test-org-roam-config-format.el
new file mode 100644
index 00000000..e9378b7a
--- /dev/null
+++ b/tests/test-org-roam-config-format.el
@@ -0,0 +1,151 @@
+;;; test-org-roam-config-format.el --- Tests for cj/--format-roam-node -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--format-roam-node function from org-roam-config.el
+;;
+;; This function formats org-roam node file content with title, node-id, and body content.
+;; It creates a complete org-roam file with properties, title, category, and filetags.
+;;
+;; Example:
+;; Input: title: "My Note", node-id: "abc123", content: "* Content\n"
+;; Output: Full org-roam file with metadata and content
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Now load the actual production module
+(require 'org-roam-config)
+
+;;; Test Helpers
+
+(defun test-format (title node-id content)
+ "Test cj/--format-roam-node with TITLE, NODE-ID, and CONTENT.
+Returns the formatted file content."
+ (cj/--format-roam-node title node-id content))
+
+;;; Normal Cases
+
+(ert-deftest test-format-simple-node ()
+ "Should format simple node with all components."
+ (let ((result (test-format "Test Title" "id-123" "* Content\n")))
+ (should (string-match-p ":PROPERTIES:" result))
+ (should (string-match-p ":ID: id-123" result))
+ (should (string-match-p "#\\+TITLE: Test Title" result))
+ (should (string-match-p "#\\+CATEGORY: Test Title" result))
+ (should (string-match-p "#\\+FILETAGS: Topic" result))
+ (should (string-match-p "\\* Content" result))))
+
+(ert-deftest test-format-properties-first ()
+ "Should place properties at the beginning."
+ (let ((result (test-format "Title" "id" "content")))
+ (should (string-prefix-p ":PROPERTIES:\n" result))))
+
+(ert-deftest test-format-id-after-properties ()
+ "Should place ID in properties block."
+ (let ((result (test-format "Title" "test-id-456" "content")))
+ (should (string-match-p ":PROPERTIES:\n:ID: test-id-456\n:END:" result))))
+
+(ert-deftest test-format-title-after-properties ()
+ "Should place title after properties."
+ (let ((result (test-format "My Title" "id" "content")))
+ (should (string-match-p ":END:\n#\\+TITLE: My Title\n" result))))
+
+(ert-deftest test-format-category-matches-title ()
+ "Should set category to match title."
+ (let ((result (test-format "Project Name" "id" "content")))
+ (should (string-match-p "#\\+TITLE: Project Name\n#\\+CATEGORY: Project Name\n" result))))
+
+(ert-deftest test-format-filetags-topic ()
+ "Should set filetags to Topic."
+ (let ((result (test-format "Title" "id" "content")))
+ (should (string-match-p "#\\+FILETAGS: Topic\n" result))))
+
+(ert-deftest test-format-content-at-end ()
+ "Should place content after metadata."
+ (let ((result (test-format "Title" "id" "* Heading\nBody text\n")))
+ (should (string-suffix-p "* Heading\nBody text\n" result))))
+
+;;; Edge Cases - Various Titles
+
+(ert-deftest test-format-title-with-spaces ()
+ "Should handle title with spaces."
+ (let ((result (test-format "Multi Word Title" "id" "content")))
+ (should (string-match-p "#\\+TITLE: Multi Word Title" result))
+ (should (string-match-p "#\\+CATEGORY: Multi Word Title" result))))
+
+(ert-deftest test-format-title-with-punctuation ()
+ "Should handle title with punctuation."
+ (let ((result (test-format "Title: With, Punctuation!" "id" "content")))
+ (should (string-match-p "#\\+TITLE: Title: With, Punctuation!" result))))
+
+(ert-deftest test-format-title-with-numbers ()
+ "Should handle title with numbers."
+ (let ((result (test-format "Version 2.0" "id" "content")))
+ (should (string-match-p "#\\+TITLE: Version 2\\.0" result))))
+
+;;; Edge Cases - Various Node IDs
+
+(ert-deftest test-format-uuid-style-id ()
+ "Should handle UUID-style ID."
+ (let ((result (test-format "Title" "a1b2c3d4-e5f6-7890-abcd-ef1234567890" "content")))
+ (should (string-match-p ":ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890" result))))
+
+(ert-deftest test-format-short-id ()
+ "Should handle short ID."
+ (let ((result (test-format "Title" "1" "content")))
+ (should (string-match-p ":ID: 1" result))))
+
+(ert-deftest test-format-long-id ()
+ "Should handle long ID."
+ (let* ((long-id (make-string 100 ?a))
+ (result (test-format "Title" long-id "content")))
+ (should (string-match-p (concat ":ID: " long-id) result))))
+
+;;; Edge Cases - Various Content
+
+(ert-deftest test-format-empty-content ()
+ "Should handle empty content."
+ (let ((result (test-format "Title" "id" "")))
+ (should (string-suffix-p "#+FILETAGS: Topic\n\n" result))))
+
+(ert-deftest test-format-multiline-content ()
+ "Should handle multiline content."
+ (let ((result (test-format "Title" "id" "* H1\nText\n** H2\nMore\n")))
+ (should (string-suffix-p "* H1\nText\n** H2\nMore\n" result))))
+
+(ert-deftest test-format-content-with-properties ()
+ "Should handle content that already has properties."
+ (let ((result (test-format "Title" "id" "* Heading\n:PROPERTIES:\n:CUSTOM: value\n:END:\n")))
+ (should (string-match-p ":CUSTOM: value" result))))
+
+;;; Integration Tests - Structure
+
+(ert-deftest test-format-complete-structure ()
+ "Should create proper org-roam file structure."
+ (let ((result (test-format "My Note" "abc-123" "* Content\n")))
+ ;; Check order of components
+ (should (< (string-match ":PROPERTIES:" result)
+ (string-match ":ID:" result)))
+ (should (< (string-match ":ID:" result)
+ (string-match ":END:" result)))
+ (should (< (string-match ":END:" result)
+ (string-match "#\\+TITLE:" result)))
+ (should (< (string-match "#\\+TITLE:" result)
+ (string-match "#\\+CATEGORY:" result)))
+ (should (< (string-match "#\\+CATEGORY:" result)
+ (string-match "#\\+FILETAGS:" result)))
+ (should (< (string-match "#\\+FILETAGS:" result)
+ (string-match "\\* Content" result)))))
+
+(ert-deftest test-format-double-newline-after-metadata ()
+ "Should have double newline between metadata and content."
+ (let ((result (test-format "Title" "id" "* Content")))
+ (should (string-match-p "#\\+FILETAGS: Topic\n\n\\* Content" result))))
+
+(provide 'test-org-roam-config-format)
+;;; test-org-roam-config-format.el ends here
diff --git a/tests/test-org-roam-config-link-description.el b/tests/test-org-roam-config-link-description.el
new file mode 100644
index 00000000..06321b8f
--- /dev/null
+++ b/tests/test-org-roam-config-link-description.el
@@ -0,0 +1,188 @@
+;;; test-org-roam-config-link-description.el --- Tests for cj/org-link-get-description -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/org-link-get-description function from org-roam-config.el
+;;
+;; This function extracts the description from an org link, or returns the text unchanged.
+;; If TEXT contains an org link like [[url][description]], it returns description.
+;; If TEXT contains multiple links, only the first one is processed.
+;; Otherwise it returns TEXT unchanged.
+;;
+;; Examples:
+;; Input: "[[https://example.com][Example Site]]"
+;; Output: "Example Site"
+;;
+;; Input: "[[https://example.com]]"
+;; Output: "https://example.com"
+;;
+;; Input: "Plain text"
+;; Output: "Plain text"
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Now load the actual production module
+(require 'org-roam-config)
+
+;;; Test Helpers
+
+(defun test-link-description (text)
+ "Test cj/org-link-get-description on TEXT.
+Returns the extracted description or text unchanged."
+ (cj/org-link-get-description text))
+
+;;; Normal Cases - Link with Description
+
+(ert-deftest test-link-with-description ()
+ "Should extract description from link with description."
+ (let ((result (test-link-description "[[https://example.com][Example Site]]")))
+ (should (string= result "Example Site"))))
+
+(ert-deftest test-link-with-multiword-description ()
+ "Should extract multi-word description."
+ (let ((result (test-link-description "[[url][Multiple Word Description]]")))
+ (should (string= result "Multiple Word Description"))))
+
+(ert-deftest test-link-with-special-chars-in-description ()
+ "Should extract description with special characters."
+ (let ((result (test-link-description "[[url][Description: with, punctuation!]]")))
+ (should (string= result "Description: with, punctuation!"))))
+
+(ert-deftest test-link-file-path-with-description ()
+ "Should extract description from file link."
+ (let ((result (test-link-description "[[file:~/document.pdf][My Document]]")))
+ (should (string= result "My Document"))))
+
+(ert-deftest test-link-with-numbers-in-description ()
+ "Should extract description containing numbers."
+ (let ((result (test-link-description "[[url][Chapter 42]]")))
+ (should (string= result "Chapter 42"))))
+
+;;; Normal Cases - Link without Description
+
+(ert-deftest test-link-without-description-url ()
+ "Should return URL when no description is present."
+ (let ((result (test-link-description "[[https://example.com]]")))
+ (should (string= result "https://example.com"))))
+
+(ert-deftest test-link-without-description-file ()
+ "Should return file path when no description."
+ (let ((result (test-link-description "[[file:~/notes.org]]")))
+ (should (string= result "file:~/notes.org"))))
+
+(ert-deftest test-link-without-description-id ()
+ "Should return ID when no description."
+ (let ((result (test-link-description "[[id:abc123]]")))
+ (should (string= result "id:abc123"))))
+
+;;; Normal Cases - No Link
+
+(ert-deftest test-plain-text ()
+ "Should return plain text unchanged."
+ (let ((result (test-link-description "Plain text without link")))
+ (should (string= result "Plain text without link"))))
+
+(ert-deftest test-text-with-brackets-but-not-link ()
+ "Should return text with single brackets unchanged."
+ (let ((result (test-link-description "Text [with] brackets")))
+ (should (string= result "Text [with] brackets"))))
+
+(ert-deftest test-text-with-partial-link-syntax ()
+ "Should return text with partial link syntax unchanged."
+ (let ((result (test-link-description "[[incomplete link")))
+ (should (string= result "[[incomplete link"))))
+
+;;; Boundary Cases - Multiple Links
+
+(ert-deftest test-multiple-links-extracts-first ()
+ "Should extract description from first link only."
+ (let ((result (test-link-description "[[url1][First]] and [[url2][Second]]")))
+ (should (string= result "First"))))
+
+(ert-deftest test-multiple-links-first-has-no-description ()
+ "Should extract URL from first link when it has no description."
+ (let ((result (test-link-description "[[url1]] and [[url2][Second]]")))
+ (should (string= result "url1"))))
+
+;;; Boundary Cases - Empty and Edge Cases
+
+(ert-deftest test-empty-string ()
+ "Should return empty string unchanged."
+ (let ((result (test-link-description "")))
+ (should (string= result ""))))
+
+(ert-deftest test-link-with-empty-description ()
+ "Should return text unchanged when description brackets are empty."
+ (let ((result (test-link-description "[[https://example.com][]]")))
+ ;; Regex requires at least one char in description, so no match
+ (should (string= result "[[https://example.com][]]"))))
+
+(ert-deftest test-link-with-empty-url ()
+ "Should return text unchanged when link is completely empty."
+ (let ((result (test-link-description "[[]]")))
+ ;; Regex requires at least one char in URL, so no match, returns unchanged
+ (should (string= result "[[]]"))))
+
+(ert-deftest test-link-with-empty-url-and-description ()
+ "Should handle completely empty link."
+ (let ((result (test-link-description "[][]")))
+ (should (string= result "[][]"))))
+
+;;; Edge Cases - Special Link Types
+
+(ert-deftest test-internal-link ()
+ "Should extract description from internal link."
+ (let ((result (test-link-description "[[*Heading][My Heading]]")))
+ (should (string= result "My Heading"))))
+
+(ert-deftest test-internal-link-without-description ()
+ "Should return heading target from internal link without description."
+ (let ((result (test-link-description "[[*Heading]]")))
+ (should (string= result "*Heading"))))
+
+(ert-deftest test-custom-id-link ()
+ "Should handle custom ID links."
+ (let ((result (test-link-description "[[#custom-id][Custom Section]]")))
+ (should (string= result "Custom Section"))))
+
+;;; Edge Cases - Link with Surrounding Text
+
+(ert-deftest test-link-with-prefix-text ()
+ "Should extract description from link with prefix text."
+ (let ((result (test-link-description "See [[url][documentation]] for details")))
+ (should (string= result "documentation"))))
+
+(ert-deftest test-link-at-start ()
+ "Should extract description from link at start of text."
+ (let ((result (test-link-description "[[url][Link]] at beginning")))
+ (should (string= result "Link"))))
+
+(ert-deftest test-link-at-end ()
+ "Should extract description from link at end of text."
+ (let ((result (test-link-description "Text with [[url][link]]")))
+ (should (string= result "link"))))
+
+;;; Edge Cases - Special Characters in URL
+
+(ert-deftest test-link-with-query-params ()
+ "Should handle URL with query parameters."
+ (let ((result (test-link-description "[[https://example.com?q=test&foo=bar][Search]]")))
+ (should (string= result "Search"))))
+
+(ert-deftest test-link-with-anchor ()
+ "Should handle URL with anchor."
+ (let ((result (test-link-description "[[https://example.com#section][Section]]")))
+ (should (string= result "Section"))))
+
+(ert-deftest test-link-with-spaces-in-description ()
+ "Should preserve spaces in description."
+ (let ((result (test-link-description "[[url][Multiple Spaces]]")))
+ (should (string= result "Multiple Spaces"))))
+
+(provide 'test-org-roam-config-link-description)
+;;; test-org-roam-config-link-description.el ends here
diff --git a/tests/test-org-roam-config-slug.el b/tests/test-org-roam-config-slug.el
new file mode 100644
index 00000000..eb3149dd
--- /dev/null
+++ b/tests/test-org-roam-config-slug.el
@@ -0,0 +1,223 @@
+;;; test-org-roam-config-slug.el --- Tests for cj/--generate-roam-slug -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--generate-roam-slug function from org-roam-config.el
+;;
+;; This function converts a title to a filename-safe slug by:
+;; 1. Converting to lowercase
+;; 2. Replacing non-alphanumeric characters with hyphens
+;; 3. Removing leading and trailing hyphens
+;;
+;; Examples:
+;; Input: "My Project Name"
+;; Output: "my-project-name"
+;;
+;; Input: "Hello, World!"
+;; Output: "hello-world"
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Now load the actual production module
+(require 'org-roam-config)
+
+;;; Test Helpers
+
+(defun test-slug (title)
+ "Test cj/--generate-roam-slug on TITLE.
+Returns the slugified string."
+ (cj/--generate-roam-slug title))
+
+;;; Normal Cases - Simple Titles
+
+(ert-deftest test-slug-simple-word ()
+ "Should return lowercase simple word."
+ (let ((result (test-slug "Hello")))
+ (should (string= result "hello"))))
+
+(ert-deftest test-slug-multiple-words ()
+ "Should replace spaces with hyphens."
+ (let ((result (test-slug "My Project Name")))
+ (should (string= result "my-project-name"))))
+
+(ert-deftest test-slug-already-lowercase ()
+ "Should handle already lowercase text."
+ (let ((result (test-slug "simple")))
+ (should (string= result "simple"))))
+
+(ert-deftest test-slug-mixed-case ()
+ "Should convert mixed case to lowercase."
+ (let ((result (test-slug "MixedCaseTitle")))
+ (should (string= result "mixedcasetitle"))))
+
+;;; Normal Cases - Punctuation
+
+(ert-deftest test-slug-with-comma ()
+ "Should remove commas."
+ (let ((result (test-slug "Hello, World")))
+ (should (string= result "hello-world"))))
+
+(ert-deftest test-slug-with-period ()
+ "Should remove periods."
+ (let ((result (test-slug "Version 2.0")))
+ (should (string= result "version-2-0"))))
+
+(ert-deftest test-slug-with-exclamation ()
+ "Should remove exclamation marks."
+ (let ((result (test-slug "Hello World!")))
+ (should (string= result "hello-world"))))
+
+(ert-deftest test-slug-with-question ()
+ "Should remove question marks."
+ (let ((result (test-slug "What Is This?")))
+ (should (string= result "what-is-this"))))
+
+(ert-deftest test-slug-with-colon ()
+ "Should remove colons."
+ (let ((result (test-slug "Note: Important")))
+ (should (string= result "note-important"))))
+
+(ert-deftest test-slug-with-parentheses ()
+ "Should remove parentheses."
+ (let ((result (test-slug "Item (copy)")))
+ (should (string= result "item-copy"))))
+
+;;; Normal Cases - Numbers
+
+(ert-deftest test-slug-with-numbers ()
+ "Should preserve numbers."
+ (let ((result (test-slug "Chapter 42")))
+ (should (string= result "chapter-42"))))
+
+(ert-deftest test-slug-only-numbers ()
+ "Should handle titles with only numbers."
+ (let ((result (test-slug "123")))
+ (should (string= result "123"))))
+
+(ert-deftest test-slug-mixed-alphanumeric ()
+ "Should preserve alphanumeric characters."
+ (let ((result (test-slug "Test123ABC")))
+ (should (string= result "test123abc"))))
+
+;;; Boundary Cases - Multiple Consecutive Special Chars
+
+(ert-deftest test-slug-multiple-spaces ()
+ "Should collapse multiple spaces into single hyphen."
+ (let ((result (test-slug "Hello World")))
+ (should (string= result "hello-world"))))
+
+(ert-deftest test-slug-mixed-punctuation ()
+ "Should collapse mixed punctuation into single hyphen."
+ (let ((result (test-slug "Hello, ... World!")))
+ (should (string= result "hello-world"))))
+
+(ert-deftest test-slug-consecutive-hyphens ()
+ "Should collapse consecutive hyphens."
+ (let ((result (test-slug "Hello---World")))
+ (should (string= result "hello-world"))))
+
+;;; Boundary Cases - Leading/Trailing Special Chars
+
+(ert-deftest test-slug-leading-space ()
+ "Should remove leading hyphen from leading space."
+ (let ((result (test-slug " Hello")))
+ (should (string= result "hello"))))
+
+(ert-deftest test-slug-trailing-space ()
+ "Should remove trailing hyphen from trailing space."
+ (let ((result (test-slug "Hello ")))
+ (should (string= result "hello"))))
+
+(ert-deftest test-slug-leading-punctuation ()
+ "Should remove leading hyphen from leading punctuation."
+ (let ((result (test-slug "...Hello")))
+ (should (string= result "hello"))))
+
+(ert-deftest test-slug-trailing-punctuation ()
+ "Should remove trailing hyphen from trailing punctuation."
+ (let ((result (test-slug "Hello!!!")))
+ (should (string= result "hello"))))
+
+(ert-deftest test-slug-leading-and-trailing ()
+ "Should remove both leading and trailing hyphens."
+ (let ((result (test-slug " Hello World ")))
+ (should (string= result "hello-world"))))
+
+;;; Boundary Cases - Empty and Short
+
+(ert-deftest test-slug-empty-string ()
+ "Should return empty string for empty input."
+ (let ((result (test-slug "")))
+ (should (string= result ""))))
+
+(ert-deftest test-slug-only-punctuation ()
+ "Should return empty string for only punctuation."
+ (let ((result (test-slug "!!!")))
+ (should (string= result ""))))
+
+(ert-deftest test-slug-only-spaces ()
+ "Should return empty string for only spaces."
+ (let ((result (test-slug " ")))
+ (should (string= result ""))))
+
+(ert-deftest test-slug-single-char ()
+ "Should handle single character."
+ (let ((result (test-slug "A")))
+ (should (string= result "a"))))
+
+;;; Edge Cases - Special Characters
+
+(ert-deftest test-slug-with-underscore ()
+ "Should replace underscores with hyphens."
+ (let ((result (test-slug "my_variable_name")))
+ (should (string= result "my-variable-name"))))
+
+(ert-deftest test-slug-with-slash ()
+ "Should remove slashes."
+ (let ((result (test-slug "path/to/file")))
+ (should (string= result "path-to-file"))))
+
+(ert-deftest test-slug-with-at-sign ()
+ "Should remove at signs."
+ (let ((result (test-slug "user@example")))
+ (should (string= result "user-example"))))
+
+(ert-deftest test-slug-with-hash ()
+ "Should remove hash symbols."
+ (let ((result (test-slug "#hashtag")))
+ (should (string= result "hashtag"))))
+
+(ert-deftest test-slug-with-dollar ()
+ "Should remove dollar signs."
+ (let ((result (test-slug "$price")))
+ (should (string= result "price"))))
+
+;;; Edge Cases - Unicode (if supported)
+
+(ert-deftest test-slug-with-unicode ()
+ "Should remove unicode characters."
+ (let ((result (test-slug "Café")))
+ (should (string= result "caf"))))
+
+(ert-deftest test-slug-with-emoji ()
+ "Should remove emoji."
+ (let ((result (test-slug "Hello 😀 World")))
+ (should (string= result "hello-world"))))
+
+;;; Edge Cases - Long Titles
+
+(ert-deftest test-slug-very-long-title ()
+ "Should handle very long titles."
+ (let* ((long-title (mapconcat #'identity (make-list 20 "word") " "))
+ (result (test-slug long-title)))
+ (should (string-prefix-p "word-" result))
+ (should (string-suffix-p "-word" result))
+ (should (not (string-match-p " " result)))))
+
+(provide 'test-org-roam-config-slug)
+;;; test-org-roam-config-slug.el ends here