Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 62 additions & 26 deletions src/clojure_mcp/tools/file_write/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,34 @@
"Core implementation for the file-write tool.
This namespace contains the pure functionality without any MCP-specific code."
(:require
[clojure.java.io :as io]
[clojure-mcp.tools.form-edit.pipeline :as pipeline]
[clojure-mcp.utils.diff :as diff-utils]
[clojure-mcp.linting :as linting]
[clojure-mcp.utils.valid-paths :as valid-paths]
[rewrite-clj.zip :as z]))
[clojure.java.io :as io]
[clojure-mcp.tools.form-edit.pipeline :as pipeline]
[clojure-mcp.utils.diff :as diff-utils]
[clojure-mcp.linting :as linting]
[clojure-mcp.utils.valid-paths :as valid-paths]
[rewrite-clj.zip :as z]))

(defn is-clojure-file?
"Check if a file is a Clojure-related file based on its extension or Babashka shebang.
(defn is-clojure-file?
"Check if a file is a Clojure-related file based on its extension or Babashka shebang.

Parameters:
- file-path: Path to the file to check

Returns true for Clojure extensions (.clj, .cljs, .cljc, .edn, .bb) or files with a `bb` shebang."
[file-path]
(boolean (valid-paths/clojure-file? file-path)))
[file-path]
(boolean (valid-paths/clojure-file? file-path)))

(defn write-clojure-file
"Write content to a Clojure file, with linting, formatting, and diffing.

Parameters:
- file-path: Validated path to the file to write
- content: Content to write to the file
- dry_run: Optional preview mode ('diff' or 'new-source')

Returns:
- A map with :error, :type, :file-path, and :diff keys"
[nrepl-client-atom file-path content]
[nrepl-client-atom file-path content dry_run]
(let [file (io/file file-path)
file-exists? (.exists file)
old-content (if file-exists? (slurp file) "")
Expand All @@ -50,27 +51,47 @@
pipeline/format-source ;; Format the content
pipeline/generate-diff ;; Generate diff between old and new content
pipeline/determine-file-type ;; Determine if creating or updating
pipeline/save-file)] ;; Save the file and get offsets
;; Conditionally skip file save if dry_run is set
(fn [ctx]
(if dry_run
ctx
(pipeline/save-file ctx))))]

;; Format the result for tool consumption
(if (::pipeline/error result)
{:error true
:message (::pipeline/message result)}
{:error false
:type (::pipeline/type result)
:file-path (::pipeline/file-path result)
:diff (::pipeline/diff result)})))
(cond
;; Return new-source for dry_run="new-source"
(= dry_run "new-source")
{:error false
:dry_run "new-source"
:new-source (::pipeline/output-source result)}

;; Return just diff for dry_run="diff"
(= dry_run "diff")
{:error false
:dry_run "diff"
:diff (::pipeline/diff result)}

;; Return full result for normal operation
:else
{:error false
:type (::pipeline/type result)
:file-path (::pipeline/file-path result)
:diff (::pipeline/diff result)}))))

(defn write-text-file
"Write content to a non-Clojure text file, with diffing but no linting or formatting.

Parameters:
- file-path: Validated path to the file to write
- content: Content to write to the file
- dry_run: Optional preview mode ('diff' or 'new-source')

Returns:
- A map with :error, :type, :file-path, and :diff keys"
[file-path content]
[file-path content dry_run]
(try
(let [file (io/file file-path)
file-exists? (.exists file)
Expand All @@ -82,13 +103,27 @@
(diff-utils/generate-unified-diff old-content content))
"")]

;; Write the content directly
(spit file content)
(cond
;; Return new-source if dry_run="new-source"
(= dry_run "new-source")
{:error false
:dry_run "new-source"
:new-source content}

;; Return just diff for dry_run="diff"
(= dry_run "diff")
{:error false
:dry_run "diff"
:diff diff}

{:error false
:type (if file-exists? "update" "create")
:file-path file-path
:diff diff})
;; Normal operation - write and return full result
:else
(do
(spit file content)
{:error false
:type (if file-exists? "update" "create")
:file-path file-path
:diff diff})))
(catch Exception e
{:error true
:message (str "Error writing file: " (.getMessage e))})))
Expand All @@ -101,10 +136,11 @@
Parameters:
- file-path: Validated path to the file to write
- content: Content to write to the file
- dry_run: Optional preview mode ('diff' or 'new-source')

Returns:
- A map with :error, :type, :file-path, and :diff keys"
[nrepl-client-atom file-path content]
[nrepl-client-atom file-path content dry_run]
(if (is-clojure-file? file-path)
(write-clojure-file nrepl-client-atom file-path content)
(write-text-file file-path content)))
(write-clojure-file nrepl-client-atom file-path content dry_run)
(write-text-file file-path content dry_run)))
28 changes: 21 additions & 7 deletions src/clojure_mcp/tools/file_write/tool.clj
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Before using this tool:
:required [:file_path :content]})

(defmethod tool-system/validate-inputs :file-write [{:keys [nrepl-client-atom]} inputs]
(let [{:keys [file_path content]} inputs
(let [{:keys [file_path content dry_run]} inputs
nrepl-client (and nrepl-client-atom @nrepl-client-atom)]
(when-not file_path
(throw (ex-info "Missing required parameter: file_path" {:inputs inputs})))
Expand All @@ -88,10 +88,11 @@ Before using this tool:

;; Return validated inputs with normalized path
{:file-path validated-path
:content content})))
:content content
:dry_run dry_run})))

(defmethod tool-system/execute-tool :file-write [{:keys [nrepl-client-atom]} inputs]
(let [{:keys [file-path content]} inputs
(let [{:keys [file-path content dry_run]} inputs
;; Capture original content - empty string for new files
_ (when nrepl-client-atom
(let [file (io/file file-path)]
Expand All @@ -105,18 +106,31 @@ Before using this tool:
nrepl-client-atom
file-path
""))))
result (core/write-file nrepl-client-atom file-path content)]
result (core/write-file nrepl-client-atom file-path content dry_run)]
;; Update the timestamp if write was successful and we have a client atom
(when (and nrepl-client-atom (not (:error result)))
(when (and nrepl-client-atom (not (:error result)) (not dry_run))
(file-timestamps/update-file-timestamp-to-current-mtime! nrepl-client-atom file-path))
result))

(defmethod tool-system/format-results :file-write [_ result]
(if (:error result)
(cond
;; If there's an error, return the error message
(:error result)
{:result [(:message result)]
:error true}
;; Otherwise, format a successful result

;; Check if this is a dry_run with new-source
(:new-source result)
{:result [(:new-source result)]
:error false}

;; Check if this is a dry_run with diff only (no file-path)
(and (:diff result) (not (:file-path result)))
{:result [(:diff result)]
:error false}

;; Otherwise, format a successful result with preamble
:else
(let [file-type (if (core/is-clojure-file? (:file-path result)) "Clojure" "Text")
response (str file-type " file " (:type result) "d: " (:file-path result))]
(if (seq (:diff result))
Expand Down
28 changes: 18 additions & 10 deletions test/clojure_mcp/tools/file_write/core_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
(testing "Creating a new text file"
(let [new-file (io/file *test-dir* "new-text-file.txt")
content "This is a brand new file."
result (file-write-core/write-text-file (.getPath new-file) content)]
result (file-write-core/write-text-file (.getPath new-file) content nil)]
(is (not (:error result)))
(is (= "create" (:type result)))
(is (= (.getPath new-file) (:file-path result)))
Expand All @@ -79,7 +79,7 @@
(let [path (.getPath *test-txt-file*)
original-content (slurp *test-txt-file*)
new-content "This is updated content.\nWith new lines."
result (file-write-core/write-text-file path new-content)]
result (file-write-core/write-text-file path new-content nil)]
(is (not (:error result)))
(is (= "update" (:type result)))
(is (= path (:file-path result)))
Expand All @@ -93,7 +93,8 @@
result (file-write-core/write-clojure-file
test-utils/*nrepl-client-atom*
(.getPath new-file)
content)]
content
nil)]
(is (not (:error result)))
(is (= "create" (:type result)))
(is (= (.getPath new-file) (:file-path result)))
Expand All @@ -107,7 +108,8 @@
result (file-write-core/write-clojure-file
test-utils/*nrepl-client-atom*
path
new-content)]
new-content
nil)]
(is (not (:error result)))
(is (= "update" (:type result)))
(is (= path (:file-path result)))
Expand All @@ -120,7 +122,8 @@
result (file-write-core/write-clojure-file
test-utils/*nrepl-client-atom*
path
unformatted-content)]
unformatted-content
nil)]
(is (not (:error result)))
(is (= "update" (:type result)))
(is (not (str/includes? (slurp *test-clj-file*) "poorly-formatted-fn[x]")))
Expand All @@ -132,7 +135,8 @@
result (file-write-core/write-clojure-file
test-utils/*nrepl-client-atom*
path
content-with-missing-paren)]
content-with-missing-paren
nil)]
(is (not (:error result)))
(is (= "update" (:type result)))
(let [saved-content (slurp *test-clj-file*)]
Expand All @@ -146,7 +150,8 @@
result (file-write-core/write-clojure-file
test-utils/*nrepl-client-atom*
path
content-with-mismatched-brackets)]
content-with-mismatched-brackets
nil)]
(is (not (:error result)))
(is (= "update" (:type result)))
(let [saved-content (slurp *test-clj-file*)]
Expand All @@ -161,7 +166,8 @@
result (file-write-core/write-clojure-file
test-utils/*nrepl-client-atom*
path
content-with-syntax-error)]
content-with-syntax-error
nil)]
;; The test should fail with a specific error
(is (:error result) "Should have error for non-repairable syntax error")
(when (:message result)
Expand All @@ -176,7 +182,8 @@
content "(ns dispatch.test)"
result (file-write-core/write-file test-utils/*nrepl-client-atom*
(.getPath clj-file)
content)]
content
nil)]
(is (not (:error result)))
(is (= "create" (:type result)))
(is (.exists clj-file))
Expand All @@ -187,7 +194,8 @@
content "Plain text content"
result (file-write-core/write-file test-utils/*nrepl-client-atom*
(.getPath txt-file)
content)]
content
nil)]
(is (not (:error result)))
(is (= "create" (:type result)))
(is (.exists txt-file))
Expand Down