diff options
| -rw-r--r-- | gptel-tools/read_buffer.el | 24 | ||||
| -rw-r--r-- | gptel-tools/write_text_file.el | 134 | ||||
| -rw-r--r-- | tests/test-gptel-tools-list-directory-files.el | 162 | ||||
| -rw-r--r-- | tests/test-gptel-tools-move-to-trash.el | 136 | ||||
| -rw-r--r-- | tests/test-gptel-tools-read-buffer.el | 60 | ||||
| -rw-r--r-- | tests/test-gptel-tools-read-text-file.el | 117 | ||||
| -rw-r--r-- | tests/test-gptel-tools-write-text-file.el | 141 | ||||
| -rw-r--r-- | todo.org | 45 |
8 files changed, 730 insertions, 89 deletions
diff --git a/gptel-tools/read_buffer.el b/gptel-tools/read_buffer.el index d01cee71..1b4fc904 100644 --- a/gptel-tools/read_buffer.el +++ b/gptel-tools/read_buffer.el @@ -1,27 +1,31 @@ ;;; read_buffer.el --- Read buffer tool for GPTel -*- coding: utf-8; lexical-binding: t; -*- ;;; Commentary: -;; +;; Gptel tool that returns the contents of an Emacs buffer by name. ;;; Code: (require 'gptel) +(defun cj/read-buffer--get-content (buffer) + "Return the substring of BUFFER from `point-min' to `point-max'. +BUFFER may be a buffer object or a buffer name string. Signal an +error when no live buffer matches." + (unless (buffer-live-p (get-buffer buffer)) + (error "Buffer %s is not live" buffer)) + (with-current-buffer buffer + (buffer-substring-no-properties (point-min) (point-max)))) + (gptel-make-tool :name "read_buffer" - :function (lambda (buffer) - (unless (buffer-live-p (get-buffer buffer)) - (error "Error: buffer %s is not live" buffer)) - (with-current-buffer buffer - (buffer-substring-no-properties (point-min) (point-max)))) + :function (lambda (buffer) (cj/read-buffer--get-content buffer)) :description "return the contents of an emacs buffer" :args (list '(:name "buffer" - :type string ; :type value must be a symbol - :description "the name of the buffer whose contents are to be retrieved")) + :type string + :description "the name of the buffer whose contents are to be retrieved")) :category "emacs") -;; Automatically add to gptel-tools on load (add-to-list 'gptel-tools (gptel-get-tool '("emacs" "read_buffer"))) (provide 'read_buffer) -;;; read_buffer.el ends here. +;;; read_buffer.el ends here diff --git a/gptel-tools/write_text_file.el b/gptel-tools/write_text_file.el index 03d64e57..40482c66 100644 --- a/gptel-tools/write_text_file.el +++ b/gptel-tools/write_text_file.el @@ -1,94 +1,94 @@ ;;; write_text_file.el --- Write text files for gptel -*- lexical-binding: t; -*- -;; Copyright (C) 2025 - -;; Author: gptel-tool-writer +;; Author: Craig Jennings <c@cjennings.net> ;; Keywords: convenience, tools -;; Package-Requires: ((emacs "27.1") (gptel "0.9.0")) ;; This file is not part of GNU Emacs. -;; This program is free software; you can redistribute it and/or modify -;; it under the terms of the GNU General Public License as published by -;; the Free Software Foundation, either version 3 of the License, or -;; (at your option) any later version. - -;; This program is distributed in the hope that it will be useful, -;; but WITHOUT ANY WARRANTY; without even the implied warranty of -;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -;; GNU General Public License for more details. - ;;; Commentary: -;; This file provides a gptel tool for writing text files to the filesystem. -;; The tool includes safety features like backup creation, size limits, -;; and restriction to the user's home directory. +;; Gptel tool for writing a text file under the user's home directory. +;; Creates parent directories as needed, optionally overwrites an +;; existing file (with a timestamped backup), and rejects writes +;; larger than 1 GB unless the user confirms. ;;; Code: (require 'gptel) +(defconst cj/write-text-file--size-limit (* 1024 1024 1024) + "Soft cap for new-file writes (1 GB). Above this size a confirm is required.") + +(defun cj/write-text-file--validate-path (path) + "Validate PATH for write. Return the expanded path on success. +PATH must resolve inside the user's home directory." + (let ((full (expand-file-name path "~"))) + (unless (string-prefix-p (expand-file-name "~") full) + (error "Path must be within home directory: %s" path)) + full)) + +(defun cj/write-text-file--backup-name (path) + "Return a timestamped backup filename for PATH." + (format "%s-%s.bak" + path + (format-time-string "%Y-%m-%d-%H%M%S"))) + +(defun cj/write-text-file--ensure-parent (path) + "Ensure the parent directory of PATH exists and is writable. +Create missing parents. Signal on failure." + (let ((parent (file-name-directory path))) + (when parent + (unless (file-exists-p parent) + (condition-case err + (make-directory parent t) + (error (error "Cannot create directory %s: %s" + parent (error-message-string err))))) + (unless (file-writable-p parent) + (error "No write permission for directory %s" parent))))) + +(defun cj/write-text-file--run (path content &optional overwrite) + "Write CONTENT to PATH. Return a status string. +PATH must be inside the user's home directory. If the file exists +and OVERWRITE is non-nil, make a timestamped backup before writing; +otherwise signal." + (let* ((full (cj/write-text-file--validate-path path)) + (content (or content "")) + (size (length content))) + (when (> size cj/write-text-file--size-limit) + (unless (y-or-n-p (format "File is %s. Write anyway? " + (file-size-human-readable size))) + (error "File write cancelled: size exceeds 1GB limit"))) + (cj/write-text-file--ensure-parent full) + (when (file-exists-p full) + (if overwrite + (let ((backup (cj/write-text-file--backup-name full))) + (copy-file full backup t) + (message "Backed up existing file to %s" backup)) + (error "File %s already exists. Set overwrite to true to replace it" full))) + (with-temp-file full (insert content)) + (format "Successfully wrote %d bytes to %s" size full))) + (with-eval-after-load 'gptel (gptel-make-tool :name "write_text_file" :function (lambda (path content &optional overwrite) - (let* ((full-path (expand-file-name path "~")) - (content (or content "")) - (content-size (length content)) - (size-limit (* 1024 1024 1024))) ; 1 GB - ;; Check if path is within home directory - (unless (string-prefix-p (expand-file-name "~") full-path) - (error "Path must be within home directory")) - ;; Check size limit - (when (> content-size size-limit) - (unless (y-or-n-p (format "File is %s. Write anyway? " - (file-size-human-readable content-size))) - (error "File write cancelled: size exceeds 1GB limit"))) - ;; Check write permission on parent directory - (let ((parent-dir (file-name-directory full-path))) - (when parent-dir - ;; Create parent directories if needed - (unless (file-exists-p parent-dir) - (condition-case err - (make-directory parent-dir t) - (error (error "Cannot create directory %s: %s" - parent-dir (error-message-string err))))) - ;; Check write permission - (unless (file-writable-p parent-dir) - (error "No write permission for directory %s" parent-dir)))) - ;; Handle existing file - (when (file-exists-p full-path) - (if overwrite - ;; Create backup with timestamp - (let* ((backup-name - (format "%s-%s.bak" - full-path - (format-time-string "%Y-%m-%d-%H%M%S")))) - (copy-file full-path backup-name t) - (message "Backed up existing file to %s" backup-name)) - (error "File %s already exists. Set overwrite to true to replace it" full-path))) - ;; Write the file atomically - (with-temp-file full-path - (insert content)) - (format "Successfully wrote %d bytes to %s" - content-size full-path))) + (cj/write-text-file--run path content overwrite)) :description "Write text content to a file within the user's home directory. Creates parent directories if needed. Backs up existing files with timestamp when overwriting." :args (list '(:name "path" - :type string - :description "File path relative to home directory, e.g., 'documents/myfile.txt' or '~/documents/myfile.txt'") + :type string + :description "File path relative to home directory, e.g., 'documents/myfile.txt' or '~/documents/myfile.txt'") '(:name "content" - :type string - :description "The text content to write to the file") + :type string + :description "The text content to write to the file") '(:name "overwrite" - :type boolean - :description "If true, backup and overwrite existing file. If false or omitted, error if file exists" - :optional t)) + :type boolean + :description "If true, backup and overwrite existing file. If false or omitted, error if file exists" + :optional t)) :category "filesystem" :confirm t :include t) - - ;; Automatically add to gptel-tools on load + (add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "write_text_file")))) (provide 'write_text_file) -;;; write_text_file.el ends here
\ No newline at end of file +;;; write_text_file.el ends here diff --git a/tests/test-gptel-tools-list-directory-files.el b/tests/test-gptel-tools-list-directory-files.el new file mode 100644 index 00000000..a91a7e79 --- /dev/null +++ b/tests/test-gptel-tools-list-directory-files.el @@ -0,0 +1,162 @@ +;;; test-gptel-tools-list-directory-files.el --- Tests for list_directory_files -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the helpers in list_directory_files.el. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(eval-and-compile + (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) + (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) + (setq load-prefer-newer t) + (unless (featurep 'gptel) + (defvar gptel-tools nil) + (defun gptel-make-tool (&rest _args) nil) + (defun gptel-get-tool (&rest _args) nil) + (provide 'gptel))) + +(require 'list_directory_files) + +;; -------------------------- helpers + +(defun test-gptel-tools-list--with-tree (fn) + "Create a small directory tree, call FN with its root, clean up." + (let ((root (make-temp-file "test-gptel-tools-list-" t))) + (unwind-protect + (progn + (with-temp-file (expand-file-name "a.txt" root) (insert "a")) + (with-temp-file (expand-file-name "b.org" root) (insert "b")) + (make-directory (expand-file-name "sub" root)) + (with-temp-file (expand-file-name "sub/c.txt" root) (insert "c")) + (funcall fn root)) + (delete-directory root t)))) + +;; -------------------------- mode-to-permissions + +(ert-deftest test-gptel-tools-list-mode-to-permissions-regular-file () + "Mode 0644 on a regular file: -rw-r--r--." + (should (equal (list-directory-files--mode-to-permissions #o0644) + "-rw-r--r--"))) + +(ert-deftest test-gptel-tools-list-mode-to-permissions-directory () + "Mode 0755 + dir bit: drwxr-xr-x." + (should (equal (list-directory-files--mode-to-permissions + (logior #o40000 #o0755)) + "drwxr-xr-x"))) + +(ert-deftest test-gptel-tools-list-mode-to-permissions-executable () + "Mode 0700: -rwx------." + (should (equal (list-directory-files--mode-to-permissions #o0700) + "-rwx------"))) + +;; -------------------------- get-file-info + +(ert-deftest test-gptel-tools-list-get-file-info-success () + "Success: returns a plist with :success t and metadata." + (test-gptel-tools-list--with-tree + (lambda (root) + (let ((info (list-directory-files--get-file-info + (expand-file-name "a.txt" root)))) + (should (plist-get info :success)) + (should (numberp (plist-get info :size))) + (should (stringp (plist-get info :permissions))))))) + +(ert-deftest test-gptel-tools-list-get-file-info-directory () + "Directory info: :is-directory is t." + (test-gptel-tools-list--with-tree + (lambda (root) + (let ((info (list-directory-files--get-file-info + (expand-file-name "sub" root)))) + (should (plist-get info :is-directory)))))) + +;; -------------------------- filter-by-extension + +(ert-deftest test-gptel-tools-list-filter-by-extension-keeps-match () + "Filter for txt keeps txt files." + (let* ((filter (list-directory-files--filter-by-extension "txt")) + (info '(:success t :path "/x/foo.txt" :is-directory nil))) + (should (funcall filter info)))) + +(ert-deftest test-gptel-tools-list-filter-by-extension-drops-non-match () + "Filter for txt drops non-txt files." + (let* ((filter (list-directory-files--filter-by-extension "txt")) + (info '(:success t :path "/x/foo.org" :is-directory nil))) + (should-not (funcall filter info)))) + +(ert-deftest test-gptel-tools-list-filter-by-extension-always-keeps-directories () + "Filter keeps directories regardless of extension." + (let* ((filter (list-directory-files--filter-by-extension "txt")) + (info '(:success t :path "/x/sub" :is-directory t))) + (should (funcall filter info)))) + +(ert-deftest test-gptel-tools-list-filter-by-extension-no-extension-is-nil () + "No extension produces a nil filter (i.e. no filtering)." + (should-not (list-directory-files--filter-by-extension nil))) + +;; -------------------------- format-file-entry + +(ert-deftest test-gptel-tools-list-format-file-entry-shape () + "Formatted entry contains permissions, size, mtime, and relative path." + (let* ((info (list (cons :path "/home/u/foo.txt") + (cons :permissions "-rw-r--r--") + (cons :executable nil) + (cons :size 42) + (cons :last-modified (current-time)))) + ;; Build as plist by flattening the cons list. + (info-plist (cl-loop for (k . v) in info append (list k v))) + (out (list-directory-files--format-file-entry info-plist "/home/u"))) + (should (string-match-p "-rw-r--r--" out)) + (should (string-match-p "foo.txt" out)))) + +;; -------------------------- list-directory + +(ert-deftest test-gptel-tools-list-list-directory-flat () + "Non-recursive listing returns only entries in the top level." + (test-gptel-tools-list--with-tree + (lambda (root) + (let* ((result (list-directory-files--list-directory root nil nil)) + (files (plist-get result :files))) + (should files) + (let ((paths (mapcar (lambda (i) (plist-get i :path)) files))) + (should (cl-some (lambda (p) (string-match-p "/a\\.txt\\'" p)) paths)) + (should-not (cl-some (lambda (p) (string-match-p "/c\\.txt\\'" p)) paths))))))) + +(ert-deftest test-gptel-tools-list-list-directory-recursive () + "Recursive listing also returns sub-directory contents." + (test-gptel-tools-list--with-tree + (lambda (root) + (let* ((result (list-directory-files--list-directory root t nil)) + (files (plist-get result :files)) + (paths (mapcar (lambda (i) (plist-get i :path)) files))) + (should (cl-some (lambda (p) (string-match-p "/c\\.txt\\'" p)) paths)))))) + +(ert-deftest test-gptel-tools-list-list-directory-error-not-a-directory () + "Non-directory path returns errors entry." + (test-gptel-tools-list--with-tree + (lambda (root) + (let* ((result (list-directory-files--list-directory + (expand-file-name "a.txt" root) nil nil)) + (errors (plist-get result :errors))) + (should errors))))) + +;; -------------------------- format-output + +(ert-deftest test-gptel-tools-list-format-output-has-files-section () + "Format-output includes a \"Found N file(s)\" line when files present." + (test-gptel-tools-list--with-tree + (lambda (root) + (let* ((result (list-directory-files--list-directory root nil nil)) + (out (list-directory-files--format-output root result))) + (should (string-match-p "Found [0-9]+ file" out)))))) + +(ert-deftest test-gptel-tools-list-format-output-empty () + "Empty result: \"No files found\"." + (let ((out (list-directory-files--format-output + "/nowhere" '(:files nil :errors nil)))) + (should (string-match-p "No files found" out)))) + +(provide 'test-gptel-tools-list-directory-files) +;;; test-gptel-tools-list-directory-files.el ends here diff --git a/tests/test-gptel-tools-move-to-trash.el b/tests/test-gptel-tools-move-to-trash.el new file mode 100644 index 00000000..a6ab1200 --- /dev/null +++ b/tests/test-gptel-tools-move-to-trash.el @@ -0,0 +1,136 @@ +;;; test-gptel-tools-move-to-trash.el --- Tests for move_to_trash gptel tool -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the helpers in move_to_trash.el. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(eval-and-compile + (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) + (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) + (setq load-prefer-newer t) + (unless (featurep 'gptel) + (defvar gptel-tools nil) + (defun gptel-make-tool (&rest _args) nil) + (defun gptel-get-tool (&rest _args) nil) + (provide 'gptel))) + +(require 'move_to_trash) + +;; -------------------------- helpers + +(defun test-gptel-tools-trash--with-tmp-tree (fn) + "Create a temp source dir and trash dir; run FN with both; clean up." + (let* ((src (make-temp-file "test-gptel-tools-trash-src-" t)) + (trash (make-temp-file "test-gptel-tools-trash-dst-" t))) + (unwind-protect + (funcall fn src trash) + (when (file-exists-p src) (delete-directory src t)) + (when (file-exists-p trash) (delete-directory trash t))))) + +;; -------------------------- generate-unique-name + +(ert-deftest test-gptel-tools-trash-generate-unique-name-no-conflict () + "No conflict: returns the plain base name in trash." + (test-gptel-tools-trash--with-tmp-tree + (lambda (_src trash) + (let ((out (gptel--move-to-trash-generate-unique-name + "/anywhere/foo.txt" trash))) + (should (equal (file-name-nondirectory out) "foo.txt")))))) + +(ert-deftest test-gptel-tools-trash-generate-unique-name-conflict-timestamps () + "Name conflict: returns a name with a timestamp suffix." + (test-gptel-tools-trash--with-tmp-tree + (lambda (_src trash) + (with-temp-file (expand-file-name "foo.txt" trash) (insert "")) + (let* ((out (gptel--move-to-trash-generate-unique-name + "/anywhere/foo.txt" trash)) + (name (file-name-nondirectory out))) + (should-not (equal name "foo.txt")) + (should (string-match-p "\\`foo-[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\.txt\\'" + name)))))) + +(ert-deftest test-gptel-tools-trash-generate-unique-name-no-extension () + "Conflict on a name without extension: timestamp appended to the bare name." + (test-gptel-tools-trash--with-tmp-tree + (lambda (_src trash) + (with-temp-file (expand-file-name "noext" trash) (insert "")) + (let* ((out (gptel--move-to-trash-generate-unique-name + "/anywhere/noext" trash)) + (name (file-name-nondirectory out))) + (should-not (equal name "noext")) + (should (string-match-p "\\`noext-[0-9]" name)))))) + +;; -------------------------- validate-path + +(ert-deftest test-gptel-tools-trash-validate-path-normal-home () + "Normal: an existing path under HOME validates." + (let ((path (expand-file-name + (format ".test-gptel-tools-trash-home-%s.tmp" + (format-time-string "%s%N")) + "~"))) + (unwind-protect + (progn + (with-temp-file path (insert "")) + (should (equal (gptel--move-to-trash-validate-path path) + (expand-file-name path)))) + (when (file-exists-p path) (delete-file path))))) + +(ert-deftest test-gptel-tools-trash-validate-path-normal-tmp () + "Normal: an existing path under /tmp validates." + (let ((path (make-temp-file "test-gptel-tools-trash-tmpvalidate-"))) + (unwind-protect + (should (equal (gptel--move-to-trash-validate-path path) + (expand-file-name path))) + (when (file-exists-p path) (delete-file path))))) + +(ert-deftest test-gptel-tools-trash-validate-path-error-outside-allowed () + "Error: a path outside HOME or /tmp signals." + (should-error (gptel--move-to-trash-validate-path "/etc/hostname"))) + +(ert-deftest test-gptel-tools-trash-validate-path-error-critical-dir () + "Error: critical directories (home root, .emacs.d, .config, /tmp) signal." + (should-error (gptel--move-to-trash-validate-path "~")) + (should-error (gptel--move-to-trash-validate-path "~/.emacs.d")) + (should-error (gptel--move-to-trash-validate-path "~/.config")) + (should-error (gptel--move-to-trash-validate-path "/tmp"))) + +(ert-deftest test-gptel-tools-trash-validate-path-error-missing () + "Error: missing path signals." + (let ((path (expand-file-name + (format ".test-gptel-tools-trash-missing-%s.tmp" + (format-time-string "%s%N")) + "~"))) + (when (file-exists-p path) (delete-file path)) + (should-error (gptel--move-to-trash-validate-path path)))) + +;; -------------------------- perform + +(ert-deftest test-gptel-tools-trash-perform-moves-file () + "Perform: moves the file out of the source dir into the trash dir." + (test-gptel-tools-trash--with-tmp-tree + (lambda (src trash) + (let ((file (expand-file-name "doomed.txt" src))) + (with-temp-file file (insert "trash me")) + (let ((status (gptel--move-to-trash-perform file trash))) + (should (string-match-p "moved to trash" status)) + (should-not (file-exists-p file)) + (should (file-exists-p (expand-file-name "doomed.txt" trash)))))))) + +(ert-deftest test-gptel-tools-trash-perform-handles-directory () + "Perform: moves a directory as a unit." + (test-gptel-tools-trash--with-tmp-tree + (lambda (src trash) + (let ((dir (expand-file-name "subdir" src))) + (make-directory dir) + (with-temp-file (expand-file-name "inside.txt" dir) (insert "x")) + (let ((status (gptel--move-to-trash-perform dir trash))) + (should (string-match-p "Directory moved to trash" status)) + (should-not (file-exists-p dir)) + (should (file-exists-p (expand-file-name "subdir/inside.txt" trash)))))))) + +(provide 'test-gptel-tools-move-to-trash) +;;; test-gptel-tools-move-to-trash.el ends here diff --git a/tests/test-gptel-tools-read-buffer.el b/tests/test-gptel-tools-read-buffer.el new file mode 100644 index 00000000..75efd604 --- /dev/null +++ b/tests/test-gptel-tools-read-buffer.el @@ -0,0 +1,60 @@ +;;; test-gptel-tools-read-buffer.el --- Tests for read_buffer gptel tool -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for `cj/read-buffer--get-content', the testable helper that +;; backs the read_buffer gptel tool. + +;;; Code: + +(require 'ert) + +(eval-and-compile + (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) + (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) + (setq load-prefer-newer t) + (unless (featurep 'gptel) + (defvar gptel-tools nil) + (defun gptel-make-tool (&rest _args) nil) + (defun gptel-get-tool (&rest _args) nil) + (provide 'gptel))) + +(require 'read_buffer) + +(ert-deftest test-gptel-tools-read-buffer-normal () + "Normal: returns the contents of an existing buffer." + (with-temp-buffer + (rename-buffer "test-gptel-tools-read-buffer-normal" t) + (insert "hello world") + (should (equal (cj/read-buffer--get-content (buffer-name)) "hello world")))) + +(ert-deftest test-gptel-tools-read-buffer-boundary-empty-buffer () + "Boundary: empty buffer returns the empty string." + (with-temp-buffer + (rename-buffer "test-gptel-tools-read-buffer-empty" t) + (should (equal (cj/read-buffer--get-content (buffer-name)) "")))) + +(ert-deftest test-gptel-tools-read-buffer-boundary-buffer-object () + "Boundary: accepts a buffer object as well as a name string." + (with-temp-buffer + (insert "from buffer object") + (should (equal (cj/read-buffer--get-content (current-buffer)) + "from buffer object")))) + +(ert-deftest test-gptel-tools-read-buffer-boundary-strips-text-properties () + "Boundary: the returned string has no text properties." + (with-temp-buffer + (rename-buffer "test-gptel-tools-read-buffer-props" t) + (insert (propertize "fontified" 'face 'bold)) + (let ((content (cj/read-buffer--get-content (buffer-name)))) + (should (equal content "fontified")) + (should-not (text-properties-at 0 content))))) + +(ert-deftest test-gptel-tools-read-buffer-error-missing-buffer () + "Error: nonexistent buffer name signals." + (when (get-buffer "test-gptel-tools-read-buffer-absent") + (kill-buffer "test-gptel-tools-read-buffer-absent")) + (should-error (cj/read-buffer--get-content + "test-gptel-tools-read-buffer-absent"))) + +(provide 'test-gptel-tools-read-buffer) +;;; test-gptel-tools-read-buffer.el ends here diff --git a/tests/test-gptel-tools-read-text-file.el b/tests/test-gptel-tools-read-text-file.el new file mode 100644 index 00000000..3a4f6662 --- /dev/null +++ b/tests/test-gptel-tools-read-text-file.el @@ -0,0 +1,117 @@ +;;; test-gptel-tools-read-text-file.el --- Tests for read_text_file gptel tool -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the helpers in read_text_file.el. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(eval-and-compile + (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) + (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) + (setq load-prefer-newer t) + (unless (featurep 'gptel) + (defvar gptel-tools nil) + (defun gptel-make-tool (&rest _args) nil) + (defun gptel-get-tool (&rest _args) nil) + (provide 'gptel))) + +(require 'read_text_file) + +;; -------------------------- helpers + +(defun test-gptel-tools-read-text-file--in-home (suffix content fn) + "Run FN with a temp file (containing CONTENT) under HOME using SUFFIX." + (let* ((name (format ".test-gptel-tools-read-text-file-%s-%s.tmp" + suffix (format-time-string "%s%N"))) + (path (expand-file-name name "~"))) + (unwind-protect + (progn + (with-temp-file path (insert content)) + (funcall fn path)) + (when (file-exists-p path) (delete-file path))))) + +;; -------------------------- validate-file-path + +(ert-deftest test-gptel-tools-read-text-file-validate-path-normal () + "Normal: an existing readable file under HOME passes." + (test-gptel-tools-read-text-file--in-home + "normal" "hi" + (lambda (path) + (should (equal (cj/validate-file-path path) (file-truename path)))))) + +(ert-deftest test-gptel-tools-read-text-file-validate-path-error-outside-home () + "Error: path outside HOME signals." + (should-error (cj/validate-file-path "/etc/hostname"))) + +(ert-deftest test-gptel-tools-read-text-file-validate-path-error-missing () + "Error: missing file signals." + (let ((path (expand-file-name + (format ".test-gptel-tools-read-text-file-missing-%s.tmp" + (format-time-string "%s%N")) + "~"))) + (when (file-exists-p path) (delete-file path)) + (should-error (cj/validate-file-path path)))) + +(ert-deftest test-gptel-tools-read-text-file-validate-path-error-directory () + "Error: a directory signals." + (should-error (cj/validate-file-path "~"))) + +;; -------------------------- get-file-metadata + +(ert-deftest test-gptel-tools-read-text-file-get-metadata-shape () + "Returns a plist with :size and :string keys." + (test-gptel-tools-read-text-file--in-home + "meta" "abc" + (lambda (path) + (let ((meta (cj/get-file-metadata path))) + (should (plist-get meta :size)) + (should (= 3 (plist-get meta :size))) + (should (stringp (plist-get meta :string))) + (should (string-match-p "modified" (plist-get meta :string))))))) + +;; -------------------------- check-file-size-limits + +(ert-deftest test-gptel-tools-read-text-file-size-limits-normal () + "Small size below warning limit is a no-op." + (should-not (cj/check-file-size-limits 1024 nil))) + +(ert-deftest test-gptel-tools-read-text-file-size-limits-error-hard-cap () + "Sizes above 100MB always signal." + (should-error (cj/check-file-size-limits (* 101 1024 1024) t)) + (should-error (cj/check-file-size-limits (* 101 1024 1024) nil))) + +(ert-deftest test-gptel-tools-read-text-file-size-limits-warning-with-no-confirm () + "Above 10MB but below 100MB with no-confirm passes through silently." + (should-not (cj/check-file-size-limits (* 11 1024 1024) t))) + +;; -------------------------- detect-binary-file + +(ert-deftest test-gptel-tools-read-text-file-detect-binary-text-file () + "Text file: detect-binary returns nil." + (test-gptel-tools-read-text-file--in-home + "text" "plain ascii content" + (lambda (path) + (should-not (cj/detect-binary-file path))))) + +(ert-deftest test-gptel-tools-read-text-file-detect-binary-with-null-byte () + "File with NUL in first 1024 bytes returns truthy." + (test-gptel-tools-read-text-file--in-home + "bin" (concat "head\0tail") + (lambda (path) + (should (cj/detect-binary-file path))))) + +;; -------------------------- handle-special-file-types + +(ert-deftest test-gptel-tools-read-text-file-handle-special-epub-error () + "EPUB special-type handler signals \"not yet implemented\"." + (should-error (cj/handle-special-file-types "/tmp/foo.epub" t))) + +(ert-deftest test-gptel-tools-read-text-file-handle-special-binary-returns-nil () + "Generic binary file with no-confirm returns nil to indicate normal read." + (should-not (cj/handle-special-file-types "/tmp/foo.bin" t))) + +(provide 'test-gptel-tools-read-text-file) +;;; test-gptel-tools-read-text-file.el ends here diff --git a/tests/test-gptel-tools-write-text-file.el b/tests/test-gptel-tools-write-text-file.el new file mode 100644 index 00000000..258ae8cc --- /dev/null +++ b/tests/test-gptel-tools-write-text-file.el @@ -0,0 +1,141 @@ +;;; test-gptel-tools-write-text-file.el --- Tests for write_text_file gptel tool -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for `cj/write-text-file--run' and its helpers. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(eval-and-compile + (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) + (add-to-list 'load-path (expand-file-name "gptel-tools" user-emacs-directory)) + (setq load-prefer-newer t) + (unless (featurep 'gptel) + (defvar gptel-tools nil) + (defun gptel-make-tool (&rest _args) nil) + (defun gptel-get-tool (&rest _args) nil) + (provide 'gptel))) + +(require 'write_text_file) + +;; ------------------------------------------------------- helpers + +(defun test-gptel-tools-write-text-file--in-home (suffix fn) + "Run FN with a fresh path under HOME using SUFFIX. Clean up after." + (let* ((name (format ".test-gptel-tools-write-text-file-%s-%s.tmp" + suffix (format-time-string "%s%N"))) + (path (expand-file-name name "~"))) + (unwind-protect + (funcall fn path) + (when (file-exists-p path) (delete-file path)) + (dolist (b (file-expand-wildcards (concat path "-*.bak"))) + (when (file-exists-p b) (delete-file b)))))) + +;; --------------------------------------------- validate-path + +(ert-deftest test-gptel-tools-write-text-file-validate-path-normal () + "Normal: returns the expanded path for a HOME-relative input." + (let ((result (cj/write-text-file--validate-path "foo.txt"))) + (should (string-prefix-p (expand-file-name "~") result)) + (should (string-suffix-p "/foo.txt" result)))) + +(ert-deftest test-gptel-tools-write-text-file-validate-path-error-outside-home () + "Error: a path outside HOME signals." + (should-error (cj/write-text-file--validate-path "/etc/hostname"))) + +;; --------------------------------------------- backup-name + +(ert-deftest test-gptel-tools-write-text-file-backup-name-shape () + "Backup names append a YYYY-MM-DD-HHMMSS suffix and .bak." + (let ((name (cj/write-text-file--backup-name "/home/user/foo.txt"))) + (should (string-prefix-p "/home/user/foo.txt-" name)) + (should (string-suffix-p ".bak" name)) + (should (string-match-p "-[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}-[0-9]\\{6\\}\\.bak\\'" + name)))) + +;; --------------------------------------------- ensure-parent + +(ert-deftest test-gptel-tools-write-text-file-ensure-parent-creates-missing () + "Normal: creates missing parent directories." + (let* ((base (make-temp-file "test-gptel-tools-write-text-file-" t)) + (deep (expand-file-name "a/b/c/file.txt" base))) + (unwind-protect + (progn + (cj/write-text-file--ensure-parent deep) + (should (file-directory-p (file-name-directory deep)))) + (delete-directory base t)))) + +(ert-deftest test-gptel-tools-write-text-file-ensure-parent-error-unwritable () + "Error: an unwritable parent signals." + (let* ((parent (make-temp-file "test-gptel-tools-write-text-file-ro-" t)) + (target (expand-file-name "child.txt" parent))) + (unwind-protect + (progn + (set-file-modes parent #o500) + (should-error (cj/write-text-file--ensure-parent target))) + (set-file-modes parent #o700) + (delete-directory parent t)))) + +;; --------------------------------------------- run + +(ert-deftest test-gptel-tools-write-text-file-run-normal () + "Normal: writes new content and returns a status string." + (test-gptel-tools-write-text-file--in-home + "new" + (lambda (path) + (let ((result (cj/write-text-file--run + (file-name-nondirectory path) "hello\n" nil))) + (should (string-match-p "Successfully wrote" result)) + (with-temp-buffer + (insert-file-contents path) + (should (equal (buffer-string) "hello\n"))))))) + +(ert-deftest test-gptel-tools-write-text-file-run-error-existing-no-overwrite () + "Error: existing file without overwrite signals." + (test-gptel-tools-write-text-file--in-home + "existing" + (lambda (path) + (with-temp-file path (insert "old content\n")) + (should-error (cj/write-text-file--run + (file-name-nondirectory path) "new content\n" nil)) + ;; File preserved + (with-temp-buffer + (insert-file-contents path) + (should (equal (buffer-string) "old content\n")))))) + +(ert-deftest test-gptel-tools-write-text-file-run-overwrite-creates-backup () + "Overwrite path makes a timestamped backup before writing." + (test-gptel-tools-write-text-file--in-home + "overwrite" + (lambda (path) + (with-temp-file path (insert "old content\n")) + (cj/write-text-file--run + (file-name-nondirectory path) "new content\n" t) + ;; New content landed + (with-temp-buffer + (insert-file-contents path) + (should (equal (buffer-string) "new content\n"))) + ;; Backup exists with old content + (let ((backups (file-expand-wildcards (concat path "-*.bak")))) + (should (= 1 (length backups))) + (with-temp-buffer + (insert-file-contents (car backups)) + (should (equal (buffer-string) "old content\n"))))))) + +(ert-deftest test-gptel-tools-write-text-file-run-boundary-empty-content () + "Boundary: nil content writes an empty file." + (test-gptel-tools-write-text-file--in-home + "empty" + (lambda (path) + (cj/write-text-file--run (file-name-nondirectory path) nil nil) + (should (file-exists-p path)) + (should (= 0 (file-attribute-size (file-attributes path))))))) + +(ert-deftest test-gptel-tools-write-text-file-run-error-outside-home () + "Error: a path outside HOME signals." + (should-error (cj/write-text-file--run "/etc/test-write.txt" "x" nil))) + +(provide 'test-gptel-tools-write-text-file) +;;; test-gptel-tools-write-text-file.el ends here @@ -2637,18 +2637,39 @@ the install-once guard for the post-response hook, and the save/delete interactive entry points exercised via =cl-letf= stubs. Per-test temp directories; no writes outside them. -*** TODO [#B] Add ERT coverage for gptel-tools .el files :tests: - -The six tool files have zero direct coverage. Focus on the pure helpers (the interactive =gptel-make-tool= entry points get smoke coverage only): - -- =read_text_file.el= — =cj/validate-file-path=, =cj/get-file-metadata=, =cj/check-file-size-limits=, =cj/detect-binary-file=, =cj/handle-special-file-types=. -- =update_text_file.el= — =cj/build-sed-command= (each operation × Normal / Boundary / Error), backup-naming behavior. -- =write_text_file.el= — overwrite-vs-error path, backup naming, parent-directory creation. -- =list_directory_files.el= — =--mode-to-permissions=, =--get-file-info=, =--filter-by-extension=, =--format-file-entry=, recursive vs flat listing on a temp dir. -- =move_to_trash.el= — =gptel--move-to-trash-validate-path= (home/tmp allowed, anywhere else rejected), =gptel--move-to-trash-generate-unique-name= name-conflict suffixing. -- =read_buffer.el= — small smoke test that =read_buffer= returns the body of an existing buffer and errors on a nonexistent name. - -Skip mocking =gptel-make-tool= itself; cover the helpers it wraps. +*** 2026-05-16 Sat @ 01:39:11 -0500 Added ERT coverage for the gptel-tools .el files + +Five new test files cover the five remaining gptel tools beyond +=update_text_file= (which was tested with its rewrite): + +- =tests/test-gptel-tools-read-buffer.el= -- 5 tests for the new + =cj/read-buffer--get-content= helper extracted from the + =gptel-make-tool= lambda. +- =tests/test-gptel-tools-write-text-file.el= -- 10 tests for the + helpers extracted from =write_text_file.el= (validate-path, + backup-name, ensure-parent, run with normal/overwrite/error + paths). +- =tests/test-gptel-tools-read-text-file.el= -- 12 tests for the + pre-existing helpers: =cj/validate-file-path=, + =cj/get-file-metadata=, =cj/check-file-size-limits=, + =cj/detect-binary-file=, =cj/handle-special-file-types=. +- =tests/test-gptel-tools-list-directory-files.el= -- 15 tests for + the =list-directory-files--*= helpers (mode-to-permissions for + files/dirs/executables, get-file-info, extension filter, formatter, + recursive vs flat listing, error path). +- =tests/test-gptel-tools-move-to-trash.el= -- 10 tests for the + =gptel--move-to-trash-*= helpers (unique-name generation with and + without extension, path validation gating HOME and /tmp, critical + directory rejection, perform on files and directories). + +Two small refactors landed first to make the tooling testable: +=read_buffer.el= and =write_text_file.el= had their main bodies +inlined into the =gptel-make-tool= lambdas; I extracted them into +=cj/read-buffer--get-content= and =cj/write-text-file--run= (plus +=--validate-path=, =--backup-name=, =--ensure-parent=) following the +Internal/Wrapper split documented in =elisp-testing.md=. + +52 new tests, all green. *** TODO [#C] Research and shortlist additional gptel tools :feature:research: |
