diff options
Diffstat (limited to 'gptel-tools/update_text_file.el')
| -rw-r--r-- | gptel-tools/update_text_file.el | 235 |
1 files changed, 0 insertions, 235 deletions
diff --git a/gptel-tools/update_text_file.el b/gptel-tools/update_text_file.el deleted file mode 100644 index f8b58025..00000000 --- a/gptel-tools/update_text_file.el +++ /dev/null @@ -1,235 +0,0 @@ -;;; update_text_file.el --- Update text files for gptel -*- lexical-binding: t; -*- - -;; Author: Craig Jennings <c@cjennings.net> -;; Keywords: convenience, tools - -;; This file is not part of GNU Emacs. - -;;; Commentary: - -;; Gptel tool for updating an existing text file with one of five -;; operations: -;; -;; replace Replace all occurrences of PATTERN with REPLACEMENT. -;; append Add TEXT at the end of the file. -;; prepend Add TEXT at the beginning of the file. -;; insert-at-line Insert TEXT at LINE-NUM (1-indexed). -;; delete-lines Delete every line containing PATTERN. -;; -;; The operations are pure-string transforms — file I/O happens only at -;; the outer wrapper, which validates the path, takes a timestamped -;; backup, and writes the new content atomically. The tool uses gptel's -;; `:confirm t' meta-flag for the user-facing prompt, mirroring how -;; `write_text_file' handles confirmation. -;; -;; PATTERN is a literal substring for `replace' and `delete-lines'. No -;; regex. The model can build literal multi-line patterns and we don't -;; want it to discover regex metacharacter gotchas through trial and -;; error. - -;;; Code: - -(require 'gptel) -(require 'subr-x) -(require 'cl-lib) - -;; ---------------------------------------------------------------- helpers - -(defun cj/update-text-file--validate-path (path) - "Validate PATH for update. Return the truename on success. - -PATH must resolve inside the user's home directory, must exist, must -be a regular file, and must be readable and writable." - (let* ((home (file-name-as-directory (file-truename (expand-file-name "~")))) - (full (expand-file-name path "~"))) - (unless (string-prefix-p (expand-file-name "~") full) - (error "Path must be within home directory: %s" path)) - (unless (file-exists-p full) - (error "File not found: %s" full)) - (let ((resolved (file-truename full))) - (unless (or (string= resolved (directory-file-name home)) - (string-prefix-p home resolved)) - (error "Resolved path must be within home directory: %s" path)) - (when (file-directory-p resolved) - (error "Path is a directory, not a file: %s" resolved)) - (unless (file-readable-p resolved) - (error "No read permission for file: %s" resolved)) - (unless (file-writable-p resolved) - (error "No write permission for file: %s" resolved)) - resolved))) - -(defun cj/update-text-file--backup-name (path) - "Return a backup filename for PATH timestamped to the current second." - (format "%s-%s.bak" path (format-time-string "%Y-%m-%d-%H%M%S"))) - -(defconst cj/update-text-file--size-limit (* 10 1024 1024) - "Reject files larger than 10MB so a runaway operation can't churn the disk.") - -;; ----------------------------------------------------- string transforms -;; -;; Each transform takes the file contents as a string plus operation -;; parameters and returns the new contents. Pure functions — no I/O. - -(defun cj/update-text-file--replace (content pattern replacement) - "Return CONTENT with every occurrence of PATTERN replaced by REPLACEMENT. -PATTERN is treated as a literal substring. Signal an error if PATTERN is -empty or nil." - (unless (and (stringp pattern) (> (length pattern) 0)) - (error "Replace operation requires a non-empty pattern")) - (unless (stringp replacement) - (error "Replace operation requires a replacement string")) - (replace-regexp-in-string (regexp-quote pattern) replacement content t t)) - -(defun cj/update-text-file--append (content text) - "Return CONTENT with TEXT added at the end, separated by a newline. -A trailing newline is guaranteed. Signal if TEXT is nil or empty." - (unless (and (stringp text) (> (length text) 0)) - (error "Append operation requires non-empty text")) - (let ((base (if (or (string-empty-p content) - (string-suffix-p "\n" content)) - content - (concat content "\n")))) - (if (string-suffix-p "\n" text) - (concat base text) - (concat base text "\n")))) - -(defun cj/update-text-file--prepend (content text) - "Return CONTENT with TEXT added at the beginning. -TEXT is separated from CONTENT by a newline. Signal if TEXT is nil -or empty." - (unless (and (stringp text) (> (length text) 0)) - (error "Prepend operation requires non-empty text")) - (if (string-suffix-p "\n" text) - (concat text content) - (concat text "\n" content))) - -(defun cj/update-text-file--insert-at-line (content line-num text) - "Return CONTENT with TEXT inserted before LINE-NUM (1-indexed). -LINE-NUM 1 prepends. LINE-NUM one past the last line appends. Signal -on out-of-range LINE-NUM or empty TEXT." - (unless (and (integerp line-num) (> line-num 0)) - (error "Insert-at-line requires a positive integer line number")) - (unless (and (stringp text) (> (length text) 0)) - (error "Insert-at-line requires non-empty text")) - (let* ((lines (split-string content "\n")) - ;; `split-string' on a newline-terminated string returns an - ;; extra empty element at the end. Trim it so the line count - ;; matches what a human would say. - (trailing-newline (string-suffix-p "\n" content)) - (line-count (cond - ((string-empty-p content) 0) - (trailing-newline (1- (length lines))) - (t (length lines))))) - (when (> line-num (1+ line-count)) - (error "Line %d out of range (file has %d lines)" line-num line-count)) - (let* ((to-insert (if (string-suffix-p "\n" text) - (substring text 0 (1- (length text))) - text)) - (idx (1- line-num)) - (head (cl-subseq lines 0 idx)) - (tail (cl-subseq lines idx))) - (mapconcat #'identity - (append head (list to-insert) tail) - "\n")))) - -(defun cj/update-text-file--delete-lines (content pattern) - "Return CONTENT with every line containing PATTERN removed. -PATTERN is a literal substring. Trailing-newline state is preserved -when at least one line survives; an empty result is returned as the -empty string." - (unless (and (stringp pattern) (> (length pattern) 0)) - (error "Delete-lines requires a non-empty pattern")) - (let* ((trailing-newline (string-suffix-p "\n" content)) - (raw-lines (split-string content "\n")) - ;; Drop the trailing empty element split-string produces when - ;; the input ends in a newline. - (lines (if trailing-newline - (butlast raw-lines) - raw-lines)) - (kept (cl-remove-if (lambda (line) - (string-match-p (regexp-quote pattern) line)) - lines))) - (cond - ((null kept) "") - (trailing-newline (concat (mapconcat #'identity kept "\n") "\n")) - (t (mapconcat #'identity kept "\n"))))) - -(defun cj/update-text-file--apply-operation - (content operation pattern replacement line-num) - "Dispatch OPERATION on CONTENT. Return the transformed string. - -OPERATION is one of \"replace\", \"append\", \"prepend\", -\"insert-at-line\", or \"delete-lines\". PATTERN, REPLACEMENT, and -LINE-NUM are used per operation; unused arguments are ignored." - (pcase operation - ("replace" (cj/update-text-file--replace content pattern replacement)) - ("append" (cj/update-text-file--append content pattern)) - ("prepend" (cj/update-text-file--prepend content pattern)) - ("insert-at-line" (cj/update-text-file--insert-at-line content line-num pattern)) - ("delete-lines" (cj/update-text-file--delete-lines content pattern)) - (_ (error "Unknown operation: %s" operation)))) - -;; ----------------------------------------------------- file-level wrapper - -(defun cj/update-text-file--run (path operation pattern replacement line-num) - "Update PATH with OPERATION and return a status string. - -PATTERN, REPLACEMENT, and LINE-NUM are passed through per operation. -A timestamped backup is created next to the file before writing. If -the operation produces no change the backup is removed and the file -is left untouched." - (let* ((full (cj/update-text-file--validate-path path)) - (size (file-attribute-size (file-attributes full)))) - (when (> size cj/update-text-file--size-limit) - (error "File too large (%s): exceeds 10MB limit" - (file-size-human-readable size))) - (let* ((before (with-temp-buffer - (insert-file-contents full) - (buffer-string))) - (after (cj/update-text-file--apply-operation - before operation pattern replacement line-num))) - (cond - ((string= before after) - (format "No changes made to %s" full)) - (t - (let ((backup (cj/update-text-file--backup-name full))) - (copy-file full backup t) - (with-temp-file full (insert after)) - (format "Updated %s (backup: %s)" - full (file-name-nondirectory backup)))))))) - -;; ----------------------------------------------------- tool registration - -(with-eval-after-load 'gptel - (gptel-make-tool - :name "update_text_file" - :function (lambda (path operation &optional pattern replacement line-num) - (cj/update-text-file--run path operation pattern replacement line-num)) - :description "Update an existing text file with one of: replace, append, prepend, insert-at-line, delete-lines. Creates a timestamped backup before writing. Patterns are literal substrings, not regex." - :args (list '(:name "path" - :type string - :description "File path relative to home directory, e.g. 'documents/foo.txt' or '~/documents/foo.txt'") - '(:name "operation" - :type string - :enum ["replace" "append" "prepend" "insert-at-line" "delete-lines"] - :description "Which update operation to perform") - '(:name "pattern" - :type string - :description "For replace/delete-lines: the literal substring to match. For append/prepend/insert-at-line: the text to add. Required for every operation." - :optional t) - '(:name "replacement" - :type string - :description "For replace: the literal replacement text. Ignored by other operations." - :optional t) - '(:name "line_num" - :type integer - :description "For insert-at-line: 1-indexed line number to insert before. Ignored by other operations." - :optional t)) - :category "filesystem" - :confirm t - :include t) - - (add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "update_text_file")))) - -(provide 'update_text_file) -;;; update_text_file.el ends here |
